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, theuserOpHashreturned 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 intocallData, 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).