Skip to main content
This page covers the edge cases around execute(): what the ExecutionAccount can do, how assets are returned to the private pool atomically, and what the advanced SDK exposes for custom orchestration and follow-up calls.

ExecutionAccount boundary

An execute() session is a single sponsored ERC-4337 UserOperation built around the ExecutionAccount. Depending on the request it contains:
  • Optional private withdrawals from the user’s Unlink balance into the ExecutionAccount (withdrawFromPool, one entry per token).
  • The user calls, run as the userCalls section of the plan.
  • An optional atomic returnToPool deposit that returns ERC-20 tokens from the ExecutionAccount back into the user’s private Unlink balance.
The account runs all three as one typed plan (executeUnlinkPlan(withdrawFromPool, userCalls, returnToPool)). The withdrawals, the calls, and the deposit back into the pool either all succeed or all revert together — there is no separate return transaction and no partial-settlement state. The ExecutionAccount is a smart account, not an EOA. The owner key signs an ExecutionIntent for ERC-4337 validation. It does not send normal EVM transactions directly from the ExecutionAccount.

Session shape: funded vs unfunded

The number of signatures and round trips depends on whether the session withdraws from the pool.
  • Funded session (withdrawFromPool present) — funding mode private_withdrawal. Three steps: preparefinalizesubmit. Prepare returns one withdrawal signing request per withdrawFromPool entry (one private spend proof per token); the user signs each; finalize proves the withdrawals, materializes the atomic withdrawToExecutionAccount funding section, and returns the final ExecutionIntent; the user signs that intent and submit broadcasts the UserOperation. The ExecutionIntent is null until finalize because the plan (and its planHash) is not complete until the funding section is materialized.
  • Unfunded session (withdrawFromPool omitted, including every executeAccountCall) — funding mode existing_execution_account. Two steps: preparesubmit. Prepare returns the ExecutionIntent directly because no funding section has to be materialized.
The high-level client.execute() / client.executeAccountCall() helpers run the whole sequence for you, including the withdrawal and intent signatures. The advanced helpers below expose the individual steps.

Advanced SDK surface

The @unlink-xyz/sdk/advanced subpath exports lower-level helpers for custom orchestration:
prepareExecute / finalizeExecute / submitExecute are the funded private-withdrawal execute-session steps. Direct prepareExecute callers pass an account candidate (accountPolicy plus executionAccount.slotIndex, address, owner, and initCode); /execute/prepare is the atomic bind-or-conflict boundary. execute() handles the normal discovery, local derivation, and retry loop for you. prepareExecuteAccountCall / submitExecuteAccountCall prepare and submit a sponsored UserOperation from an ExecutionAccount without preparing a withdrawal. The prepare request may include factory initCode for the account’s first bind/deploy; the durable account binding is completed on submit after the backend verifies the owner’s ExecutionIntent signature. Direct low-level callers that use first-bind evidence in prepare must send the same factory initCode to submitExecuteAccountCall. executeAccountCall is the high-level helper that performs the same account-call flow for you. finalizeExecute applies only to funded sessions; unfunded sessions submit the intent returned by prepare directly. reserveExecutionAccount is still exported for explicit discovery/reservation tools and legacy custom flows. The high-level private-withdrawal execute() path does not call it; it lists accounts for an environment, derives the candidate account from the returned allocation hint, then prepares that candidate. By default, execute() and executeAccountCall() resolve when the backend reports execution.confirmation_status: "confirmed": the handleOps receipt/effects required by the session have been observed and are visible to API reads. Pass waitUntil: "processed" (or "finalized") if your integration needs the durable terminal execution-session state before continuing.
The withdrawless account-call flow does not create a pool withdrawal, and it is not a withdraw(0) workaround. Cold first use is supported only for the verified ExecutionAccount factory initCode derived by the SDK.
Pass an evm provider when constructing the user client to enable the SDK’s client-side deployed-code check for warm account calls. The backend always performs the authoritative factory/initCode derivation checks before preparing a withdrawless account-call session, and requires deployed code only when the request is reusing an account without first-bind initCode evidence.

Return assets with returnToPool

Pass returnToPool in the same execute() call when the batch leaves ERC-20 tokens in the ExecutionAccount and those tokens should go back into the user’s private balance. The return is atomic and typed. returnToPool is the plan’s returnToPool section, grouped by token; the ExecutionAccount constructs each token group’s approvedepositFromExecutionAccount → allowance-reset on-chain, inside the same UserOperation as the funding withdrawals and your calls. The ExecutionAccount is the depositor and the pool authorizes it directly, so there is no Permit2 signature, nonce, or deadline to manage — and no separate deposit transaction that can fail on its own.
Your batch must leave at least the requested amount of each returnToPool 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 independently-capped plan sections, not one combined batch: at most 16 calls, at most 8 withdrawFromPool tokens, and at most 8 returnToPool token groups with at most 4 output notes per token. Return-to-pool groups do not consume the 16-call budget. The structural caps sit alongside a per-network sponsored gas budget checked at prepare, so a plan stacking several sections near their caps can receive a gas-budget rejection even with every section within bounds. Each token group can mix fixed amount outputs with one sweep output that re-shields the runtime remainder (sweep: true + required max_total, optional min_total floor and baseline). recipient is reserved for future use; v1 defaults every output to the authenticated Unlink address and rejects a supplied recipient.

Follow-up calls without withdrawal

If a previous execute session completed without returnToPool, those assets can remain in the ExecutionAccount. Use client.executeAccountCall to run another call from the same account index without moving funds through the pool first. The same recovery path applies when a known terminal failure such as user_op_reverted leaves tokens in the ExecutionAccount. This still uses the ExecutionAccount owner signature and Unlink’s sponsored ERC-4337 path. The session has funding mode existing_execution_account, and its withdrawal fields are empty arrays: withdraw_from_pool: [] and withdrawal_tx_ids: [].
You can also use it for a non-deposit follow-up call, as long as the target calldata can execute from the ExecutionAccount and does not need a new private withdrawal. When returnToPool is omitted, calls must be non-empty.

Public EOA deposit fallback

depositWithApproval() deposits from the connected EVM wallet, not from the ExecutionAccount. If atomic returnToPool is not suitable for a flow, transfer the tokens from the ExecutionAccount to the user’s EOA in a follow-up executeAccountCall(), then call depositWithApproval() from that EOA.
This fallback is public. The ERC-20 transfer from the ExecutionAccount to the EOA and the later deposit source wallet are visible on-chain.
The pool, circuits, and contracts still require positive note amounts for real withdrawals and outputs. A zero-amount withdrawal is not the supported escape hatch.