BotanaryBotanary
Smart account

User operations

The single relay boundary - POST /userops - and how the backend and frontend track an operation to a terminal outcome.

Every intent - a send, a swap, a grant, a freeze - eventually becomes one signed UserOperation submitted through the same relay boundary. This page covers that relay and how you track it, not the ERC-4337 envelope itself (see ERC-4337).

IntentType

Every op is tagged with what it's actually for. The full set: send, swap, delegation_enable, delegation_freeze, delegation_unfreeze, delegation_revoke, account_freeze, account_unfreeze, rules_update, guardian_add, guardian_remove, account_deploy, account_fund, panic_install, panic_freeze, delegated_action, device_add, device_remove, device_threshold, managed_deposit.

UserOpBuild: the standard build response

Almost every build endpoint returns the same shape: { intentType, userOp: UnsignedUserOp, userOpHash, humanSummary (nullable), simulation (nullable), riskReducing, instant, gasless, gasPermit (nullable), usdtApproval (nullable) }.

A few of those fields carry real behavior, not just metadata:

  • riskReducing - freeze, revoke, and withdraw-to-safety operations are instant and never gated on having native gas on hand.
  • gasless - the operation is pre-wired to a sponsored or revoke-only paymaster.
  • gasPermit - present only when gas is being paid in USDC. When it's set, the userOpHash returned alongside it is stale - see Signing envelopes for the two-signature flow that recomputes it after the paymaster data is attached.
  • usdtApproval - present only when gas is being paid in USDT and the on-chain allowance is insufficient. It's informational - the approval is already batched into callData, so it never needs a second signature.

Relaying and tracking

POST /userops is the single relay boundary: it accepts a SignedUserOp, submits it to the bundler, and returns a 202 with a UserOpReceipt to poll (400/401/422 on failure, the last one for a policy decline). GET /userops/{userOpId} returns the current UserOpReceipt for a submitted op (401/404).

Server-side, this is OrchestratorService, wired to the bundler port plus repositories for user ops, accounts, audit, and delegations. submit() validates that a signature is actually attached (it refuses to relay an unsigned op), forwards it to the bundler, decodes the delegation's permissionId from calldata for a revoke intent specifically, persists the operation record before any audit entry is written (so a crash between the two can't produce a half-recorded op), and - if the bundler result is already terminal - appends exactly one audit entry via an atomic claim. get(id) re-polls the bundler for a stored op and performs that same terminal-reconciliation step if the op has newly settled, which is how a real (non-fake) submission gets its audit entry even though submit() itself returned before inclusion.

Two more details worth knowing if you're building against this: markRevoked() retires exactly the delegation whose permissionId - decoded from the operation's own calldata, "the authority the chain actually acted on" - matches (an unknown permissionId is a no-op, not an error), and optimistic state changes on the backend's own records (an account marked frozen, a delegation marked revoked) only fire once an operation has actually executed, never on a rejected or reverted one.

Polling from the frontend

export async function waitForUserOp(id: string, tries = 30): Promise<UserOpReceipt> {
  let last: UserOpReceipt | null = null;
  for (let i = 0; i < tries; i++) {
    const { data } = await fetchClient.GET('/userops/{userOpId}', { params: { path: { userOpId: id } } });
    if (data) {
      last = data;
      if (data.status === 'included' || data.status === 'failed') return data;
    }
    await sleep(4000);
  }
  if (last) return last;
  throw new Error('Timed out waiting for the userOp receipt.');
}

The frontend then classifies the terminal receipt into one of four outcomes - mined, reverted, rejected, or pending - distinguishing "included but reverted" (a real, evidence-bearing transaction that failed) from "rejected before it reached the chain" (no on-chain trace at all) from "didn't confirm in time."

Three relay helpers cover the three signing lanes: relayOwnerOp (root-key signing, including the two-signature USDC gas-permit path), relaySessionOp (session-key signing plus the Smart Sessions USE-mode wrap), and relayGrant (relays a grant's enable operation through POST /delegations rather than the generic POST /userops).

On this page