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 tokens privately. 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.

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 amount 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.
import { account, createUnlinkClient, evm } from "@unlink-xyz/sdk/browser";
import { encodeFunctionData } from "viem";

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

const token = "0xTokenAddress";
const vault = "0xVaultAddress";
const amount = 1_000_000_000_000_000_000n;

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

const supplyCall = {
  target: vault,
  value: "0",
  data: encodeFunctionData({
    abi: vaultAbi,
    functionName: "supply",
    args: [token, amount],
  }),
  label: "supply",
};

const result = await client.execute({
  token,
  amount: amount.toString(),
  calls: [approveCall, supplyCall],
});

console.log(result.status, result.executionId);
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.

Deposit back privately

Use depositBack when the action leaves ERC-20 tokens in the ExecutionAccount and you want to return them to the user’s private Unlink balance. execute({ depositBack }) still starts with a private withdrawal, then runs the call batch, then deposits back after the ExecutionAccount call is confirmed. The call batch must leave depositBack.amount of depositBack.token in the ExecutionAccount, and Permit2 must be able to pull that token. Include an approval for Permit2 unless the ExecutionAccount already has enough allowance.
const depositBackAmount = 900_000_000_000_000_000n;

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

const result = await client.execute({
  token,
  amount: amount.toString(),
  calls: [...calls, approvePermit2Call],
  depositBack: {
    token,
    amount: depositBackAmount.toString(),
    nonce: crypto.getRandomValues(new BigUint64Array(1))[0].toString(),
    deadline: Math.floor(Date.now() / 1000) + 3600,
  },
});
Choose a fresh nonce for each deposit-back. The deadline is a Unix timestamp in seconds and should leave enough time for the execution to settle. To deposit back from an already funded ExecutionAccount without another private withdrawal, use executeAccountCall({ depositBack }) with the same Permit2 approval requirement. See Advanced execute. If an execution ends as deposit_back_failed or user_op_reverted, tokens can remain in the ExecutionAccount. Submit a follow-up executeAccountCall from the same account index with corrected calls, including the Permit2 approval when using depositBack.

Follow-up calls from the same account

Before using this flow, update to the latest canary SDK:
npm install @unlink-xyz/sdk@canary
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.
const first = await client.execute({
  token,
  amount: amount.toString(),
  calls: [approveCall, supplyCall],
});

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

const followUp = await client.executeAccountCall({
  accountIndex: first.execution.account_index,
  calls: [
    {
      target: vault,
      value: "0",
      data: encodeFunctionData({
        abi: vaultAbi,
        functionName: "claimRewards",
        args: [],
      }),
    },
  ],
});

console.log(followUp.status, followUp.executionId);
For a public fallback that transfers ERC-20 tokens from the ExecutionAccount to the connected EVM wallet and then uses depositWithApproval() to deposit back into the pool, see Advanced execute.

Parameters

Required fields:
  • token: ERC-20 token withdrawn privately into the ExecutionAccount.
  • amount: amount in token base units.
  • calls: ordered batch of one to sixteen EVM calls.
Optional fields:
  • depositBack: private re-deposit for ERC-20 tokens left in the ExecutionAccount.
  • accountPolicy: account selection policy. Defaults to "fresh". Use "fresh", "reuseLatest", or { slotIndex }.
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.
// Paginated list of the caller's accounts (stable keyset cursor).
const page = await client.executionAccounts.list({
  environment, // optional filter
  limit, // 1–100, default 50
  cursor, // page.next_cursor from a previous call
});

// One account by id.
const account = await client.executionAccounts.get(accountId);

// One account by its on-chain address (or null), scoped to the caller.
const account = await client.executionAccounts.getByAddress({
  environment,
  address,
});

// Reserve (or reuse) backend-authoritative account indices.
const reserved = await client.executionAccounts.reserve({
  policy: "fresh", // "fresh" | "reuseLatest" | { slotIndex }
});
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.