Somnia Accounts / Reference
Safe transactions
The layer under planExecution: what a Safe transaction is, how it is hashed, how several calls
become one, and the ABIs. Reach for this when you are assembling the outer transaction yourself
rather than going through an execution plan.
SafeTx
What an account executes. One of these, signed by enough owners, becomes one execTransaction.
import type {Address, Hex} from "viem";
import type {SafeOperationValue} from "@somnia-chain/accounts-sdk";
interface SafeTx {
readonly to: Address; // where the ACCOUNT sends the call
readonly value: bigint; // wei, paid out of the ACCOUNT's balance — not attached by the sender
readonly data: Hex;
readonly operation: SafeOperationValue; // Call, or DelegateCall for a batch
readonly nonce: bigint; // the account's `nonce()` at signing time
}
void {} as unknown as SafeTx;
value comes out of the account, not the submitter's wallet. execTransaction is payable, but
this SDK always sends zero value with it — the escrow is the account's own balance.
SafeOperation
import {SafeOperation} from "@somnia-chain/accounts-sdk";
SafeOperation.Call; // => 0
SafeOperation.DelegateCall; // => 1
A batch is a DelegateCall to MultiSendCallOnly, which is what keeps the account as msg.sender
for every inner call. MultiSendCallOnly, never MultiSend — see below.
Building one
import {batchedSafeTx, buildSafeTx, contractAddress} from "@somnia-chain/accounts-sdk";
// Chain-aware: resolves `MultiSendCallOnly` from the manifests.
const tx = buildSafeTx(chainId, calls, 4n);
// Pure: for a caller that resolved the batcher itself, or is testing against a substitute.
const same = batchedSafeTx(contractAddress(chainId, "MultiSendCallOnly"), calls, 4n);
void [tx, same];
Both are pure. The nonce is a parameter rather than a read, because you may already be tracking
it; readSafeNonce(client, account) is the one read, kept separate for that reason.
A single call is sent as a Call operation; several are packed. encodeMultiSendCalls(calls) gives
you the packed bytes alone.
MultiSend versus MultiSendCallOnly
They take identical calldata and grant different authority, and no type system distinguishes
them. MultiSend permits a nested delegatecall, so one entry in a batch can rewrite the
account's own storage — its owners, its modules, its implementation. MultiSendCallOnly cannot.
This SDK uses MultiSendCallOnly everywhere, and batchedSafeTx names the distinction in its own
signature. Both addresses are in the manifests because an operator needs to look either up; if your
integration takes a batcher address as configuration, this is the mistake worth a comment.
Hashing it
import {safeTxHash, safeTxTypedData, SAFE_TX_TYPES} from "@somnia-chain/accounts-sdk";
const digest = safeTxHash(chainId, account, plan.tx); // what owners must satisfy
const payload = safeTxTypedData(chainId, account, plan.tx); // the EIP-712 payload to render
void [digest, payload, SAFE_TX_TYPES];
Safe's domain contains the chain id. So a Safe transaction is always chain-bound: the same
transaction is a different message on every network, and an injected wallet will validate
domain.chainId against its selected network before signing. That is why Prepared.chainBound is
true for everything signed for an account, and why the account
authorization — whose domain omits chainId — is the one thing that reaches
every chain from one signature.
safeTxGas, baseGas, gasPrice, gasToken and refundReceiver are all part of what Safe
hashes; this SDK sets them to zero and the zero address. That choice has a consequence worth
knowing — see Swallowed reverts below.
The outer transaction
import {
encodeExecTransaction,
execTransactionCall,
packSignatures,
} from "@somnia-chain/accounts-sdk";
const blob = packSignatures([{kind: "key", signer: owner, signature}]);
const outer = execTransactionCall(account, plan.tx, blob); // {to, value, data}
const bytes = encodeExecTransaction(plan.tx, blob); // calldata alone
void [outer, bytes];
execTransactionCall is the one to reach for, because the target is the part that goes wrong:
these bytes sent anywhere but the acting account revert. encodeExecTransaction exists for a caller
assembling the transaction itself.
Swallowed reverts
A Safe can swallow an inner revert: the outer transaction mines with status: "success" while a
failure event says the inner call did not run. Which path it takes depends on the gas fields.
Because execTransactionCall sets safeTxGas and gasPrice to zero — no gas accounting, no refund
— a Safe reverts the outer transaction on an inner failure rather than emitting
ExecutionFailure. So the failure arrives as a reverted receipt, which submitCall already turns
into a TransactionRevertedError.
On Safe 1.3/1.4 that revert carried the string GS013. 1.5.0 does not emit it — and 1.5.0 is
what our factory deploys — so do not branch on that code: the mechanism survived the version, the
code did not. See Errors.
If you build the outer call yourself with non-zero gas fields, you are back on the swallowing path
and have to inspect receipt.logs for ExecutionFailure and ExecutionFromModuleFailure.
Signing through an account
The lower-level pieces behind nested execution, for a caller that needs the digests rather than the plan:
| Function | What it is |
|---|---|
safeDomainSeparator(chainId, safe) |
an account's EIP-712 domain separator, computed offline |
safeMessageHash(chainId, safe, message) |
what an owner must sign for the account to accept a message — mirrors CompatibilityFallbackHandler.getMessageHashForSafe |
parentApprovalHash(chainId, parent, childTxHash) |
the composition callers actually need: what a parent's owners sign so the parent approves a child's transaction |
nestedSignature(parent, parentSignature) |
wrap a parent's signature so a child accepts it, as an EIP-1271 contract signature |
nestThrough(signature, through) |
fold one key's signature back through a whole chain, nearest parent outermost |
packSignatures(entries) |
the blob, sorted, with offsets computed after the whole static part |
A parent must have a fallback handler installed. The EIP-1271 check reaches the account through
it, and an account with no handler cannot answer isValidSignature at all. Our factory installs one
at creation, so this holds for anything it made — worth knowing if you point this SDK at a Safe from
elsewhere.
Reading an account's nonce
import {readSafeNonce} from "@somnia-chain/accounts-sdk";
const nonce = await readSafeNonce(client, account);
void nonce;
Only the acting account's nonce matters, however deep the authority chain. A parent approves a message rather than executing a transaction, so it has no nonce that enters this.
The ABIs
Generated from the compiled contracts, committed, and gated by mise run abis-check. All as const,
so viem infers argument and return types from them.
| Export | Contract |
|---|---|
safeAbi |
the account |
somniaAccountFactoryAbi |
the factory |
multiSendCallOnlyAbi |
the batcher this SDK uses |
multiSendAbi |
the batcher it does not — present because operators look it up |
compatibilityFallbackHandlerAbi |
what answers isValidSignature |
erc20Abi |
the minimum for balances and transfers |
Never hand-write one. No parseAbi([...]), no pasted JSON. A four-entry Safe ABI compiles,
works, and then silently disagrees with the deployed contract after an upgrade — which is the
failure this package exists to prevent.
Storage slots
import {SAFE_SLOTS, SENTINEL_MODULES, SENTINEL_OWNERS} from "@somnia-chain/accounts-sdk";
void [SAFE_SLOTS, SENTINEL_MODULES, SENTINEL_OWNERS];
SAFE_SLOTS is what configOf reads directly out of storage — the singleton in slot 0, the guard,
the module guard, the fallback handler — because Safe exposes no getter for some of them. The
sentinels are the heads of Safe's two linked lists; prevOwner is the lookup that hides the owner
one.