These examples assume you’ve completed the Quickstart setup steps (dependencies and environment variables).
- Ethereum Virtual Machine
- Solana Virtual Machine
Setup
import { ethers } from "ethers";
const TOKEN_ADDRESS = "0x57AB1EFE59b1C7b36b1Dc9315B4782bCcBb83721"; // CBTUSD on Base Sepolia
const ERC20_ABI = [
"function balanceOf(address account) view returns (uint256)",
"function decimals() view returns (uint8)",
"function name() view returns (string)",
"function transfer(address to, uint256 amount) returns (bool)",
"function approve(address spender, uint256 amount) returns (bool)",
"function transferFrom(address from, address to, uint256 amount) returns (bool)",
"function allowance(address owner, address spender) view returns (uint256)",
];
const provider = new ethers.JsonRpcProvider(process.env.RPC_URL);
const signer = new ethers.Wallet(process.env.PRIVATE_KEY!, provider);
const token = new ethers.Contract(TOKEN_ADDRESS, ERC20_ABI, signer);
Transfer with memo
Attach a 32-byte reference to a transfer for off-chain reconciliation — for example, an order ID or payment reference.Memo values are permanently visible on the public blockchain. Do not include sensitive information such as customer names, account numbers, or PII. Use only non-sensitive references (such as a hashed or opaque order ID). If you need to attach sensitive data, encrypt it off-chain before encoding as bytes32 and decrypt it off-chain when reading the event.
const MEMO_ABI = [
"function transferWithMemo(address to, uint256 amount, bytes32 memo) external",
];
const tokenWithMemo = new ethers.Contract(TOKEN_ADDRESS, MEMO_ABI, signer);
const decimals = await token.decimals();
const amount = ethers.parseUnits("10", decimals);
const memo = ethers.encodeBytes32String("order-12345"); // up to 31 characters
const tx = await tokenWithMemo.transferWithMemo(recipientAddress, amount, memo);
await tx.wait();
console.log("Transfer with memo confirmed:", tx.hash);
Read a memo from a transaction
const MEMO_EVENT_ABI = [
"event Transfer(address indexed from, address indexed to, uint256 value)",
"event Memo(bytes32 indexed memo)",
];
const iface = new ethers.Interface(MEMO_EVENT_ABI);
const receipt = await provider.getTransactionReceipt(txHash);
for (const log of receipt.logs) {
try {
const parsed = iface.parseLog(log);
if (parsed?.name === "Memo") {
const memoString = ethers.decodeBytes32String(parsed.args.memo);
console.log("Memo:", memoString);
}
} catch {
// Log belongs to a different contract or event
}
}
Approve and transferFrom
Delegate spending to a contract or address, then transfer on the owner’s behalf:// Owner approves a spender for a specific amount
const approveTx = await token.approve(spenderAddress, ethers.parseUnits("100", decimals));
await approveTx.wait();
// Spender transfers on behalf of the owner
const spenderToken = token.connect(spenderSigner);
const transferTx = await spenderToken.transferFrom(ownerAddress, recipientAddress, amount);
await transferTx.wait();
Gasless approvals (ERC-2612 Permit)
Allow a spender to be approved without the owner paying gas:const PERMIT_ABI = [
"function permit(address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s) external",
"function nonces(address owner) view returns (uint256)",
"function name() view returns (string)",
];
const tokenPermit = new ethers.Contract(TOKEN_ADDRESS, PERMIT_ABI, provider);
const tokenName = await tokenPermit.name();
const nonce = await tokenPermit.nonces(signer.address);
const deadline = Math.floor(Date.now() / 1000) + 3600; // 1 hour
const domain = {
name: tokenName,
version: "1",
chainId: (await provider.getNetwork()).chainId,
verifyingContract: TOKEN_ADDRESS,
};
const types = {
Permit: [
{ name: "owner", type: "address" },
{ name: "spender", type: "address" },
{ name: "value", type: "uint256" },
{ name: "nonce", type: "uint256" },
{ name: "deadline", type: "uint256" },
],
};
const values = {
owner: signer.address,
spender: spenderAddress,
value: amount,
nonce,
deadline,
};
// Owner signs off-chain — no gas required
const signature = await signer.signTypedData(domain, types, values);
const { v, r, s } = ethers.Signature.from(signature);
// Anyone can submit the permit, often bundled with the spending transaction
const tokenWithPermit = new ethers.Contract(TOKEN_ADDRESS, PERMIT_ABI, relayerSigner);
await tokenWithPermit.permit(signer.address, spenderAddress, amount, deadline, v, r, s);
Gasless transfers (ERC-3009)
Allow a token holder to authorize a specific transfer without paying gas:const ERC3009_ABI = [
"function transferWithAuthorization(address from, address to, uint256 value, uint256 validAfter, uint256 validBefore, bytes32 nonce, uint8 v, bytes32 r, bytes32 s) external",
"function authorizationState(address authorizer, bytes32 nonce) view returns (bool)",
];
const tokenERC3009 = new ethers.Contract(TOKEN_ADDRESS, ERC3009_ABI, provider);
const tokenName = await token.name();
const nonce = ethers.hexlify(ethers.randomBytes(32)); // random, not sequential
const validAfter = 0n;
const validBefore = BigInt(Math.floor(Date.now() / 1000) + 3600); // 1 hour
const domain = {
name: tokenName,
version: "1",
chainId: (await provider.getNetwork()).chainId,
verifyingContract: TOKEN_ADDRESS,
};
const types = {
TransferWithAuthorization: [
{ name: "from", type: "address" },
{ name: "to", type: "address" },
{ name: "value", type: "uint256" },
{ name: "validAfter", type: "uint256" },
{ name: "validBefore", type: "uint256" },
{ name: "nonce", type: "bytes32" },
],
};
const values = {
from: signer.address,
to: recipientAddress,
value: amount,
validAfter,
validBefore,
nonce,
};
// Token holder signs off-chain — no gas required
const signature = await signer.signTypedData(domain, types, values);
const { v, r, s } = ethers.Signature.from(signature);
// Any relayer can submit the transfer on-chain
const tx = await tokenERC3009
.connect(relayerSigner)
.transferWithAuthorization(signer.address, recipientAddress, amount, validAfter, validBefore, nonce, v, r, s);
await tx.wait();
console.log("Gasless transfer confirmed:", tx.hash);
Restrict submission to the recipient (anti-front-running)
UsereceiveWithAuthorization when you want only the intended recipient to be able to submit the transaction:const RECEIVE_ABI = [
"function receiveWithAuthorization(address from, address to, uint256 value, uint256 validAfter, uint256 validBefore, bytes32 nonce, uint8 v, bytes32 r, bytes32 s) external",
];
// Sign the same way but use ReceiveWithAuthorization typehash
const types = {
ReceiveWithAuthorization: [
{ name: "from", type: "address" },
{ name: "to", type: "address" },
{ name: "value", type: "uint256" },
{ name: "validAfter", type: "uint256" },
{ name: "validBefore", type: "uint256" },
{ name: "nonce", type: "bytes32" },
],
};
const signature = await signer.signTypedData(domain, types, values);
const { v, r, s } = ethers.Signature.from(signature);
// Only `recipientSigner` (the `to` address) can submit this
const tokenReceive = new ethers.Contract(TOKEN_ADDRESS, RECEIVE_ABI, recipientSigner);
await tokenReceive.receiveWithAuthorization(
signer.address, recipientAddress, amount, validAfter, validBefore, nonce, v, r, s
);
Cancel a signed authorization
If a token holder signs an ERC-3009 authorization and changes their mind before any relayer submits it, they can void the nonce by signing aCancelAuthorization message:const CANCEL_ABI = [
"function cancelAuthorization(address authorizer, bytes32 nonce, uint8 v, bytes32 r, bytes32 s) external",
];
const cancelTypes = {
CancelAuthorization: [
{ name: "authorizer", type: "address" },
{ name: "nonce", type: "bytes32" },
],
};
const cancelValues = { authorizer: signer.address, nonce };
const cancelSignature = await signer.signTypedData(domain, cancelTypes, cancelValues);
const { v: cv, r: cr, s: cs } = ethers.Signature.from(cancelSignature);
const tokenCancel = new ethers.Contract(TOKEN_ADDRESS, CANCEL_ABI, relayerSigner);
await tokenCancel.cancelAuthorization(signer.address, nonce, cv, cr, cs);
Listen for on-chain events
Real-time event subscriptions are essential for balance tracking, order reconciliation, and detecting safety-control state changes.Subscribe to live events
const EVENTS_ABI = [
"event Transfer(address indexed from, address indexed to, uint256 value)",
"event Memo(bytes32 indexed memo)",
"event Paused(address account)",
"event Unpaused(address account)",
"event BlocklistStatusUpdated(address indexed account, bool blocklisted)",
];
const eventsToken = new ethers.Contract(TOKEN_ADDRESS, EVENTS_ABI, provider);
// Transfers involving a specific address — useful for balance tracking
const filterIn = eventsToken.filters.Transfer(null, walletAddress);
const filterOut = eventsToken.filters.Transfer(walletAddress, null);
eventsToken.on(filterIn, (from, to, value, event) => {
console.log(`Received ${value} from ${from} at block ${event.log.blockNumber}`);
});
eventsToken.on(filterOut, (from, to, value, event) => {
console.log(`Sent ${value} to ${to} at block ${event.log.blockNumber}`);
});
// Safety controls — update your application state when these fire
eventsToken.on("Paused", () => console.log("Transfers paused"));
eventsToken.on("Unpaused", () => console.log("Transfers resumed"));
eventsToken.on("BlocklistStatusUpdated", (account, blocklisted) => {
console.log(`Blocklist update: ${account} = ${blocklisted}`);
});
Query historical events
// Fetch all transfers to a wallet in the last ~24 hours (Base: ~2s blocks)
const currentBlock = await provider.getBlockNumber();
const fromBlock = currentBlock - 43200; // ~24h on Base
const filter = eventsToken.filters.Transfer(null, walletAddress);
const events = await eventsToken.queryFilter(filter, fromBlock, currentBlock);
for (const event of events) {
console.log({
from: event.args.from,
value: event.args.value.toString(),
txHash: event.transactionHash,
block: event.blockNumber,
});
}
Setup
import {
Connection,
Keypair,
PublicKey,
Transaction,
sendAndConfirmTransaction,
} from "@solana/web3.js";
import {
getMint,
getAccount,
getAssociatedTokenAddress,
getOrCreateAssociatedTokenAccount,
createTransferInstruction,
transfer,
} from "@solana/spl-token";
import * as fs from "fs";
const MINT_ADDRESS = new PublicKey("5P6MkoaCd9byPxH4X99kgKtS6SiuCQ67ZPCJzpXGkpCe"); // CBTUSD on devnet
const connection = new Connection(process.env.RPC_URL!);
const keyData = JSON.parse(fs.readFileSync(process.env.WALLET_PATH!, "utf-8"));
const payer = Keypair.fromSecretKey(Uint8Array.from(keyData));
Transfer to a recipient
const RECIPIENT_WALLET = new PublicKey("recipient-wallet-address");
// Ensure the recipient's token account exists before transferring
const recipientAccount = await getOrCreateAssociatedTokenAccount(
connection,
payer,
MINT_ADDRESS,
RECIPIENT_WALLET
);
const sourceAccount = await getOrCreateAssociatedTokenAccount(
connection,
payer,
MINT_ADDRESS,
payer.publicKey
);
const mintInfo = await getMint(connection, MINT_ADDRESS);
const amount = BigInt(10) * BigInt(10 ** mintInfo.decimals); // 10 tokens
const sig = await transfer(
connection,
payer,
sourceAccount.address,
recipientAccount.address,
payer,
amount
);
console.log("Transfer confirmed:", sig);
Batch transfers in one transaction
Combine multiple transfers into a single transaction to reduce fees:const recipients = [
{ wallet: new PublicKey("recipient-1"), amount: BigInt(1_000_000) },
{ wallet: new PublicKey("recipient-2"), amount: BigInt(2_000_000) },
{ wallet: new PublicKey("recipient-3"), amount: BigInt(500_000) },
];
const sourceAta = await getAssociatedTokenAddress(MINT_ADDRESS, payer.publicKey);
const transaction = new Transaction();
for (const { wallet, amount } of recipients) {
const destinationAta = await getAssociatedTokenAddress(MINT_ADDRESS, wallet);
transaction.add(
createTransferInstruction(
sourceAta,
destinationAta,
payer.publicKey,
amount
)
);
}
const sig = await sendAndConfirmTransaction(connection, transaction, [payer]);
console.log("Batch transfer confirmed:", sig);
Read token account info
const ataAddress = await getAssociatedTokenAddress(MINT_ADDRESS, payer.publicKey);
const account = await getAccount(connection, ataAddress);
const mintInfo = await getMint(connection, MINT_ADDRESS);
console.log("Balance:", Number(account.amount) / Math.pow(10, mintInfo.decimals));
console.log("Is frozen:", account.isFrozen);
Subscribe to balance changes
Solana doesn’t emit token-transfer events the way EVM does. Instead, subscribe to token-account changes via the WebSocket RPC:import { AccountLayout } from "@solana/spl-token";
const ataAddress = await getAssociatedTokenAddress(MINT_ADDRESS, payer.publicKey);
const subscriptionId = connection.onAccountChange(
ataAddress,
(accountInfo) => {
const decoded = AccountLayout.decode(accountInfo.data);
console.log("Balance changed:", decoded.amount.toString());
console.log("Is frozen:", decoded.state === 2); // 2 = frozen
},
{ commitment: "confirmed" }
);
// Later, when you no longer need updates:
await connection.removeAccountChangeListener(subscriptionId);
What to read next
Production Readiness
Best practices for production integrations
Troubleshooting
Common errors and solutions
Reference
Full function and program reference
Quickstart
Back to quickstart