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.