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

ExecutionAccount boundary

An execute() session has three parts:
  • A private withdrawal from the user’s Unlink balance into an ExecutionAccount.
  • A sponsored ERC-4337 UserOperation whose calldata is ExecutionAccount.executeBatch(Call[]).
  • An optional depositBack that returns ERC-20 tokens from the ExecutionAccount to the user’s private Unlink balance.
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.

Advanced SDK surface

The @unlink-xyz/sdk/advanced subpath exports lower-level helpers for custom orchestration:
import {
  createExecutionAccountClient,
  executeAccountCall,
  getExecuteSession,
  pollExecuteStatus,
  prepareExecute,
  prepareExecuteAccountCall,
  reserveExecutionAccount,
  submitExecute,
  submitExecuteAccountCall,
} from "@unlink-xyz/sdk/advanced";
prepareExecute / submitExecute are the private-withdrawal execute-session helpers. prepareExecuteAccountCall / submitExecuteAccountCall prepare and submit a sponsored UserOperation from an already deployed ExecutionAccount without preparing a withdrawal. executeAccountCall is the high-level helper that performs the same account-call flow for you.
The withdrawless account-call flow requires an already deployed ExecutionAccount. It does not create a pool withdrawal, and it is not a withdraw(0) workaround.
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 deployed-account check before preparing a withdrawless account-call session.

Nonce helper

Permit2 nonces must be fresh. A compact browser helper for a random 128-bit nonce:
function randomU128Decimal(): string {
  const bytes = crypto.getRandomValues(new Uint8Array(16));
  let value = 0n;
  for (const byte of bytes) value = (value << 8n) + BigInt(byte);
  return value.toString();
}

Return assets with depositBack

Use depositBack 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 deposit-back submit uses Permit2. Your batch must leave the requested token and amount in the ExecutionAccount, and Permit2 must be able to pull it. The usual pattern is to include an ERC-20 approval for Permit2 as one of the calls.
import { encodeFunctionData } from "viem";

const amount = 1_000_000_000_000_000_000n;
const deadline = Math.floor(Date.now() / 1000) + 3600;

const approvePermit2Call = {
  target: token,
  value: "0",
  data: encodeFunctionData({
    abi: erc20Abi,
    functionName: "approve",
    args: [permit2Address, amount],
  }),
  label: "approve-permit2",
};

const result = await client.execute({
  token,
  amount: amount.toString(),
  calls: [
    // protocol calls first, if any
    approvePermit2Call,
  ],
  depositBack: {
    token,
    amount: amount.toString(),
    nonce: randomU128Decimal(),
    deadline,
  },
});
If the batch swaps into a different ERC-20, set depositBack.token to the token that remains in the ExecutionAccount and approve Permit2 for that token.

Follow-up calls without withdrawal

If a previous execute session completed without depositBack, 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. This still uses the ExecutionAccount owner signature and Unlink’s sponsored ERC-4337 path. The session has funding mode existing_execution_account, withdrawal_tx_id: null, token: null, and amount: null.
const accountIndex = previousResult.execution.account_index;
const stuckAmount = 1_000_000_000_000_000_000n;

await client.executeAccountCall({
  accountIndex,
  calls: [
    {
      target: stuckToken,
      value: "0",
      data: encodeFunctionData({
        abi: erc20Abi,
        functionName: "approve",
        args: [permit2Address, stuckAmount],
      }),
    },
  ],
  depositBack: {
    token: stuckToken,
    amount: stuckAmount.toString(),
    nonce: randomU128Decimal(),
    deadline: Math.floor(Date.now() / 1000) + 3600,
  },
});
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.

Public EOA deposit fallback

depositWithApproval() deposits from the connected EVM wallet, not from the ExecutionAccount. If depositBack 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.
import { account, createUnlinkClient, evm } from "@unlink-xyz/sdk/browser";
import { encodeFunctionData, erc20Abi } from "viem";

const evmProvider = evm.fromEip1193({ provider: window.ethereum });

const client = createUnlinkClient({
  environment: "base-sepolia",
  account: account.fromMnemonic({ mnemonic }),
  evm: evmProvider,
});

await client.ensureRegistered();

const token = "0xTokenAddress";
const protocol = "0xProtocolAddress";
const firstAmount = 1_000_000_000_000_000_000n;

const first = await client.execute({
  token,
  amount: firstAmount.toString(),
  calls: [
    {
      target: token,
      value: "0",
      data: encodeFunctionData({
        abi: erc20Abi,
        functionName: "approve",
        args: [protocol, firstAmount],
      }),
    },
    {
      target: protocol,
      value: "0",
      data: "0x...", // initial protocol calldata
    },
  ],
});

if (first.status !== "completed") {
  throw new Error(`execute ended with status ${first.status}`);
}

const accountIndex = first.execution.account_index;
const recipientEoa = await evmProvider.getAddress();
const amountToDeposit = 500_000_000_000_000_000n;

const followUp = await client.executeAccountCall({
  accountIndex,
  calls: [
    {
      target: protocol,
      value: "0",
      data: "0x...", // optional follow-up calldata from the ExecutionAccount
    },
    {
      target: token,
      value: "0",
      data: encodeFunctionData({
        abi: erc20Abi,
        functionName: "transfer",
        args: [recipientEoa, amountToDeposit],
      }),
    },
  ],
});

if (followUp.status !== "completed") {
  throw new Error(`account call ended with status ${followUp.status}`);
}

const deposit = await client.depositWithApproval({
  token,
  amount: amountToDeposit.toString(),
});

const confirmed = await deposit.wait();
console.log(confirmed.status);
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.