Somnia Accounts / How-to guides

How to sponsor a transaction

This guide shows you how to have something other than the user pay: a relayer, a sponsor endpoint, a queue. It assumes you already build the call you want sent.

The seam

Everything this SDK builds ends in a Call, and a Call is already here are the bytes, send them however you like. So a sponsor is one method:

import {submitCall, type Relay} from "@somnia-chain/accounts-sdk";

const sponsor: Relay = {
  async send(bytes, id, hints) {
    const response = await fetch("https://sponsor.example/send", {
      method: "POST",
      body: JSON.stringify({
        to: bytes.to,
        data: bytes.data,
        chainId: id,
        // USE THIS. See below.
        gas: String(hints?.gas ?? 20_000_000n),
      }),
    });
    const {hash} = (await response.json()) as {hash: `0x${string}`};
    return hash;
  },
};

const {hash, receipt} = await submitCall(client, {chain, sender: sponsor, call, gas: 20_000_000n});
void [hash, receipt];

A Relay has no address, no provider and no network to check, deliberately. It is asked to put bytes on a chain and to say which transaction that became. Everything after that — waiting, revert detection, the unconfirmed case — stays in the SDK, because those are wrong in the same three ways whoever broadcast.

Your relayer must not wait for a receipt

send returns as soon as it has a hash. A relayer that waits hides the distinction between reverted and not confirmed yet, and that distinction is the one the caller depends on: TransactionRevertedError means nothing changed, UnconfirmedSendError means it may still land.

Use hints.gas

The third argument carries what the SDK already worked out. A relayer that ignores it and picks something reasonable-looking — 2,500,000 is the natural guess — silently fails every account creation, and the receipt says out-of-gas rather than naming the cause.

Safe's setup runs nested, so it needs a floor of gas remaining while eth_estimateGas measures gas spent. Your relayer cannot re-derive this. If you must cap it, cap it above the floor. See Gas.

The parameter is optional so an existing two-argument send still satisfies the interface — which means a relayer that ignores the hint compiles fine.

What a sponsor should check before paying

If your sponsor is an endpoint taking an authorization from a client, verify it before spending:

import {checkAccountAuthorization, recoverAuthorizationSigner} from "@somnia-chain/accounts-sdk";

const signed = {factory: account, authorization, signature};
const problems = checkAccountAuthorization(signed, {
  now: BigInt(Math.floor(Date.now() / 1000)),
  chainId,
});
if (problems.length > 0) throw new Error(`refusing to sponsor: ${problems.join(", ")}`);

const who = await recoverAuthorizationSigner(signed);
// …now decide whether `who` is someone you sponsor.
void who;

Both halves are required. checkAccountAuthorization is every check that can be made offline — expiry, the window ceiling, the factory, the derivations — and an empty array does not mean the signature is accountId's. There is deliberately no signer variant, because recovery is async and an EIP-1271 signer answers on chain. A sponsor that stops at problems.length === 0 will pay for authorizations signed by anybody.

Branch on problem.kind rather than stringifying: notYetValid is a scheduled row worth surfacing — a signature nobody meant to schedule is the shape a phished authorization takes — while window is malformed and will never become valid. The full table is in Account authorization.

Run it before every broadcast, not once when the row arrives: a fan-out is N transactions over minutes against a deadline measured in minutes.

A sponsor cannot send a pre-validated call

prevalidatedExecution produces a call that is only valid from one address, and a relayer's address cannot be checked — so submitCall refuses it with WrongSenderError rather than letting it revert as GS025 after the gas.

A sponsored write needs a real signature. Use prepareExecution + signAndSubmit, or signExecution, and keep the pre-validated path for the case where the actor pays for itself. See The call, and who may send it.

Creation is permissionless, so sponsoring it is safe

createAccount takes no owner list: the factory forces the owner from the salt. A sponsor paying for someone's account creation cannot end up owning it, and cannot inject an owner. That is why this is safe to expose as an unauthenticated endpoint if you want to — the worst case is paying for an account you did not mean to.

Granting an owner is not permissionless, and needs the owner's signature.

Edit this page