Skip to main content
Code examples for common stablecoin scenarios.
These examples assume you’ve completed the Quickstart setup steps (dependencies and environment variables).

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)

Use receiveWithAuthorization 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 a CancelAuthorization 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,
  });
}

Production Readiness

Best practices for production integrations

Troubleshooting

Common errors and solutions

Reference

Full function and program reference

Quickstart

Back to quickstart