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: prepare → finalize → submit. 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:
prepare → submit. 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
approve → depositFromExecutionAccount → 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.