TransactionsToken Transfers

Token Transfers

address.buildTransferPayload({ to, asset, amount }) lowering one intent — native value, ERC-20 calldata, or SPL instructions — by contract address, mint or symbol, via TransferRequest, TransferPayload and AssetRef.

address.buildTransferPayload(req) takes one chain-agnostic transfer intent — send this much of this asset to that recipient — and lowers it into the chain-specific fields a transaction needs: a native value, ERC-20 calldata, or SPL instructions. It does the routing so you do not have to know, per chain, whether "send USDC" means calldata or a pair of instructions.

The intent: TransferRequest and AssetRef

types.ts
interface TransferRequest {
  to: string;        // recipient wallet address
  asset?: AssetRef;  // omitted / { native: true } = the chain's native coin
  amount: bigint;    // raw base units (wei, lamports, or token smallest unit)
}

type AssetRef =
  | { native: true }
  | { address: string }  // ERC-20 contract / SPL mint
  | { symbol: string };  // resolved against tracked assets on this network

amount is always in raw base units — wei for ETH, lamports for SOL, and the token's smallest unit (e.g. 1_000_000n = 1 USDC at 6 decimals) for tokens. An out-of-range amount is a PARAMETER_ERROR.

The three transfer cases

Native coin

Omit asset (or pass { native: true }). On EVM this becomes { to, value }; on Solana { recipient, amount }.

native.ts
const evmPayload = w0.evm.buildTransferPayload({
  to: String(w1.evm.publicKey),
  amount: 1_000_000_000_000_000n,
});
// { vm: "evm", to: <recipient>, value: 1_000_000_000_000_000n, data: "0x" }

const svmPayload = w0.svm.buildTransferPayload({
  to: String(w1.svm.publicKey),
  amount: 5000n,
});
// { vm: "svm", recipient: <recipient>, amount: 5000n }

Token by contract address / mint

Name the token by its on-chain address. On EVM this lowers to ERC-20 transfer(address,uint256) calldata; on Solana to SPL instructions.

by-address.ts
// EVM ERC-20 — the payload targets the token contract with transfer() calldata
const tx = w0.evm.buildTransferPayload({
  to: String(w1.evm.publicKey),
  asset: { address: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48" }, // USDC
  amount: 1_000_000n,
});
// tx.to  === the token contract
// tx.value === 0n
// tx.data begins with 0xa9059cbb (the transfer(address,uint256) selector)

// Solana SPL — two instructions
const spl = w0.svm.buildTransferPayload({
  to: String(w1.svm.publicKey),
  asset: { address: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" }, // USDC mint
  amount: 1_000_000n,
});
// spl.instructions.length === 2

Token by symbol

Name the token by symbol. It is resolved against assets tracked on this signer's network (with a curated set of built-ins as a fallback), then re-lowered — so a symbol that maps to the native coin becomes a plain value transfer, and a token symbol becomes calldata / instructions.

by-symbol.ts
const usdc = w0.evm.buildTransferPayload({
  to: String(w1.evm.publicKey),
  asset: { symbol: "USDC" },
  amount: 1_000_000n,
});
// usdc.data begins with 0xa9059cbb (resolved to the ERC-20 token)

const eth = w0.evm.buildTransferPayload({
  to: String(w1.evm.publicKey),
  asset: { symbol: "ETH" },
  amount: 777n,
});
// eth.value === 777n (resolved to the native coin)

An unknown symbol — one not tracked on the signer's network — throws PARAMETER_ERROR.

TransferPayload

The lowered result is a discriminated union keyed on vm:

TransferPayload.ts
type TransferPayload =
  | { readonly vm: "evm"; readonly to: string; readonly value: bigint; readonly data: string }
  | { readonly vm: "svm"; readonly recipient: string; readonly amount: bigint;
      readonly instructions?: ReadonlyArray<unknown> };

Feeding the payload to buildTransaction

Spread the payload into buildTransaction(...), add whatever that step owns (chainId, rpcUrl), then send:

transfer-and-send.ts
const evm = acc.wallets[0].evm;

const payload = evm.buildTransferPayload({
  to: recipient,
  asset: { symbol: "USDC" },
  amount: 1_000_000n,
});

const tx = await evm.buildTransaction({ ...payload, chainId: 1 });
const tracker = evm.sendTransaction(tx);
await tracker.whenConfirmed();

SPL creates the recipient's token account

For an SPL transfer, the two-instruction payload is an idempotent create of the recipient's associated token account (ATA) followed by the SPL transfer itself. The create is a no-op when the account already exists, so a transfer never fails on a first-time recipient and needs no existence check; the sender pays the account rent when it does not yet exist. Only the recipient ATA is created — an absent sender ATA means a zero balance, which the transfer then refuses.

Renamed from transfer() (2.5.1)

The former transfer() method is gone. buildTransferPayload() replaces it and changes the contract deliberately: it returns a non-sendable TransferPayload instead of a ready-to-send transaction. Migrate by lowering the intent, spreading it into buildTransaction(...), and sending that:

migration.ts
// Before (removed):
// const tracker = address.transfer({ to, asset, amount }).send();

// After:
const payload = address.buildTransferPayload({ to, asset, amount });
const tracker = (await address.buildTransaction({ ...payload, chainId })).send();

Last updated on