Moving a token on every network UGTP serves: the three rails and how to tell them apart, the EIP-7702 activation, what Avalanche does differently on both its paths, and tracking an operation to finality.
Every rail, end to end
Moving a token without the account holding the chain's coin to pay for it. One API, one prepare-sign-submit-poll loop — but what the account signs changes with the network, and that is the whole of the difficulty.
Read the rail from the prepare response and branch on it. Do not branch on your own table of networks: the rail depends on the network, asset and project, the gateway resolves all three, and a client that decides for itself will be wrong the first time any of them changes.
Three rails, and how to tell them apart
Every prepare answers one of three shapes. The discriminator is the rail field, and its ABSENCE is meaningful: no rail means the UserOperation rail. Handle an unknown value as an error rather than as the default.
Any supported token, and the network's native coin
The account signs
The returned userOperationHash — a plain hash signature
You submit
operationId + signature, plus authorization when prepare requests it
Cost
Gas and the UGTP fee come out of the asset being moved.
Avalanche C-Chain
"forward_request"
Assets
ERC-20 tokens only
The account signs
The returned signingPayload, with eth_signTypedData_v4
You submit
operationId + signature
Cost
The executor collects gas and the fee in an ERC-20, against an allowance granted once.
Avalanche C-Chain
"self_funded"
Assets
The native coin only — no allowance can move it
The account signs
The whole returned transaction, including the nonce it was handed
You submit
signedTransaction alone; no operationId for a transfer
Cost
The account pays its own gas in the native coin. UGTP charges nothing, so no ugtpFee comes back.
So one network can answer two different rails depending on what is being moved: an ERC-20 on Avalanche is forward_request, and the native coin on the same network, for the same account, is self_funded. That is why the branch belongs after the response and not before the request.
What is different about Avalanche
There is no account to activate
Avalanche has no chain-level EIP-7702, so nothing is ever delegated there. GET /account/delegation-status reports the network as unsupported: that is its steady state, not a failure and not something to wait out. Never attach an authorization to a submit on this network.
An executor relays what the account signed
Instead of a user operation, prepare answers typed data. The account signs it with eth_signTypedData_v4, UGTP countersigns the fee terms and relays it, and the contract refuses anything the account did not sign. The signed terms carry a maximum fee, so the settled charge is a figure under a ceiling the user agreed to.
Check the allowance for each token
The executor checks current allowance and returns EXECUTOR_ALLOWANCE_MISSING when it is insufficient. Set up the allowance for the account, token, spender and network, then prepare again. An existing allowance can be reused until it is reduced or revoked.
The native coin takes a third path
No allowance can move AVAX, so a native transfer answers self_funded: the account signs a whole transaction, pays its own gas, and UGTP broadcasts and tracks it. A native leg must be the only leg in the request — it cannot be batched with token legs.
One thing to understand and two reads to make, before the first transfer. None of them costs anything, and two of them decide what the rest of the flow looks like.
Step 1 · Know what an account is here
What this is for: Avoid looking for an address that does not exist.
The account is the owner EOA. Nothing is derived, nothing is deployed, and there is no separate contract address to create, fund or track — the address your user already knows is the address that holds the balances. So there is no call to make at this step, which is the point of it.
// The account address, in full:
const account = ownerAddress;
// There is no create step and no resolve step. Every prepare below takes
// ownerAddress directly and registers the account itself if it has not seen it.
Then: If you are porting from an integration that called a create or a resolve endpoint first, delete that call. Those belong to an older account model and answer with the address you sent.
Step 2 · Read the delegation status
What this is for: Find out whether the account's next operation must carry an EIP-7702 authorization.
Activation is per network: activating on one says nothing about another. You do not have to call this before every transfer — prepare tells you too — but reading it lets you warn a user that their first operation on a chain needs one extra signature.
Then: not_activated means the next operation carries an authorization. activated means it must not. unsupported is Avalanche, where there is nothing to activate at all — treat it as normal, never as an error.
Step 3 · Read which tokens can pay the fee
What this is for: Pick a fee token the network will actually accept.
A fee token must be one of these. On the executor rail naming one is required — a prepare without it is refused with FEE_TOKEN_REQUIRED — so this read is a prerequisite there rather than an optimization.
Then: This is the fee-token list, not a catalogue of what can be transferred. A token missing here can still be moved; it just cannot be what the fee is charged in.
Part 2 · Price it before the user commits
One call, and two numbers out of it that mean different things. Getting this pair the wrong way round is what produces a Max button that proposes an amount the backend then refuses.
Step 1 · Estimate the fee
What this is for: Learn what to show the user and what to reserve against.
estimatedFee is the worst case the signed terms are capped on; expectedFee is what usually settles. Show the second, reserve against the first.
Then: A "send everything" control subtracts estimatedFee, not expectedFee — otherwise it proposes an amount the signed ceiling cannot cover. The raw fields are hex, the human ones are decimal strings; do the arithmetic on the raw.
Part 3 · A transfer with no native coin on Ethereum, Arbitrum, Polygon, Base or BNB
The default path. The account holds no coin for gas, signs one hash, and the fee comes out of the token being moved. This is a real operation — 2.148465421470024202 ARB on Arbitrum One, whose account held no ETH at all.
Step 1 · Prepare the transfer
What this is for: Turn an intent into something signable, and learn what it costs.
Transfers is an array, so one request can move several tokens to several recipients in one operation. Amounts are base-unit integer strings. Omit feeTokenAddress on this rail and UGTP picks it.
Then: No rail field: this is the UserOperation rail. Read authorizationRequired — when true, submit needs an authorization for the current chain state; when false, omit it. ugtpFee.tokenAmount is the signed ceiling in the fee token.
Step 2 · Sign the hash
What this is for: Authorize the operation.
Sign the 32-byte userOperationHash directly with a signer that supports raw secp256k1 signing. A viem LocalAccount can expose sign({ hash }); ordinary wallet signMessage adds an EIP-191 prefix, even with message.raw, and is not compatible. Keep key handling in the wallet or signer. If authorizationRequired is true, also sign the EIP-7702 authorization for the requested chain and nonce.
// ownerSigner is the owner's configured raw-hash signer (for example, a viem
// LocalAccount with sign). A connected wallet may not expose this capability.
if (!ownerSigner.sign) throw new Error("A raw-hash signer is required");
const signature = await ownerSigner.sign({ hash: prepared.userOperationHash });
// Follow the current prepare response, including after a delegation change.
const authorization = prepared.authorizationRequired
? await walletClient.signAuthorization({
account: ownerAddress,
contractAddress: DELEGATE_ADDRESS, // from /account/delegation-status
nonce: prepared.authorizationNonce,
chainId: Number(networkId), // networkId from the prepare request
})
: undefined;
Then: The authorization delegates the EOA to the account implementation for this network. Use the nonce returned by the current prepare response. For a new operation, check authorizationRequired again; do not reuse an authorization from a different operation.
Step 3 · Submit
What this is for: Hand UGTP the signature so it can relay the operation.
Send operationId and signature, with authorization only when the prepare response requests it.
Then: Then poll — Part 6. This operation settled at 0.564385946233880654 ARB of fee, taken from the same token it moved: gas, the static charge, and the transfer percentage.
Part 4 · The same transfer on Avalanche (executor rail)
Same endpoint, same request shape, different answer — and one setup step the other networks do not have. Everything here is a real operation moving 0.2 USDT on Avalanche C-Chain.
Step 1 · Check and grant the executor allowance
What this is for: Let the executor move this token on the account's behalf.
Ask what the token needs; you never choose the spender or the amount. A token with EIP-2612 answers permit, which needs no native coin at all. One without answers approve, and UGTP pays the gas for that one.
Then: On permit: sign the typed data and POST { networkId, ownerAddress, tokenAddress, deadline, signature } to /transaction/setup-permit, which answers { transactionHash, status, allowance }. On approve: the account signs an ordinary approve and you POST { networkId, rawTransaction } to /transaction/setup-approval, which answers { fundingTransactionHash, approvalTransactionHash, approvalStatus, allowance }. You can skip this step and do it lazily — prepare refuses with EXECUTOR_ALLOWANCE_MISSING and names the token.
Step 2 · Prepare the transfer
What this is for: Get the typed data to sign, and the fee ceiling with it.
Identical request to Part 3 except for the network and the required feeTokenAddress. The response is what differs.
Then: No userOperation and no userOperationHash — there is no user operation on this network. Sign signingPayload with eth_signTypedData_v4, pass it unchanged, and submit { operationId, signature } to the same /transaction/submit. Never attach an authorization here.
Part 5 · The native coin on Avalanche (self-funded rail)
No allowance can move AVAX, so this one is not token-settled and not relayed the same way: the account signs a whole transaction and pays its own gas, and UGTP broadcasts and tracks it. The example moves 0.001 AVAX.
Step 1 · Prepare, and notice there is no operation yet
What this is for: Get a complete transaction to sign, and the nonce it must carry.
Use the native sentinel address as tokenAddress. A native leg must be the only leg. Nothing is recorded at this point — the operation is created when the signed bytes arrive, which is what stops an abandoned transfer from leaving a row behind.
Then: There is no operationId here, and no ugtpFee — UGTP charges nothing on this path. Sign the transaction exactly as handed over, including that nonce: the account's own wallet nonce would race with an operation already in flight. gasSuggestion is a suggestion, not a term — you may raise it.
Step 2 · Submit the signed bytes
What this is for: Have UGTP broadcast and track a transaction it did not build the fee for.
Send signedTransaction instead of a signature, and for a transfer send no operationId — the bytes describe the whole intent. Exactly one of signature or signedTransaction, never both.
Then: The operationId is born here — store it now, it is what you poll. userOperationHash carries the transaction hash on this rail. If polling later reports reason SPEED_UP_REQUIRED with canRetry, the account must re-sign the same nonce at a higher fee; anything else would be a second transaction.
Part 6 · Track it to a terminal status
Identical on all three rails, and the only mechanism there is — polling. Build the loop once and every flow in this portal uses it.
Step 1 · Poll by operation id
What this is for: Find out whether it settled, and stop asking once it has.
One call, repeated with a bounded backoff until the status is terminal.
Then: unknown → received → queued → submitted → confirmed → finalized. finalized and failed end tracking. dropped stops the normal polling loop, but does not prove the signed bytes cannot execute later; check the rail-specific recovery guidance before retrying. Keep polling other statuses.
Step 2 · The five rules that keep the loop honest
What this is for: Avoid the mistakes that turn a working operation into a duplicate or a stuck screen.
Each of these corresponds to a real way integrations get this wrong.
// 1. Poll by operationId, never by a hash. transactionHash changes when the outer transaction is replaced, and on the executor and sponsored rails there is no user-operation hash at all — the field carries the request hash instead.
// 2. An operation appears from submit onward. A prepared-but-never-submitted one answers 404, which is the expected outcome for a user who walked away from the confirmation screen rather than an error to report.
// 3. Process the payload idempotently. Status can repeat across polls, and replacementCount rising is normal — it means the relayer re-priced the same operation, not that anything went wrong.
// 4. Branch on reason, not on prose. It is null on a healthy operation and carries a coded cause when something needs the caller's attention; Interpret canRetry together with the rail and reason. On the self-funded rail, SPEED_UP_REQUIRED means re-sign the same nonce at a higher fee; dropped alone does not permit a new signature.
// 5. There are no customer-configurable webhooks today. Polling is the supported mechanism, so build the loop with a bounded backoff rather than waiting for a callback that will not come.
Then: confirmed means mined; finalized means past the network's reorg-safe depth. Credit a user's balance on finalized unless you have a deliberate reason to act on confirmed and accept the reorg risk.
Before you go live
Your client branches on the response's rail field, and treats an unknown value as an error rather than as the UserOperation rail.
Follow authorizationRequired on each prepare response. Omit authorization when it is false and on rails without delegation.
A "send everything" control subtracts estimatedFee, not expectedFee.
The executor allowance is handled — granted up front, or lazily when a prepare answers EXECUTOR_ALLOWANCE_MISSING.
A native transfer on Avalanche signs the nonce it was handed, and is submitted as signedTransaction with no operationId.
Operations are polled by operationId to a terminal status, with a bounded backoff and no replacement prepared while one is still live.
A 404 on an operation that was never submitted is handled as an ordinary outcome, not an error.