Skip to main content
Get up and running with your custom stablecoin. This guide walks through reading a balance and sending your first transfer. The onchain source code is available at coinbase/custom-stablecoin.
A custom stablecoin is a standard token — an ERC-20 on EVM networks and an SPL token on Solana. That means you read balances and move funds with the same libraries, wallets, and explorers you’d use for any token. There is nothing custom-stablecoin-specific in the code below, so anything you already know about ERC-20 or SPL transfers applies directly.
Select your environment first. This guide covers both EVM and Solana. Choose your network in the tabs below — the prerequisites, dependencies, and code differ for each, so follow a single tab end to end.

Prerequisites

  • Node.js 18+ installed
  • A wallet with a private key for Base Sepolia
  • Base Sepolia ETH to pay for gas (get from CDP Faucet) — every transfer, including the self-transfer below, is an onchain transaction that costs gas
  • A CBTUSD balance to transfer (see How do I get testnet CBTUSD? below)
  • Basic familiarity with TypeScript
This quickstart uses CBTUSD, a test custom stablecoin already deployed on Base Sepolia. Your production stablecoin address is provided during onboarding.
On testnet, you have two ways to get CBTUSD:
  1. Request CBTUSD directly from the CDP Faucet.
  2. Get USDC from the CDP Faucet and swap it 1:1 for CBTUSD using the Stableswapper quickstart. There is no added Coinbase fee for the swap — you only pay network gas.
Either way, make sure you also have Base Sepolia ETH for gas. Once you hold CBTUSD, you can pick up below.In production, you acquire your own custom stablecoin the same way — swap USDC for it via Stableswapper, or through Coinbase Retail, Exchange, and Prime. Issuance itself is managed by Coinbase as part of onboarding.

1. Create tsconfig.json

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "CommonJS",
    "esModuleInterop": true,
    "resolveJsonModule": true,
    "skipLibCheck": true,
    "strict": true
  }
}

2. Install dependencies

npm install ethers
npm install --save-dev ts-node typescript @types/node
We use ethers here, but any EVM library (viem, web3.js) works the same way — the stablecoin is a plain ERC-20.

3. Set environment variables

export PRIVATE_KEY="your-wallet-private-key"
export RPC_URL="https://sepolia.base.org"
RPC_URL is the Base Sepolia endpoint your script reads from and submits transactions to. The public endpoint above is fine for testing; for production traffic, use a dedicated Base node or RPC provider to avoid rate limits.
Never commit your private key to source control. Use environment variables or a secrets manager. For production, use CDP Non-custodial Wallets for secure key management.

4. Create your script

The script below reads your CBTUSD balance and then sends a small transfer. It will:
  • Connect to Base Sepolia and load your wallet from PRIVATE_KEY
  • Read the token’s decimals so amounts display in human-readable units
  • Print your current CBTUSD balance
  • Transfer 1 CBTUSD to your own address as a smoke test
Why transfer to yourself? A self-transfer exercises the full signing-and-broadcasting path — proving your wallet, RPC, and the token contract all work together — without needing a second wallet and without changing your net balance. You still need to hold at least 1 CBTUSD (the contract checks your balance) and you still pay gas in ETH.
transfer.ts
import { ethers } from "ethers";

const TOKEN_ADDRESS = "0x57AB1EFE59b1C7b36b1Dc9315B4782bCcBb83721"; // CBTUSD on Base Sepolia

// A minimal ABI — you only need to declare the functions you actually call,
// not the token's entire interface.
const ERC20_ABI = [
  "function balanceOf(address account) view returns (uint256)",
  "function decimals() view returns (uint8)",
  "function transfer(address to, uint256 amount) returns (bool)",
];

async function main() {
  // The provider reads chain state; the signer signs and pays for transactions.
  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);

  // ERC-20 balances are stored as integers in the token's smallest unit.
  // `decimals` tells us the scaling factor so we can convert to/from whole tokens.
  const decimals = await token.decimals();
  const balance  = await token.balanceOf(signer.address);
  console.log("Balance:", ethers.formatUnits(balance, decimals));

  // Make the "you need a balance" assumption explicit with a friendly error.
  const amount = ethers.parseUnits("1", decimals);
  if (balance < amount) {
    throw new Error(
      "Not enough CBTUSD to transfer. See 'How do I get testnet CBTUSD?' in the quickstart."
    );
  }

  // Transfer 1 token to yourself as a smoke test
  const tx      = await token.transfer(signer.address, amount);
  const receipt = await tx.wait();
  console.log("Transfer confirmed:", receipt.hash);
}

main()
  .then(() => process.exit(0))
  .catch((err) => { console.error(err.message); process.exit(1); });

5. Run your script

npx ts-node transfer.ts
You should see output similar to:
Balance: 5.0
Transfer confirmed: 0x6c3f...a91b
Paste the transaction hash into Base Sepolia explorer to view the transfer onchain.
Going to production? The only changes are configuration, not code: point RPC_URL at Base Mainnet, fund the wallet with real ETH for gas, and set TOKEN_ADDRESS to your stablecoin’s address from onboarding.
Running into issues? See the Troubleshooting guide for common errors and solutions.

Examples

Memos, permit, gasless transfers, and more

Reference

Full function and program reference

Key Addresses

Contract and mint addresses

Overview

Learn about Custom Stablecoins