Skip to main content
Use 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:
  1. withdraws the funding amount from the pool into itself,
  2. runs your calls (the DeFi action),
  3. approves the pool for each returnToPool token, and
  4. deposits the returned tokens back into the pool.
These steps are one sponsored ERC-4337 batch. If any step reverts, the entire operation reverts — including the withdrawal — so funds can never be left stranded in the ExecutionAccount. There is no separate, second deposit transaction to fail. While the funding withdrawal is in flight, its whole-note input leaves 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 as withdrawFromPool funds 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 for erc20Abi and vaultAbi. This snippet uses viem for calldata encoding. Use ethers or your own encoder if that is already your app stack.
The target contract sees the ExecutionAccount as 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

Pass returnToPool 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.
Your 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.
To return assets from an already funded ExecutionAccount without another private withdrawal, use 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 completed while 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.
When 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.
Like the transaction-level 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 the execute() 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

Use executeAccountCall() 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.
An 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 when returnToPool is non-empty.
Optional fields:
  • 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 fixed amount. 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.
Each 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.