Somnia Accounts / Reference
Errors
Every failure this SDK raises, what it means, and what to branch on.
The family
All of them extend SomniaAccountsError, so one test tells you whether a failure was ours:
import {SomniaAccountsError} from "@somnia-chain/accounts-sdk";
try {
await Promise.resolve();
} catch (e) {
if (e instanceof SomniaAccountsError) {
// ours: `e.message` is built from typed fields, and `e.cause` has whatever caused it
} else {
throw e; // a wallet, a transport, or a bug in your own code
}
}
Branch on the class and read the fields. Do not match on the message. The fields are the API;
the wording is not. Each class sets name to its own class name explicitly, so a minified bundle
still logs a readable one.
Nothing raw escapes. A wallet that answers badly is a WalletResponseError, a chain that does
not answer is an RpcError, an argument refused offline is an InvalidInputError rather than a
RangeError. Whatever caused a failure is chained in cause, never swallowed.
The classes
Your input
| Class | Fields | Means |
|---|---|---|
InvalidInputError |
— | An argument this SDK could refuse without a chain. Fix the call; do not retry. |
InvariantError |
— | A condition the SDK believed could not happen. The bug is ours — please report it. |
present(value, "what should have been true") is the invariant helper used instead of non-null
assertions, and it raises InvariantError.
Deployment and chains
| Class | Fields | Means |
|---|---|---|
UnsupportedChainError |
chainId |
Nothing is committed for that chain. |
MissingContractError |
chainId, contractName |
The chain is covered; that name is not in its manifest. |
AmbiguousDeploymentError |
chainId, saltVersions |
Several salt versions are committed. Pass saltVersion. |
WrongNetworkError |
expected, actual |
The client is pinned to a different chain than the call is about. |
See Addresses and chains for why these throw rather than returning
undefined.
Authority
| Class | Fields | Means |
|---|---|---|
WrongSignerError |
required, offered |
A signature recovered to the wrong address. |
WrongSenderError |
required, offered | null |
The call may only be sent by one address, and this is not it. offered is null for a relayer, whose address cannot be checked. |
NotAnOwnerError |
owner, owners |
An owner action named an address the account does not have as an owner. Raised offline against the owner list that was read, rather than letting Safe answer GS026 from inside a transaction you already paid for. |
WrongSignerError and WrongSenderError are deliberately distinct: one means nothing valid was
signed, the other means the right call is in the wrong hands. See
The call, and who may send it.
Paying and sending
| Class | Fields | Means |
|---|---|---|
SenderRequiredError |
signer |
That address can sign but cannot send. Pass a sender — a relayer, or a wallet that pays. |
CannotPayError |
address |
The sender's balance will not cover the transaction. |
UnconfirmedSendError |
hash |
Waiting stopped. The transaction may still land. Not a failure. |
TransactionRevertedError |
hash, blockNumber, reason |
Mined and reverted. Nothing changed. |
UnconfirmedSendError is the one most likely to be mishandled. It does not mean the transaction
failed — it means the SDK stopped waiting. Treat it as "unknown", not as "no": re-check the hash
before retrying, or you will send a second transaction for work that already happened.
The wallet
| Class | Fields | Means |
|---|---|---|
WalletResponseError |
method |
The wallet answered with something unusable — a malformed signature, a send that returned no hash, a recovery id out of range. The remedy is usually a different wallet, not a retry. |
UnsupportedMethodError |
method |
The wallet does not implement that RPC method. |
RpcError |
— | The chain or transport did not answer. Retry with backoff. |
The distinction between WalletResponseError and RpcError is who misbehaved: the wallet the
user chose, or the node.
The indexer
| Class | Fields | Means |
|---|---|---|
IndexerUnavailableError |
retryAfterMs |
Rate-limited, overloaded or unreachable. A subclass of RpcError. Wait retryAfterMs when given, then ask again. |
FeedRestartRequiredError |
chainId |
The index was rebuilt since the feed's cursor was issued. Continuing would skip or repeat rows, so start the read again without a cursor. |
Why a transaction reverted
A receipt carries no revert data at any Safe version, so the only route to a reason is replaying the
call at the block it landed in. submitCall does that for you, and the decoded reason lands on
TransactionRevertedError.reason — best-effort by construction: a node that cannot replay the block,
or a revert with nothing decodable in it, leaves reason undefined.
import {decodeRevertData, replayForRevert, revertReason} from "@somnia-chain/accounts-sdk";
// If you send the bytes yourself, this is the same path: replay the call at the block it
// landed in. `to` is the account for an `execTransaction`, and `data` is the calldata that
// reverted. `null` means nothing better than "it reverted" can be said.
const reason = await revertReason(client, {
to: account,
data: call.data,
blockNumber: receipt.blockNumber,
from: sender.address,
});
void [reason, decodeRevertData, replayForRevert];
revertReason answers with a DecodedRevert or null:
import type {DecodedRevert} from "@somnia-chain/accounts-sdk";
function render(decoded: DecodedRevert | null): string {
// `null` is "nothing better than 'it reverted' can be said" — render that, not an empty reason.
if (decoded === null) return "it reverted";
return decoded.reason;
}
void render;
You can pass your own protocol's error ABI to submitCall via errors, so a revert in your
contracts is named rather than reported as an unknown selector. The SDK knows Solidity's built-ins
and ERC-20's; it cannot know yours.
Safe's own codes
Safe reverts with short codes — GS020, GS025, GS026 — that mean several things each and
explain none of them.
import {
explainSafeError,
isRetryable,
safeErrorCode,
KNOWN_SAFE_ERRORS,
} from "@somnia-chain/accounts-sdk";
// Pass the ERROR, not a code you extracted yourself — all three dig the code out for you.
const caught: unknown = new Error("execution reverted: GS026");
const explained = explainSafeError(caught); // SafeError | undefined
if (explained !== undefined) {
explained.code; // "GS026"
explained.message; // one sentence, for a person
explained.retryable; // whether the same bytes might succeed later
// Set when one code covers several distinct causes. **Present means "do not show `message` as
// the diagnosis"** — it is a summary, and the readings are the actual possibilities. A UI that
// has ruled some out should say the rest.
explained.readings; // readonly {cause: string; retryable: boolean}[] | undefined
}
safeErrorCode(caught); // "GS026" | undefined, if you want only the code
isRetryable(caught); // the `retryable` flag, or false when the code is unknown
void KNOWN_SAFE_ERRORS; // the whole table
retryable is load-bearing rather than informational. It is the flag a submit path reads to
decide whether to re-sign, and getting it wrong in the true direction produces an infinite loop of
wallet prompts. isRetryable returns false for anything it cannot recognise, which is the safe
direction.
Prefer isRetryable(error) over reading retryable yourself. When readings is present it is
the readings' flags that decide — isRetryable returns true if any of them is retryable, where
the top-level retryable is the summary. Reading the summary on a multi-cause code is the guard a
consumer was missing once.
The table tracks Safe 1.5.0, and a test cross-checks it against the vendored contracts so it cannot
drift from what they actually emit — that check already caught a GS013 entry Safe 1.5.0 cannot
emit.
The ones worth knowing by sight, with the table's own wording:
| Code | The SDK's sentence | Also means, here |
|---|---|---|
GS020 |
Signatures were missing entirely. | |
GS021 |
A signature was malformed. | |
GS022 |
A contract signature pointed outside the data given. | a hand-built nested blob with a wrong offset |
GS025 |
An owner approved a different transaction than this one. | a pre-validated entry with neither msg.sender nor an approveHash behind it — the usual cause here |
GS026 |
The signature was not accepted for this transaction. | not an owner, or signers out of ascending order, or a repeated owner |
GS030 |
Only the account itself may make this change. | an owner function called from outside execTransaction |
GS031 |
The account must be set up before this is allowed. |
GS026 is why this module is not a Record<string, string>: one code covers three situations whose
correct responses differ in kind — "try again", "you cannot do this", and "sign again over fresh
data". That is what readings carries, and packSignatures sorts for you precisely because the
code gives a caller nothing to distinguish them by.
An unrecognised code is still named
explainSafeError returns undefined only when there is no GSxxx code in the error at all. A
code it has no sentence for still comes back, through a fallback:
import {explainSafeError, KNOWN_SAFE_ERRORS} from "@somnia-chain/accounts-sdk";
// A code with no sentence still comes back, named:
explainSafeError(new Error("reverted: GS013")); // => {code: "GS013", message: "The account rejected the transaction (GS013).", retryable: false}
// `undefined` means there was no GSxxx code in there at all:
explainSafeError(new Error("something else entirely")); // => undefined
// So THIS is how you ask whether the SDK understands a code:
KNOWN_SAFE_ERRORS.includes("GS013"); // => false
KNOWN_SAFE_ERRORS.includes("GS026"); // => true
Naming it beats passing viem's raw wall of text through, and retryable: false is the safe default:
a retry loop on an unknown code is the failure mode that costs real money in wallet prompts.
So test KNOWN_SAFE_ERRORS.includes(code), not explained !== undefined, when you want to know
whether the SDK actually understands a code. The presence of a SafeError is not the same as the
presence of a diagnosis.
GS013 is the code to stop quoting
Safe 1.5.0 does not emit GS013, and 1.5.0 is what our factory deploys. It is a 1.3/1.4 code,
and the mechanism it described — a failed inner call reverting the whole execTransaction — is
still entirely real, which is exactly why it is easy to keep quoting from memory.
It is deliberately absent from the table, so KNOWN_SAFE_ERRORS does not contain it and the only
thing you get back is the generic fallback above. A test reads Safe's own vendored source and fails
if the table ever claims a code the contracts cannot emit — that check is what removed GS013 in
the first place. Do not branch on it.