Somnia Accounts / Reference

Account derivation

How an account's address is computed, offline, before anything exists.

The formula

salt    = keccak256(abi.encode(accountId, index))
address = CREATE3(factory, salt)

accountId is any address. index is which account under it — any uint256, not a small counter: the factory reserves nothing and counts nothing, so index 2**256 - 1 is as valid as 0. That is the whole input — no owner list, no bytecode, no nonce, no chain.

The scan bounds elsewhere (maxIndex, MAX_SCAN_WIDTH) are limits on how much you look, not on what exists. Nothing on chain enumerates an accountId's accounts, so if your application hands out indices from a sparse or hashed space, only you know where to look.

What follows from it

The address is known before the account exists. No RPC, no round-trip: it can be on screen before a single transaction, and funds can arrive at it beforehand.

The address is the same on every chain. CREATE3 derives from the deployer and the salt, and the factory is at one address everywhere (see Addresses and chains). One key, one index, one address, all three networks.

The address survives owner rotation. Owners are Safe state; the address is not derived from them. Rotate every owner and the account stays where it is — and stays derivable from the accountId, which is what makes recovery possible at all. Under a CREATE2 recipe the address is only reproducible from the original setup() calldata, so an account whose owners were rotated is an account you can no longer find from a key you hold.

The owner cannot be injected. createAccount takes no owner list: the factory forces the owner from the salt. So a third party cannot deploy your account with themselves as owner, which is why creation is safe to leave permissionless and why anyone may pay for it.

Computing it

import {
  accountAddress,
  accountSalt,
  predictAccountAddress,
  readAccountAddress,
} from "@somnia-chain/accounts-sdk";

accountAddress(chainId, accountId, 0n); // offline; resolves the factory from the manifests
accountSalt(accountId, 0n); // just the salt
predictAccountAddress(await Promise.resolve(account), accountId, 0n); // against a factory you name
await readAccountAddress(client, chainId, accountId, 0n); // what the deployed factory says

accountAddress is the one to reach for. predictAccountAddress exists for a caller that resolved the factory itself or is testing against a substitute; readAccountAddress exists to check the offline derivation against the chain, which is worth doing once in a test rather than on every render.

CREATE3_PROXY_INITCODE_HASH is exported for a caller reimplementing the derivation in another language. The TypeScript derivation is pinned against values the contracts produced.

The two properties the formula buys, as assertions rather than prose:

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

const KEY = "0x000000000000000000000000000000000000dEaD" as const;

// The same on every chain, because CREATE3 derives from the deployer and the salt.
accountAddress(5031, KEY, 0n); // => accountAddress(50312, KEY, 0n)
accountAddress(50383, KEY, 0n); // => accountAddress(5031, KEY, 0n)

// And a different index is a different account.
accountAddress(50312, KEY, 1n) === accountAddress(50312, KEY, 0n); // => false

Index zero is the main account

Index 0 is conventionally "the account for this key". Everything above it is a sub-account, and subAccountAddress(chainId, mainAccount, index) is the same function with the vocabulary of one:

import {accountAddress, subAccountAddress} from "@somnia-chain/accounts-sdk";

const main = accountAddress(chainId, owner, 0n);
const desk = subAccountAddress(chainId, main, 1n); // an account whose accountId is an account
void [main, desk];

A sub-account is not a different kind of thing. It is an ordinary account whose accountId happens to be an account rather than a key — see About accounts owning accounts.

Attributing an address you were given

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

// Offline proof of which key and index produced an address — `undefined` when none of them did.
const derivation = attributeAddress(chainId, account, [owner, sessionKey], {maxIndex: 8n});
if (derivation !== undefined) {
  derivation.accountId; // which candidate
  derivation.index; // and at which index
}

Useful for "is this address one of ours" without trusting a label, and for showing a user why an address is theirs.

maxIndex defaults to 19n, so the search covers the first twenty indices per candidate. A negative answer is therefore "not in the first twenty under any of these keys", not "not theirs" — widen it if your application hands out higher indices.

Finding what exists

Derivation tells you where an account would be; eth_getCode tells you whether it is there. accountsOf, accountsAt and accountsInRange do both, and report three distinct states — present, absent, and read did not answer — because an unanswered read rendered as "nothing there" shows a funded account as empty. See How to find an account.

Scans are capped at MAX_SCAN_WIDTH (1000n) indices per call: each index is an eth_getCode, so a range is a request count with a different name. accountsInRange(0n, 2n ** 32n) from a typo does not fail — it hangs, and rate-limits everything else the app is doing.

Edit this page