Somnia Accounts / How-to guides

How to create accounts on every chain with one signature

This guide shows you how to take one owner signature and use it to create accounts on every chain the factory is deployed on. It assumes you have the owner's wallet and a funded payer on each chain you want to reach.

Why one signature is possible

The naive flow costs one prompt per chain, and the cost is entirely in granting the session key: Safe's EIP-712 domain contains the chain id, so this key may act for me is a different message on every network.

AccountAuthorization is signed against a domain with no chainId, and createAccountWithOwners deploys the account and sets its owners in one transaction. So the consent decision is made once, and there is never a moment where the account exists without its session key.

Do it

import {
  buildAccountAuthorization,
  createAccountsGasLimit,
  prepareAccountCreation,
  signWith,
  submitCall,
} from "@somnia-chain/accounts-sdk";

// 1. Build the message. `indices` is every account this signature should create.
const authorization = buildAccountAuthorization({
  chainId: 50312, // only used to find the factory, which is the same address everywhere
  accountId: owner,
  indices: [0n, 1n, 2n], // the main account and two sub-accounts
  additionalOwners: [sessionKey],
  now: BigInt(Math.floor(Date.now() / 1000)),
});

// 2. One prompt. Only `accountId` can sign this.
const creation = prepareAccountCreation({authorization, chainId: 50312});
const sig = await signWith(signer, creation);

// 3. One call per chain, as data. Send each with its own funded payer.
const gas = createAccountsGasLimit(authorization.indices.length);
for (const perChain of creation.toCalls(sig, [5031, 50312, 50383])) {
  await submitCall(client, {
    chain,
    sender,
    call: perChain,
    gas,
    describe: "The create transaction",
  });
}

toCalls returns data rather than sending, because each chain needs its own funded sender and a chain that fails is retried rather than rolling the others back.

Check the chain accepts a batch before you collect the signature

The factory is upgradeable, sits behind one proxy address on every chain, and the batch entry point did not always exist. So "which version is live here" is per chain and changes at whatever pace a rollout takes. A client that assumes the new one collects a signature, sends it, and gets a revert with no data in it — after the person has already approved something.

import {readFactoryImplementation, supportsBatchCreation} from "@somnia-chain/accounts-sdk";

if (!(await supportsBatchCreation(client, chainId))) {
  // This chain is on an older implementation: `createAccountWithOwners` is not there. Fall back
  // to create-then-grant (below), or skip the chain and say so.
  const impl = await readFactoryImplementation(client, chainId);
  console.warn(`chain ${chainId} factory is ${impl}, no batch authorization`);
}

It is a view probe, so it costs one eth_call and no gas. A read that did not answer is not an answer: only a contract-level revert means "the old factory", while a timeout or a rate limit is rethrown — so a caller can say "could not check" rather than reporting a chain as un-upgraded because the RPC blinked.

All three deployed chains accept batches today. Check anyway if you span chains you do not control the rollout of.

Creating one account, without an authorization

The two-step flow still exists and is still the right one when there is no second key to grant — a bot that owns its account outright, say. It is permissionless and needs no signature at all:

import {createAccountCall, submitCall} from "@somnia-chain/accounts-sdk";

// The whole transaction, ready to send from any EOA that holds gas.
const create = createAccountCall(chainId, owner, 0n);

const {receipt} = await submitCall(client, {
  chain,
  sender, // anyone. The factory forces the owner from the salt, so a payer cannot inject one
  call: create,
  gas: create.gas, // carried on the call, because this limit cannot be estimated
  describe: "The create transaction",
});
void receipt;

createAccountCall returns a CreateAccountCall — a Call that carries its own gas, because the limit it needs is not something a caller can derive. encodeCreateAccount gives you the calldata alone if you are assembling the transaction yourself.

Idempotent. An account that already exists is returned rather than redeployed, so a racing sponsor and user do not make one of them fail.

What this flow costs you is a second transaction and a second signature per chain to grant the session key — which is the whole reason the authorization exists.

Report per chain, and read the owners back

The signature reaches everywhere. The transactions do not — they need gas on each network, and that is the part that actually fails.

Worse: createAccount is permissionless, so someone else may have created the account first. It comes back deployed, with your session key missing, and no error anywhere. So do not trust the call:

import {ownersOf} from "@somnia-chain/accounts-sdk";

const set = await ownersOf(client, account);
const granted = set.owners.some((o) => o.address.toLowerCase() === sessionKey.toLowerCase());
if (!granted) {
  // The account exists but your key is not on it. Someone created it from the same accountId.
  // It is still owned by that accountId alone — no theft — but you need a grant transaction.
}

Gas

createAccountsGasLimit(count), where count is how many indices the authorization names, including any that turn out to be taken already. A skip is cheap and nobody has to know offline which indices are free.

Sending a batch with the single-account limit runs out partway through, and the failure is silent: the send succeeds, the hash is real, the receipt says nothing was created. Assert receipt.status — submitCall does.

The block gas limit is a hard ceiling. A batch too large for one block has to be split into several calls, which the same signature covers.

Windows

The authorization is valid for 15 minutes by default (DEFAULT_AUTHORIZATION_WINDOW), and no chain accepts a window over 5 hours (MAX_AUTHORIZATION_WINDOW) measured from the signed issuedAt. An over-long window is therefore refused forever rather than merely early — buildAccountAuthorization refuses it offline so nobody signs a message that cannot work.

If the fan-out may take longer than the window — a queue, a manual approval — widen window deliberately rather than re-prompting.

Letting something else drain it

The signature is a bearer credential for exactly what it says. Store it and let a worker do the sending:

import {
  checkAccountAuthorization,
  factoryAddress,
  serializeAccountAuthorization,
} from "@somnia-chain/accounts-sdk";

// The factory travels with the row, so the worker needs no manifest — and a row signed against a
// different deployment cannot be silently replayed against this one.
const signed = {factory: factoryAddress(chainId), authorization, signature};
const row = serializeAccountAuthorization(signed);

// …later, in the worker, before spending anything:
const problems = checkAccountAuthorization(signed, {
  now: BigInt(Math.floor(Date.now() / 1000)),
  chainId,
});
void [row, problems];

bigint does not survive JSON.stringify, which is why the serializers exist.

Edit this page