Somnia Accounts / How-to guides
How to find an account
This guide shows you how to go from a key you hold to the accounts it controls, and from an account to who controls it. It assumes a read-only client; none of this needs a wallet.
None of this needs the indexer
Accounts are derived, not discovered. From a key and an index you compute the address offline,
and eth_getCode tells you whether anything is there. So the answer to "what accounts does this key
have" is a derivation plus a batch of reads — no indexer, no event history.
What is unreachable this way: whose account a bare address is, and history — when an account was created, who paid, what it did. Those reads exist too, and How to use the indexer is their guide.
From a key to its accounts
import {accountsOf} from "@somnia-chain/accounts-sdk";
// Indices 0..maxIndex, which defaults to 19n — twenty addresses, twenty `eth_getCode` calls.
const scan = await accountsOf(client, chainId, owner, {maxIndex: 19n});
for (const slot of scan.slots) {
slot.index;
slot.address; // derived, correct whether or not anything is deployed there
slot.deployed; // true | false | undefined <- three states, see below
}
scan.requests; // how many reads this cost
maxIndex is a scan bound, not a property of the factory. The factory reserves nothing and
counts nothing — index 7 is simply the eighth address that accountId may occupy — so a scan can
always be wrong about "no more accounts".
Say "none in the first twenty", never "none". describeCoverage(scan) turns the scan into
exactly that sentence, so a UI does not have to phrase it itself:
import {accountsOf, describeCoverage} from "@somnia-chain/accounts-sdk";
const scan = await accountsOf(client, chainId, owner);
console.log(`checked ${describeCoverage(scan)}`); // e.g. "indices 0 to 19"
deployed has three values and they are all distinct product states.
| Value | Means |
|---|---|
true |
code is present |
false |
the address is empty — not an error. The address is real and funds can arrive before the account exists |
undefined |
the read did not answer. Not an empty slot |
Rendering undefined as "nothing there" shows an existing, funded account as empty. That is the
single most consequential mistake in this area, which is why the type makes you handle it.
Specific indices, or a range
import {
accountsAt,
accountsInRange,
rangeIndices,
MAX_SCAN_WIDTH,
} from "@somnia-chain/accounts-sdk";
await accountsAt(client, chainId, owner, [0n, 5n, 12n]);
await accountsInRange(client, chainId, owner, {from: 0n, count: 50n});
void [rangeIndices, MAX_SCAN_WIDTH];
Capped at MAX_SCAN_WIDTH (1000n) per call, because each index is an eth_getCode and a range is
a request count with a different name. accountsInRange with a typo'd width does not fail — it
hangs, and rate-limits everything else your app is doing. Page instead.
The first free index
import {accountsOf, firstFreeIndex, nextFreeIndex} from "@somnia-chain/accounts-sdk";
const scan = await accountsOf(client, chainId, owner);
const free = firstFreeIndex(scan); // from a scan you already have
const next = await nextFreeIndex(client, chainId, owner); // reads for you
void [free, next];
nextFreeIndex throws rather than guessing when no read answered: "the first free index is 0" is
indistinguishable from "I could not see anything", and one of those creates an account on top of
another.
A main account's sub-accounts
The same three functions, named for the case you are in:
import {subAccountsAt, subAccountsInRange, subAccountsOf} from "@somnia-chain/accounts-sdk";
const desks = await subAccountsOf(client, chainId, account);
void [desks, subAccountsAt, subAccountsInRange];
subAccountsOf(client, chainId, main) is accountsOf(client, chainId, main) — a sub-account is
an ordinary account whose accountId happens to be an account. The alias exists so that having to
already know that is not how you find out. Same maxIndex bound, same three-state deployed, same
describeCoverage.
Note that index 0 under a main account is a perfectly ordinary sub-account here; the "index 0 is
the main account" convention applies to a key's accounts, not to an account's.
From an account to who controls it
import {ownersOf} from "@somnia-chain/accounts-sdk";
const set = await ownersOf(client, account);
set.threshold;
for (const o of set.owners) {
o.address;
o.kind; // "key" | "account" | "unknown"
}
kind is answerable on chain and worth answering. An owner with code that behaves like a Safe
is an account — the sub-account model — and one without is a key. unknown means the code read
failed, not that it is neither. An owner that has code but is not a Safe is reported as a key,
deliberately: it cannot be signed through as a parent.
Walking upward, and the whole tree
import {accountTree, custodyChain} from "@somnia-chain/accounts-sdk";
// Upward: the chain of accounts between this one and the key at the top.
const chainUp = await custodyChain(client, account);
chainUp.complete; // false when a read on the way up failed — say so rather than implying a leaf
// Downward: this account and its sub-accounts, recursively.
const tree = await accountTree(client, chainId, account);
void [chainUp, tree];
Both report incompleteness explicitly. A tree that silently stops at a failed read looks exactly like a tree that ended.
complete: false means one of three things — depth ran out, the request budget ran out, or a
read did not answer — and it does not say which. Treat it as "there may be more", never as the
full set.
Every walk is budgeted, and the numbers differ per function
The cost is multiplicative — each level multiplies the accounts to inspect — so these are bounded by a request budget as well as by depth. The defaults are not the same, so do not carry one number across:
| Function | maxDepth |
maxRequests |
|---|---|---|
custodyChain |
8 |
200 |
accountTree |
3 |
400 |
authorityPath |
4 |
— |
accountTree's depth of 3 is the one most likely to surprise: a four-level tree comes back
complete: false with the default. Raise it deliberately, and expect the request count to grow
with it — scan.requests and the tree's own requests say what a call actually cost.
authorityPath returning null therefore means "no authority within four levels", not "no
authority". Both are real answers; only one of them is what a UI should say.
Which parents to sign through
import {authorityPath} from "@somnia-chain/accounts-sdk";
// "I hold this key — what do I pass as `through`?"
const path = await authorityPath(client, account, sessionKey);
if (path === null) {
// This key has no authority here, within the search depth. A real answer, not an error:
// offering an execution guaranteed to revert is worse than saying so.
}
void path;
Breadth-first, so the shortest chain wins when an account is reachable more than one way — which is also the cheapest to execute, since every level adds a wrapper and an EIP-1271 call.
Across chains
import {
scanAcrossChains,
setupsAcrossChains,
sharedSetup,
deviatingSetups,
} from "@somnia-chain/accounts-sdk";
const clients = [{client, chainId: 50312}];
const across = await scanAcrossChains(clients, owner);
// Same address everywhere, but the owner sets can differ — a grant that failed on one chain.
const setups = await setupsAcrossChains(clients, account);
const common = sharedSetup(setups);
// Which chains disagree with the one you treat as the reference.
const odd = deviatingSetups(setups, 50312);
void [across, common, odd];
The address is identical on every chain; the state behind it is not. deviatingSetups is how you
find the chain where the grant transaction ran out of gas.
Configuration beyond owners
import {configOf, attributeAddress} from "@somnia-chain/accounts-sdk";
const cfg = await configOf(client, account); // modules, guard, fallback handler, singleton
const derivation = attributeAddress(chainId, account, [owner], {maxIndex: 8n}); // offline: is it ours?
void [cfg, derivation];
Related
- Account derivation
- How to use the indexer — the reverse lookup, and history
- How to check an account is healthy
- About accounts owning accounts