Somnia Accounts / How-to guides
How to sign with a browser wallet
This guide shows you how to use an injected wallet — MetaMask, Rabby, anything EIP-1193 — as a signer or a payer, and how to avoid the four failures every integration rediscovers.
Wrap the provider
import {walletSigner} from "@somnia-chain/accounts-sdk";
// `window.ethereum` is not in the DOM types; every app declares it, one way or another.
const injected = (window as {ethereum?: unknown}).ethereum;
const signer = walletSigner({
address: owner,
getProvider: async () => injected as never,
// `true` for a wallet the user brought. See below — this is the consequential field.
external: true,
});
void signer;
getProvider is called per send rather than held, because a provider bound to a stale session
fails obscurely.
external decides which RPC method is used, and the wrong one looks like your bug
An embedded key can sign a whole transaction, so the caller broadcasts the raw bytes itself and the wallet's selected network never enters it. That is what keeps acting on a second chain to one prompt.
An injected wallet cannot: eth_signTransaction is not a method MetaMask implements, and it
answers -32602 "Missing or invalid parameters" — which reads as a bug in your app rather than a
wrong call. This exact mistake shipped once: "Add this chain" failed for every wallet-only session
while working perfectly whenever an embedded key happened to pay.
The SDK's own default is false — embedded — because an embedded key is the one a caller is
least likely to think about. So a user-brought wallet has to be marked external: true explicitly;
leave it off and it gets asked for eth_signTransaction, which it does not implement.
If you are wrapping wallets whose kind you cannot always tell, default your own resolution to
true and pass it down (external: wallet.external ?? true, which is what our explorer does). That
is the conservative direction: an embedded key treated as external costs at most a less readable
prompt, while the reverse costs a chain-switch dialog mid-flow — or, on a network the wallet has
never seen, an add-chain one.
It also affects signing, not only sending: an injected wallet validates domain.chainId against
its selected network, so it is offered the digest first, while an embedded key renders anything and
is offered typed data a person can actually read.
Typed data or a digest depends on the payload, not the wallet
Prepared.chainBound says whether the payload names a chainId, and signWith uses it:
import {signWith} from "@somnia-chain/accounts-sdk";
// chainBound: true -> an injected wallet gets the DIGEST, because it would otherwise validate
// `domain.chainId` against its selected network and demand a switch first.
// chainBound: false -> it gets the typed data, so the person can read what they are approving.
const sig = await signWith(signer, prepared);
void sig;
Safe's domain contains the chain id, so anything signed for the account is chainBound: true. The
account authorization is not — its domain omits chainId deliberately — so that one can be
rendered.
signWith falls back from typed data to the digest when a wallet refuses the domain, but only on
a refusal. A wallet answering with nonsense is a WalletResponseError, not a reason to sign
something else: papering over it would hand back a signature recovered against a different hash than
the caller believes was signed.
Sanitising what a wallet hands back
import {sanitize, serializeTypedData} from "@somnia-chain/accounts-sdk";
// Some extensions inject values that break `JSON.stringify` on BigInt.
const payload = serializeTypedData(prepared.typedData);
void [payload, sanitize];
These exist because every consumer otherwise rediscovers the same four things: stringified typed
data, the reversed eth_sign parameter order, the BigInt corruption browser extensions inject, and
a wallet answering with something that is not a signature.
The SDK never signs
It has no keys, no wallet and no session, and it should not. What it does own is which shape a
signature has to be in for the contract to accept it — typed data or a digest signed as a message,
Safe's v + 4 marker distinguishing the second, a raw recovery id that must be lifted to 27/28 or
ECDSA rejects an otherwise valid signature, and a wallet that refuses one method needing the other.
So the contract is: you implement two methods against whatever wallet you have, and the SDK turns
"here is a digest" into "here is a signature the contract will accept". walletSigner is that
implementation for an EIP-1193 provider; a hardware wallet, a KMS or a remote signer is your own
object satisfying Signer.
Signing and paying are different decisions
import {prepareExecution, signAndSubmit} from "@somnia-chain/accounts-sdk";
// The owner's injected wallet signs. The session key pays and broadcasts. The owner never
// needs gas — `execTransaction` checks signatures, not `msg.sender`.
const {hash} = await signAndSubmit(client, {
prepared: prepareExecution(plan, owner),
signer,
sender,
chain,
});
void hash;
Give signAndSubmit a signer that cannot pay and no sender, and it raises SenderRequiredError
rather than failing at broadcast against the wrong address. canPay(signer) is the type guard it
uses.
Never hand a wallet a nonce
An injected wallet manages its own. A nonce computed against a different node is how a transaction ends up stuck behind a gap nobody can see — nothing fails, it just never mines, and every later transaction from that address queues behind it.
submitCall handles this: an injected wallet goes through eth_sendTransaction with neither a
nonce nor a gas price, because it fills both itself, while an embedded key — which signs a whole
transaction — gets both read from the chain you are writing to. That is the other half of why external matters, and it is why you should not assemble
the transaction yourself unless you are also taking over the nonce question.
The network check
WrongNetworkError carries expected and actual. It is thrown rather than silently reading the
wrong chain, because a client pinned to mainnet answering questions about testnet is otherwise
indistinguishable from a correct answer.
Related
- Errors —
WalletResponseErrorvsRpcError - How to act without a signature — when no prompt is needed at all