execute() when a user wants to take tokens from their private Unlink
balance, run one or more EVM calls from an ExecutionAccount, and optionally
return the resulting tokens privately to the pool.
A single call is a one-item calls array. For a DeFi action, put each step in
order, such as approve then supply. The batch is atomic: if one call reverts,
the whole batch reverts.
One atomic UserOperation
execute() runs everything as a single all-or-nothing UserOperation. When a
session withdraws from the pool and returns tokens to it, the ExecutionAccount:
- withdraws the funding amount from the pool into itself,
- runs your
calls(the DeFi action), - approves the pool for each
returnToPooltoken, and - deposits the returned tokens back into the pool.
spendable and the expected owned change appears in pending_change; render
amount to avoid a temporary zero-balance dip. An atomic revert restores the
input to spendable. This no-dip guarantee applies while sync_status is
current; syncing is a conservative lower bound. See
Reading data and status.
Gas is sponsored
execute() runs through an ExecutionAccount, an ERC-4337 smart account that
belongs to the user. The SDK sends the call batch and locally derived account
candidate to Unlink; the backend builds the UserOperation and Unlink’s paymaster
sponsors it. The operation is submitted through a bundler and the gas is paid by
Unlink, not by the user.
- The user does not need to hold native gas tokens to call
execute(). The private balance you pass aswithdrawFromPoolfunds the calls; Unlink sponsors the gas. - You do not set gas fields. The backend builds the operation, the user signs the prepared ExecutionIntent, and Unlink sponsors and submits it.
- Sponsored execution is subject to per-call gas limits. A batch that exceeds the sponsorship caps is rejected before it is submitted.
Basic DeFi call
Use your app’s contract definitions forerc20Abi and vaultAbi. This snippet
uses viem for calldata encoding. Use ethers or your own encoder if that is
already your app stack.
msg.sender. Set each call
value to "0". Use WETH or an ERC-20 flow for actions that need native ETH.
By default, the helper returns when the execute session is confirmed: the
handleOps receipt/effects have been observed by the backend and are visible to
API reads. A receipt hash alone is not enough. Pass waitUntil: "processed" if
your flow must wait for the terminal execution session state.
Return tokens privately
PassreturnToPool when the batch leaves ERC-20 tokens in the ExecutionAccount
and you want to return them to the user’s private Unlink balance. The deposit
back into the pool happens inside the same UserOperation as the withdrawal
and your calls.
returnToPool is a typed section of the execution plan, not extra calls you
write. Entries are grouped by token, and the ExecutionAccount constructs the
per-token approve → deposit → allowance-reset on-chain, inside the same
UserOperation. You do not add those calls yourself, and you do not manage a
Permit2 signature or nonce: the ExecutionAccount is the depositor and the pool
authorizes it directly through its depositFromExecutionAccount entry point.
calls must leave at least the requested returnToPool amount of each
token in the ExecutionAccount when the batch reaches the deposit step. If the
batch swaps into a different ERC-20, set the returnToPool token to whatever
token remains in the account.
Funding, calls, and return-to-pool are three separate plan sections with their
own limits — they are not one combined batch. Each section is capped
independently: at most 16 calls, at most 8 withdrawFromPool tokens, and at
most 8 returnToPool token groups with at most 4 output notes per token. Adding
a return-to-pool group does not consume your 16-call budget. These are the
structural maximums; the effective size of one plan is also bounded by the
network’s sponsored gas budget, so a plan near several caps at once (for
example, many funding tokens plus many calls) can be rejected at prepare with a
gas-budget error even though each section is under its cap.
returnToPool is the API/SDK name for the private return leg. On-chain the
ExecutionAccount settles it through the pool’s depositFromExecutionAccount
entry point.executeAccountCall({ returnToPool }). See
Advanced execute.
Knowing when the returned funds are spendable
GET /execute/{execution_id} reports usable. It is true once the execute’s
client-visible effects are ready for follow-up use: its on-chain effects are
observed by the deployment, every note it returned to the pool has materialized
as a spendable, owner-attributed note, and every funding withdrawal’s own
outputs are usable.
Wait on usable, not on status == "completed". They answer different
questions, and neither implies the other:
- A session can be
completedwhile its returned notes are still unattributed. Spending them then fails. - A session can be usable before the terminal transition on a deployment that treats optimistic observation as spendable.
usable is false, unusable_reason says what is being waited on —
awaiting_return_to_pool_evidence while the pool deposit has not been observed,
awaiting_output_attribution once it has but the notes are not yet attributed,
and terminal_failure when the effects are never coming.
A pure sweep that realizes zero returns nothing, so the pool emits no event
for it. That shape becomes usable on the completion transition alone — there
is no evidence to wait for, and waiting for some would hang forever.
usable, this is point-in-time rather than a
finality guarantee: a reorg that invalidates the optimistic overlay can turn it
back to false.
Recovery is a retry
Because the withdrawal, calls, and return-to-pool deposit are one atomic UserOperation, there is no intermediate state where funds sit unrecoverably in the ExecutionAccount. If the operation reverts, the withdrawal is rolled back and the notes remain spendable in the pool. The remedy is to retry theexecute() call — not to recover stranded funds.
A reverted operation surfaces as a non-completed terminal status. Check
result.status and re-issue the call (adjusting calldata if the revert was
caused by the DeFi action itself).
Follow-up calls from the same account
UseexecuteAccountCall() when a user already has assets or state in an
ExecutionAccount and you want to run more calldata from that same account
without another private withdrawal.
This is not withdraw(0). It prepares a sponsored ERC-4337 UserOperation from
an ExecutionAccount without a new private withdrawal. If the reserved account is
cold, the SDK supplies verified factory initCode so the first account-call can
deploy and bind it during submit. The target contract still sees the
ExecutionAccount as msg.sender.
executeAccountCall() can also return assets privately by passing
returnToPool; the deposit is settled inside the same account-call
UserOperation. For a public fallback that transfers ERC-20 tokens from the
ExecutionAccount to the connected EVM wallet and then uses
depositWithApproval() to deposit them, see
Advanced execute.
Parameters
Required fields:calls: ordered batch of zero to sixteen EVM calls. May be empty only whenreturnToPoolis non-empty.
withdrawFromPool: array of{ token, amount }withdrawn privately into the ExecutionAccount before the calls run — one entry per token, at most 8, tokens unique. Each becomes its own private spend proof, and all are batched into a single pool withdrawal. Omit it to run calls from an already funded account without a new withdrawal.returnToPool: array of per-token outputs returned privately to the pool inside the same UserOperation. A fixed output sets{ token, amount }; a sweep output sets{ token, sweep: true, max_total }(see below). At most 8 token groups, at most 4 outputs per token.accountPolicy: account selection policy. Defaults to"fresh". Use"fresh","reuseLatest", or{ slotIndex }.
withdrawFromPool[].amount and each fixed returnToPool amount are base-unit
token amounts as decimal strings (at most 2^120−1). A funding amount above that
bound is rejected on-chain, so keep withdrawals within the note-field maximum.
Sweep returns
When you cannot predict the exact leftover of a token — a swap output, staking rewards — use a sweep output instead of a fixedamount. A sweep re-shields the
runtime remainder the ExecutionAccount holds, so no dust is stranded:
max_total is required when sweep is true and caps the deposited total to
min(runtime balance, max_total). min_total (optional, default 0) reverts the
whole atomic execution if the returned total falls below it — a slippage floor.
baseline (optional) excludes a pre-existing balance of the token from the
sweep. At most one sweep output per token; fixed and sweep outputs can be mixed
for the same token.
The execute() method requires a seed-backed account. fromKeys can transfer
and withdraw but cannot execute. See
Account constructors.
Account discovery
client.executionAccounts exposes per-user discovery and reservation of
ExecutionAccounts. It surfaces identity and lifecycle metadata only — never
balances, positions, or note metadata. Scoping is enforced for capability
tokens; tenant API keys select the user (the SDK forwards the bound address).
When you list accounts for a specific environment, the response also includes
an allocation hint with tenant_index, chain_index, next_slot_index, and an
optional latest_slot_index when a reusable bound account exists. The high-level
execute() helper uses that hint to derive the candidate account locally, then
/execute/prepare atomically binds it.
ExecutionAccount carries account_id, tenant_index, chain_index, account_index, environment, account_address, owner_address, status (reserved | active | retired), deployed_at, created_at, updated_at, and last_execution_at (the created_at of the account’s most recent execution session, or null).
The reservation policy maps onto the backend allocation policy: "fresh" →
first_unused, "reuseLatest" → most_recent_active, and { slotIndex } →
by_index. client.reserveExecutionAccount(...) remains as a deprecated alias.