EVM steps - Aave withdraw
A worked, self-contained example of redeeming an Aave V3 supply position on an EVM chain and bridging the proceeds out to another chain — burning the aTokens you received when you supplied, and getting the underlying token back plus accrued interest.
This is an out-operation (outOperation: true): the intermediary already holds the position, the steps run on the origin chain, and a final transfer pushes the result to the 1Click deposit address that bridges it out. There is no user deposit — nothing to fund, nothing to wait for on the origin side.
Withdrawing is the mirror of supplying:
withdraw— burn aTokens and return the underlying token (plus interest) to the intermediary.transfer— a producer step that moves the proceeds to the 1Click deposit address, which bridges them to the destination chain.
The service then appends its own fee step. This doc covers the amount recalculation flow — two rounds against the create endpoint: a dry: true preview, then a dry: false create that carries the amount the preview computed.
The two rounds
Round
dry
What it is for
1
true
preview: the gas fee, the amount that will actually be bridged, and the destination estimate. Creates nothing.
2
false
same body, with the producer transfer's amount replaced by round 1's quote.amountIn. Creates the execution, resolves {DEPOSIT_ADDRESS}, prepares the signing payload.
Both rounds are POST /api/v1/executions/{wallet}. Signing and POST …/submit follow as on any execution — that call carries a signature, not an amount, so it is not part of the recalculation.
Recomputing the amount between the rounds
The producer transfer cannot forward the whole withdrawal: the appended fee step has to be paid out of the same proceeds. So the amount in that step is amount − networkFee, and networkFee is not known until something has quoted. That is what round 1 is for.
round 1 → details.networkFee = 6952, quote.amountIn = 190421
(190421 = 197373 − 6952)
round 2 → producer step amount: 197373 → 190421
withdraw step amount: 197373 (unchanged)
quote.amount: 197373 (unchanged)Only the producer step changes. The withdraw still pulls the full amount out of Aave, and quote.amount is still the full amount — that is the origin exposure the user signs for. The carve splits it: 190421 bridges out, 6952 pays the fee.
quote.amountIn from round 1 is the number to copy. It is already amount − networkFee — no arithmetic needed on your side.
The service also computes this itself. It re-estimates the fee on round 2 and overwrites the producer step's amount with its own
amount − networkFee, both in thestepsit returns and in the calls the signature covers. So a figure that has moved since round 1 cannot break the batch, and sending the full amount in both steps works too — that is the single-round variant. Carve it anyway when the UI has to show the exact split before the user signs, and read the returnedstepsto see what the service settled on.
What not to carry forward
Four things go wrong if you treat the dry response as a template for round 2.
Do not echo the response's
stepsarray. It already carries the appended fee transfer. Send it back and the service appends a second one — nothing rejects it, and the batch then tries to pay the fee twice, overdrawing what the withdraw produced. Rebuild the steps from your own source, or drop the appended step (the one whosemetadata.nameisFee Transfer).Do not bake in the deposit address. Every quote mints a fresh one. Keep
{DEPOSIT_ADDRESS}literal in round 2 — the create asserts the steps contain a transfer to the address it just allocated.Do not replace
{AMOUNT_IN}with the number you saw. On EXACT_OUTPUT the service rewrites only the producer step, to the freshquote.amountIn; a stale figure left in thewithdrawstep is not touched. The withdraw then pulls less thanproducer + feeand the batch reverts on the fee transfer.Do not subtract the fee from
quote.amount. The service deducts it from the input itself before quoting. Pre-subtracting deducts it twice and quotes a smaller bridge than the user asked for.quote.amountis identical in both rounds.
Chain roles flip on an out-operation
This trips people up, so it is worth stating plainly:
quote.originAsset
the token the user sends from their wallet
the token the steps produce on the execution chain (USDC on Base)
quote.destinationAsset
the token the steps consume
where the proceeds are bridged to (SOL)
Where steps run
destination chain
origin chain
Fee denominated in
the destination token
the origin token (originAsset)
Initial status
CREATED
OPERATION_PENDING
User deposit
required
none
originAsset is the underlying token, not the aToken. The intermediary holds aBasUSDC, but the batch converts it to USDC before the producer transfer, so USDC is what the quote, the fee, and the deposit-address guard are all denominated in.
Addresses (Aave V3 on Base, USDC)
Aave V3 Pool (Base)
0xA238Dd80C259a72e81d7e4664a9801593F98d1c5
USDC (Base)
0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913
aBasUSDC (the position)
0x4e65fE4DbA92790696d040ac24Aa414708F5c0AB
Fee collector
per-deployment config — read it back from the appended fee step rather than hard-coding it
Only base, eth and arb are enabled as EVM execution chains. Anything else is rejected with 400 blockchain <chain> is not supported as a destination. The pools on the other two:
eth
0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2
arb
0x794a61358D6845594F94dc1DB02A252b5b4814aD
Fetch the intermediary — the account that holds the position and executes the steps — before building anything:
Then read the position size straight off the aToken:
That balance is the maximum you can withdraw. It grows with accrued interest, so read it immediately before you build the request.
The steps
Three things about this step pair:
{DEPOSIT_ADDRESS}is not optional. You cannot know the deposit address before the service fetches the quote, and hard-coding one from an earlier response will not survive: the create path substitutes the final quote's address and then asserts the steps contain atransferoforiginAssetto that address. A stale address fails the assertion withoutOperation requires a transfer(tokenAddress, {DEPOSIT_ADDRESS}, amount) step.The producer amount changes between the two rounds. The pair above is the round-1 shape — the full
197373in both steps. On round 2 the producer transfer carries197373 − 6952 = 190421, which is round 1'squote.amountIn, while the withdraw still pulls the whole amount and the appended fee step takes the remaining6952. The step is matched on its target token, signature and recipient — never on the amount — and the service redoes the subtraction itself, so a figure that has gone stale is corrected rather than rejected.withdraw's third parameter is the intermediary, not the deposit address. The proceeds must land where the next step can spend them.
Round 1 — preview (dry: true)
What to show the user: details.networkFee (what the operation costs, in USDC) and quote.minAmountOut (the worst-case amount that reaches their wallet on the destination chain).
The dry round resolves the steps exactly like the create does — the substitution and the producer-amount rewrite are not gated on dry — so when 1Click allocates a deposit address for the dry quote you get back fully resolved, already-carved steps. Three caveats make the preview indicative rather than binding:
1Click need not allocate a deposit address for a dry quote, and in practice does not. When there is no address there is nothing to splice in: the returned steps keep
{DEPOSIT_ADDRESS}and the producer amount is left as you sent it.Gas simulation can fall back. A transfer to the zero address reverts, so if the address is missing at estimation time the fee comes from a fixed 500000 gas units rather than a measurement. The dry
networkFeeis then conservative, and it can be absent entirely if estimation fails another way — the create turns that same failure into a500.The producer step is not asserted here. The check that the steps contain a transfer to the deposit address lives behind the same address-is-present condition, so a body missing it returns
200on the dry round and400on the create. The dry round validates step encodability, not out-op shape.
Either way the deposit address on the dry quote is not the one you will use. Read the authoritative fee and address from the dry: false response.
The steps in that response are the resolved-and-augmented array, fee step included — the same shape the create returns, not an echo of what you sent.
Round 2 — create (dry: false)
The same body with dry: false, and with the producer transfer's amount replaced by round 1's quote.amountIn — 190421 where round 1 sent 197373:
That one parameter is the only difference from round 1. quote.amount is still 197373, the withdraw step still pulls the full 197373, and {DEPOSIT_ADDRESS} is still a placeholder.
If the fee the service measures here differs from the one round 1 previewed — and it usually does, since a dry quote gets no deposit address and its gas estimate falls back — it rewrites the producer step to the new figure. Read the returned amount rather than assuming yours survived.
The returned steps array is the exact batch the signature covers. Read it back and check the arithmetic — and that the producer amount is still the one you computed from round 1:
The user signs the full 197373 of origin exposure; the split between the bridge and the fee is fixed inside the signed batch, so the fee cannot be changed afterwards.
A
dry: falsecreate takes an in-flight lock for this intermediary. Thedry: trueround does not — it writes no row and takes no lock, so the preview can be re-run freely and never blocks the create that follows it. A second create before the first reaches a terminal state returns409an execution for this wallet is already in progress. Wait for it to complete or fail before creating a new one. To abandon an attempt, delete it withDELETE /api/v1/executions/{wallet}/{executionId}— not a bare DELETE, it needs a body carrying the wallet's signature over the literal stringdelete_execution:{executionId}.
Sign and submit
result.details.payload is signed with the user's wallet, using result.details.signingStandard. On an out-operation that wallet does not need to be on the execution chain at all — it only signs; the intermediary holds the funds and the service sponsors the gas.
The body is { signature, executionId } for an EVM or Tron origin. An ed25519 origin (Solana, NEAR, Stellar) adds publicKey. A TON origin adds both publicKey and a tonConnect envelope. No x-api-key here.
SIGNING — rather than the bridge-in flow's SIGNED_PENDING_DEPOSIT — is how you know the out-operation went straight to execution: the execution was already OPERATION_PENDING, so submitting the signature moves it to OPERATION_PROCESSING immediately. There is no deposit phase and no deposit/submit call.
Monitor
result is an array, so read result[0] even when filtering by a single id.
OPERATION_FAILED is terminal. SUCCESS means the batch executed on the origin chain and the bridge settled — the batch landing on-chain only moves the execution as far as OPERATION_PROCESSING, so a long stay there is normal while the bridge leg completes. The API does not currently expose the sponsored batch's transaction hash.
EXACT_INPUT vs EXACT_OUTPUT
EXACT_INPUT
EXACT_OUTPUT
quote.amount denominated in
the origin token withdrawn from Aave
the destination token the user wants to receive
What's fixed
how much leaves the position
how much arrives (≈ Y)
Amount in your steps
concrete numbers you chose
the {AMOUNT_IN} placeholder
Producer amount on round 2
round 1's quote.amountIn
still {AMOUNT_IN}
Producer transfer, server-side
rewritten to amount − fee
rewritten to quote.amountIn
The user signs
quote.amount
quote.amountIn + networkFee
EXACT_INPUT — "Take 0.197373 USDC out of Aave and bridge whatever survives fees." You know the origin amount up front, so no placeholder is needed, and the producer amount is the one you recompute between the rounds.
EXACT_OUTPUT — "I want 0.0022 SOL. Take whatever that costs out of Aave." You cannot know the origin amount until the service has quoted, so put
{AMOUNT_IN}wherever the amount appears. The service substitutesquote.amountIn + networkFee(the full origin commitment) into every occurrence, then rewrites the producer transfer down toquote.amountIn:
{AMOUNT_IN} is out-operation + EXACT_OUTPUT only. Anywhere else it is a 400: {AMOUNT_IN} placeholder requires outOperation=true and quote.swapType=EXACT_OUTPUT. Because quote.amountIn is the slippage-baked upper bound, 1Click refunds unused slippage to the intermediary (refundTo on an out-operation), so the position may end up slightly larger than the arithmetic suggests.
Where the proceeds land
By default the bridged funds go to the user's own wallet on the destination chain. To send them somewhere else, set quote.recipient:
quote.recipient is out-operation only — on a bridge-in it is a 400. Refunds always go back to the intermediary, never to the recipient.
Rules
x-api-keyis required onPOST /api/v1/executions/{wallet}, dry or not.POST …/submitdoes not take it.outOperation: truemust be set, andtypemust beevm(the default) — with an out-operation thetypeis checked againstoriginAsset's chain, notdestinationAsset's.Exactly one producer step is honoured. The service rewrites the first step matching "transfer of
originAssetto the deposit address" and stops. Any further transfer to that address keeps the amount you sent, un-carved — so the batch would try to move more than the withdraw produced. Send one.{MIN_AMOUNT_OUT}is rejected on an out-operation ({MIN_AMOUNT_OUT} placeholder is not supported with outOperation=true) — it substitutes a destination bridge output, and out-op steps run before the bridge.Do not add the fee step yourself. The service appends it — on a
dry: trueround too, so thestepsarray it hands back is not a valid request body.Step objects accept only
to,functionSignature,parameters,valueandmetadata. Anything else is a400; free-form data goes inmetadata.
Errors you will actually hit
400 outOperation requires a transfer(tokenAddress, {DEPOSIT_ADDRESS}, amount) step
no producer step, the transfer targets the wrong token, or you hard-coded a stale deposit address.
400 estimated gas fee exceeds input amount
EXACT_INPUT only: the fee is ≥ quote.amount, so withdrawing this little leaves nothing to bridge. Withdraw more.
400 {AMOUNT_IN} placeholder requires outOperation=true and quote.swapType=EXACT_OUTPUT
{AMOUNT_IN} on an EXACT_INPUT or bridge-in request.
400 {MIN_AMOUNT_OUT} placeholder is not supported with outOperation=true
wrong placeholder for this direction.
400 quote.recipient is only valid when outOperation=true
recipient sent on a bridge-in.
400 steps are required for non-dry executions
dry: false with an empty steps array.
409 an execution for this wallet is already in progress. Wait for it to complete or fail before creating a new one
a previous dry: false create is still live.
401 missing or invalid api token
absent or rejected x-api-key.
Last updated
