TransactionsToken Transfers

Token Transfers

address.transfer({ to, amount, asset? }).whenConfirmed() sends value in one call — the common path; drop to buildTransferPayload → buildTransaction → send for finer control. Native coin, ERC-20/SPL by address or mint, or by symbol.

The common path is one call: address.transfer(req) lowers a chain-agnostic transfer intent, builds the transaction, signs it, and broadcasts — returning a live TransactionTracker.

one-call-transfer.ts
const evm = acc.wallets[0].evm;

// Native coin, a token by contract/mint, or a token by symbol — one call.
const receipt = await evm.transfer({
  to: recipient,
  amount: 1_000_000n,
  asset: { symbol: "USDC" }, // omit `asset` for the native coin
}).whenConfirmed();

transfer(req, opts?) is a thin composition of buildTransferPayload(req) → buildTransaction(...) → send(): chainId and rpcUrl default from the Address's own network, so you pass only the intent, and it returns the tracker (never a re-sendable transaction). opts carries the send options plus a guard — override the destination-safety gate with transfer(req, { guard: { forceUnsafeTransfer: true } }).

For finer control — setting gas, fees, nonce, or compute budget, or inspecting the transaction before it is sent — lower the intent yourself with buildTransferPayload and build + send it explicitly, as the rest of this page describes.

Lowering an intent with buildTransferPayload

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 payload = w0.evm.buildTransferPayload({
  to: String(w1.evm.publicKey),
  asset: { address: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48" }, // USDC
  amount: 1_000_000n,
});
// payload.to  === the token contract
// payload.value === 0n
// payload.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.

Destination safety

The full signature is buildTransferPayload(req: TransferRequest, opts?: DestinationGuardOptions). Before it lowers the intent, it runs an offline destination-safety gate on the true recipient — the check has to run on the intent, because once lowered an ERC-20's to becomes the token contract and the real recipient is buried in calldata.

If the recipient would irreversibly lose the funds, the call throws IRREGULAR_DESTINATION:

  • EVM — the zero address, a known burn address, a precompile, or (for a token send) the ERC-20's own contract.
  • SVM — an off-curve address for a native-SOL send, or the incinerator.

The thrown error's details carry destinationCode, severity, chain, address, and asset, so you can branch on the exact class. Override a deliberate case — an intentional burn, funding a program-owned account — with opts.forceUnsafeTransfer: true. (The offline gate only ever produces refuse-tier verdicts; other classes need an RPC lookup and surface through checkDestination below, not this throw — an ERC-20 recipient that is itself a contract is confirm-tier, while an SPL owner that is program-owned is refuse-tier.) A malformed address is always a PARAMETER_ERROR and is not overridable.

destination-safety.ts
// Throws IRREGULAR_DESTINATION — the zero address would burn the funds.
evm.buildTransferPayload({ to: "0x0000000000000000000000000000000000000000", amount: 1n });

// Proceeds — an explicit, acknowledged burn.
evm.buildTransferPayload(
  { to: "0x0000000000000000000000000000000000000000", amount: 1n },
  { forceUnsafeTransfer: true },
);

To screen a destination without building — and with the RPC-backed classes too (recipient is a contract / an EIP-7702-delegated EOA / a mint / a program-owned account, plus the SVM warnings for a frozen token account or a non-transferable Token-2022 mint) — use address.checkDestination(req, opts?), which returns a DestinationReport (action: "allow" | "warn" | "confirm" | "refuse") instead of throwing, so a wallet UI can warn the user before the transaction is built.

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 = evm.buildTransaction({ ...payload, chainId: 1 });
const tracker = tx.send();
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.

transfer() vs. buildTransferPayload()

Both exist — they are two altitudes on the same path:

  • transfer(req, opts?) — the one-call facade at the top of this page. Lowers, builds, signs, and sends in a single call and returns a TransactionTracker. Reach for it by default.
  • buildTransferPayload(req) — the lowering step alone. Returns a non-sendable TransferPayload that you spread into buildTransaction(...) and .send() — keeping buildTransaction(...).send() the single send path, and giving you a seam to set gas / fees / nonce before anything is broadcast.
two-altitudes.ts
// One call — the common path.
const receipt = await address.transfer({ to, asset, amount }).whenConfirmed();

// Explicit — when you need to shape the transaction before sending.
const payload = address.buildTransferPayload({ to, asset, amount });
const tracker = address.buildTransaction({ ...payload, chainId }).send();

Last updated on