Somnia Accounts / Reference

Account authorization

The EIP-712 surface that creates accounts. Its domain deliberately omits chainId, which is the whole point: one signature reaches every chain.

Why the domain has no chain id

Creating an account and granting it a second owner used to cost one owner signature per chain, and the reason was entirely in the grant: Safe's EIP-712 domain contains the chain id, so the same consent decision is a different message on every network. Three chains today means three prompts on a first run.

AccountAuthorization is signed against a domain of {name, version, verifyingContract} and nothing else. The factory is at the same address on every chain, so the signature verifies everywhere — and createAccountWithOwners both deploys and sets the owners in one transaction, so there is no moment in between where a second prompt could be needed.

The message

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

function readIt(a: AccountAuthorization) {
  a.accountId; // the owner EOA the addresses derive from, and the address that signs
  a.indices; // which of that owner's accounts. One entry per account the signature creates
  a.accounts; // the addresses those indices derive to, same order — signed, and asserted on chain
  a.additionalOwners; // the owners granted alongside `accountId`. Never the complete set
  a.threshold; // the threshold the account is set up with
  a.issuedAt; // unix seconds, signed — so the window bounds the message, not its submission
  a.deadline; // unix seconds, the last moment it may be used on any chain
}
void readIt;

accounts is in the message and re-derived on chain against each index before anything is deployed. A signature cannot be replayed against a different set of addresses than the one its signer was shown.

Windows

Constant Value Means
DEFAULT_AUTHORIZATION_WINDOW 900n 15 minutes. What buildAccountAuthorization uses.
MAX_AUTHORIZATION_WINDOW 18_000n 5 hours. What every chain accepts, as a hard ceiling.

The ceiling is measured from the signed issuedAt, not from submission. So an over-long window is refused forever rather than merely early — the factory will never accept it, on any chain, at any time. buildAccountAuthorization refuses it offline for that reason, before anyone is asked to sign a message that cannot work.

A forward-dated issuedAt is a scheduled authorization rather than an invalid one: the contract refuses it until that moment arrives. Which is exactly why it is worth reading before signing.

import {
  buildAccountAuthorization,
  DEFAULT_AUTHORIZATION_WINDOW,
  MAX_AUTHORIZATION_WINDOW,
  InvalidInputError,
} from "@somnia-chain/accounts-sdk";

DEFAULT_AUTHORIZATION_WINDOW; // => 900n
MAX_AUTHORIZATION_WINDOW; // => 18_000n

const base = {
  chainId: 50312,
  accountId: "0x000000000000000000000000000000000000dEaD",
  additionalOwners: [],
  now: 1_700_000_000n,
} as const;

// Over the ceiling is refused offline, before anyone signs something no chain will accept.
buildAccountAuthorization({...base, indices: [0n], window: MAX_AUTHORIZATION_WINDOW + 1n}); // => throws InvalidInputError

// And naming no index at all is refused for the same reason — on chain it is `NoIndices`,
// after the gas, after somebody approved a message authorising nothing.
buildAccountAuthorization({...base, indices: []}); // => throws InvalidInputError

Building and signing one

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

const auth = buildAccountAuthorization({
  chainId, // only used to find the factory — the same address on every chain
  accountId: owner,
  indices: [0n, 1n, 2n], // a main account and two sub-accounts, one signature
  additionalOwners: [sessionKey],
  threshold: 1n, // the default, and what a session key needs
  now: BigInt(Math.floor(Date.now() / 1000)),
});

const creation = prepareAccountCreation({authorization: auth, chainId});
const sig = await signWith(signer, creation);
void sig;

prepareAccountCreation sets signer to authorization.accountId, and only that address can sign: the factory recovers against exactly the address the accounts derive from. A session key cannot stand in, however convenient that would be — a new account has no session key yet, which is what the authorization is for.

Who sends it is open, and usually a relayer.

Fanning it out

import {createAccountWithOwnersCalls, prepareAccountCreation} from "@somnia-chain/accounts-sdk";

const creation = prepareAccountCreation({authorization, chainId});

// One signature, one call per chain. Returned as DATA rather than sent: each chain needs its own
// funded sender, and a chain that fails is retried rather than rolling the others back.
const perChain = creation.toCalls(signature, [5031, 50312, 50383]);
void [perChain, createAccountWithOwnersCalls];

What this does not remove is one gas balance per chain. The signature costs nothing and reaches everywhere; the transactions still need a funded payer on each network, and that is the part that actually fails. Report per chain, and read the owner list back rather than trusting the call — an account someone else created first comes back deployed, with the granted key missing and no error anywhere.

Checking one before you trust it

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

// Offline. Returns the problems, so a broadcaster can refuse before spending gas.
const problems = checkAccountAuthorization(
  {factory: account, authorization, signature},
  {now: BigInt(Math.floor(Date.now() / 1000)), chainId},
);
for (const problem of problems) {
  problem.kind; // "expired" | "window" | "notYetValid" | "empty" | "length" | "derivation" | "factory"
}

An empty array means every offline check passed. It does not mean the signature is accountId's. There is deliberately no signer problem variant: recovery is async, and an EIP-1271 signer answers on chain and may answer differently per chain and over time — a variant nothing produces would read as a check this function performs. So a sponsor that trusts problems.length === 0 alone will pay for authorizations signed by anybody. recoverAuthorizationSigner is the second half, and both are needed.

What checkAccountAuthorization covers, as a tagged union you can branch on:

kind Means Worth retrying?
expired the deadline has passed only with a fresh signature
window the deadline sits further past issuedAt than any chain accepts never — malformed, and the answer is the same today and in a year
notYetValid issuedAt has not arrived yes, from issuedAt onwards
empty no indices at all; the contract's NoIndices no
length indices and accounts differ in length; ListLengthMismatch no — the message was built by hand rather than through buildAccountAuthorization
derivation a signed account address is not what its index derives to; WrongAccount no
factory signed against a different factory no

notYetValid is the one to surface rather than discard. A signature nobody meant to schedule is the shape a phished authorization takes, and it is a scheduled row, not a dead one.

derivation names the position and the index as well as the two addresses, because a batch has several and "one of them is wrong" is not something a caller can act on.

Run it before every broadcast, not once after signing. A fan-out is N transactions over minutes against a deadline measured in minutes, and each of these failures costs a transaction that reverts after the gas is spent — while looking, from the outside, like the chain rejected a legitimate signature.

Recovering the signer

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

// Handles both shapes the factory accepts from an EOA: a plain EIP-712 signature, and the digest
// signed as a message with `v + 4` — Safe's convention, and the escape hatch for a wallet that
// refuses typed data whose domain it cannot validate.
const who = await recoverAuthorizationSigner({factory: account, authorization, signature});
void who;

Not for an EIP-1271 signer. When accountId is itself an account there is nothing to recover — the parent answers isValidSignature on the chain being reached, and the answer can differ per chain and over time. It throws InvalidInputError on anything that is not 65 bytes, which is every case it cannot answer; ask the chain instead.

Storing one

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

const row = serializeAccountAuthorization({factory: account, authorization, signature});
const back = deserializeAccountAuthorization(row);
void back;

bigint does not survive JSON.stringify, so these exist rather than leaving every consumer to invent a representation that disagrees with the next one's. The serialized form carries the factory, so a stored row is self-contained: a broadcaster can verify and send it without resolving a manifest, and a row signed against a different deployment cannot be silently replayed against this one.

The lower-level pieces

Function For
accountAuthorizationTypedData the full EIP-712 payload
accountAuthorizationPayload the payload prepareAccountCreation signs
accountAuthorizationHash the digest, for a wallet that will not render typed data
authorizationDomain the domain, to show that it has no chainId
ACCOUNT_AUTHORIZATION_TYPES the type definitions
encodeCreateAccountWithOwners calldata only, for a caller assembling the transaction itself

Edit this page