Interactive Tutorial with Developer Services
If you are new to building smart contracts, check out our interactive tutorial where you can transfer USDC using Circle's Developer Services: Contracts and Wallets.
This guide demonstrates how to use the viem framework and the CCTP API in a simple script that enables a user to transfer USDC from a wallet address on the Ethereum Sepolia testnet to another wallet address on the Avalanche Fuji testnet.
Select the appropriate CCTP version:
To get started with CCTP V1, follow the example script provided here. The example uses web3.js to transfer USDC from a wallet address on Ethereum Sepolia testnet to another wallet address on Avalanche Fuji testnet.
Interactive Tutorial with Developer Services
If you are new to building smart contracts, check out our interactive tutorial where you can transfer USDC using Circle's Developer Services: Contracts and Wallets.
The script has five steps:
const approveTx = await usdcEthContract.methods
.approve(ETH_TOKEN_MESSENGER_CONTRACT_ADDRESS, amount)
.send({ gas: approveTxGas })
depositForBurn
function on the
Ethereum Sepolia TokenMessenger contract deployed on
Sepolia testnet.const burnTx = await ethTokenMessengerContract.methods
.depositForBurn(
amount,
AVAX_DESTINATION_DOMAIN,
destinationAddressInBytes32,
USDC_ETH_CONTRACT_ADDRESS,
)
.send()
messageBytes
emitted by the MessageSent event from
depositForBurn
transaction logs and hashes the retrieved messageBytes
using the keccak256 hashing algorithm.const transactionReceipt = await web3.eth.getTransactionReceipt(
burnTx.transactionHash,
)
const eventTopic = web3.utils.keccak256('MessageSent(bytes)')
const log = transactionReceipt.logs.find((l) => l.topics[0] === eventTopic)
const messageBytes = web3.eth.abi.decodeParameters(['bytes'], log.data)[0]
const messageHash = web3.utils.keccak256(messageBytes)
messageHash
from the previous step.Rate Limit
The attestation service rate limit is 35 requests per second. If you exceed 35 requests per second, the service blocks all API requests for the next 5 minutes and returns an HTTP 429 response.
let attestationResponse = { status: 'pending' }
while (attestationResponse.status != 'complete') {
const response = await fetch(
`https://iris-api-sandbox.circle.com/attestations/${messageHash}`,
)
attestationResponse = await response.json()
await new Promise((r) => setTimeout(r, 2000))
}
receiveMessage
function on the Avalanche Fuji
MessageTransmitter contract to receive USDC at the Avalanche Fuji wallet
address.const receiveTx = await avaxMessageTransmitterContract.receiveMessage(
receivingMessageBytes,
signature,
)
Before you start building the sample app to perform a USDC transfer, ensure you have met the following prerequisites:
Install Node.js and npm
Set up a non-custodial wallet (for example, MetaMask)
Fund your wallet with testnet tokens
To build the script, first set up your project environment and install the required dependencies.
Create a new directory and initialize a new Node.js
project with default
settings:
mkdir cctp-v2-transfer
cd cctp-v2-transfer
npm init -y
This also creates a default package.json
file.
In your project directory, install the required dependencies, including viem
:
npm install axios@^1.7.9 dotenv@^16.4.7 viem@^2.23.4
This sets up your development environment with the necessary libraries for
building the script. It also updates the package.json
file with the
dependencies.
Add "type": "module"
to the package.json
file:
{
"name": "cctp-v2-transfer",
"version": "1.0.0",
"type": "module",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node transfer.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"axios": "^1.7.9",
"dotenv": "^16.4.7",
"viem": "^2.23.4"
}
}
Create a .env
file in your project directory and add your wallet private key:
echo "PRIVATE_KEY=your-private-key-here" > .env
Warning: This is strictly for testing purposes. Never share your private key.
This section covers the necessary setup for the transfer.js script, including defining keys and addresses, and configuring the wallet client for interacting with the source and destination chains.
Ensure that this section of the file includes your private key and associated wallet address. The script also predefines the contract addresses, the transfer amount, and the max fee. These definitions are critical for successfully transferring USDC between the intended wallets.
// ============ Configuration Constants ============
// Authentication
const PRIVATE_KEY = process.env.PRIVATE_KEY
const account = privateKeyToAccount(`0x${PRIVATE_KEY}`)
// Contract Addresses
const ETHEREUM_SEPOLIA_USDC = '0x1c7d4b196cb0c7b01d743fbc6116a902379c7238'
const ETHEREUM_SEPOLIA_TOKEN_MESSENGER =
'0x8fe6b999dc680ccfdd5bf7eb0974218be2542daa'
const AVALANCHE_FUJI_MESSAGE_TRANSMITTER =
'0xe737e5cebeeba77efe34d4aa090756590b1ce275'
// Transfer Parameters
const DESTINATION_ADDRESS = 'your-wallet-address' // Address to receive minted tokens on destination chain
const AMOUNT = 1_000_000n // Set transfer amount in 10^6 subunits (1 USDC; change as needed)
const maxFee = 500n // Set fast transfer max fee in 10^6 subunits (0.0005 USDC; change as needed)
// Bytes32 Formatted Parameters
const DESTINATION_ADDRESS_BYTES32 = `0x000000000000000000000000${DESTINATION_ADDRESS.slice(2)}` // Destination address in bytes32 format
const DESTINATION_CALLER_BYTES32 =
'0x0000000000000000000000000000000000000000000000000000000000000000' // Empty bytes32 allows any address to call MessageTransmitterV2.receiveMessage()
// Chain-specific Parameters
const ETHEREUM_SEPOLIA_DOMAIN = 0 // Source domain ID for Ethereum Sepolia testnet
const AVALANCHE_FUJI_DOMAIN = 1 // Destination domain ID for Avalanche Fuji testnet
The wallet client configures the appropriate network settings using viem
.
In this example, the script connects to the Ethereum Sepolia testnet and the
Avalanche Fuji testnet.
// Set up the wallet clients
const sepoliaClient = createWalletClient({
chain: sepolia,
transport: http(),
account,
})
const avalancheClient = createWalletClient({
chain: avalancheFuji,
transport: http(),
account,
})
The following sections outline the relevant transfer logic of the sample script. You can view the full source code in the Build the script section below. To perform the actual transfer of USDC from Ethereum Sepolia to Avalanche Fuji using CCTP V2, follow the steps below:
The first step is to grant approval for the TokenMessengerV2 contract deployed on the Ethereum Sepolia testnet to withdraw USDC from your wallet on that source chain. This allows the contract to withdraw USDC from the specified wallet address.
async function approveUSDC() {
console.log('Approving USDC transfer...')
const approveTx = await sepoliaClient.sendTransaction({
to: ETHEREUM_SEPOLIA_USDC,
data: encodeFunctionData({
abi: [
{
type: 'function',
name: 'approve',
stateMutability: 'nonpayable',
inputs: [
{ name: 'spender', type: 'address' },
{ name: 'amount', type: 'uint256' },
],
outputs: [{ name: '', type: 'bool' }],
},
],
functionName: 'approve',
args: [ETHEREUM_SEPOLIA_TOKEN_MESSENGER, 10_000_000_000n], // Set max allowance in 10^6 subunits (10,000 USDC; change as needed)
}),
})
console.log(`USDC Approval Tx: ${approveTx}`)
}
In this step, you call the depositForBurn
function from the
TokenMessengerV2 contract
deployed on the Ethereum Sepolia testnet to burn USDC on that source chain.
You specify the following parameters:
receiveMessage
async function burnUSDC() {
console.log('Burning USDC on Ethereum Sepolia...')
const burnTx = await sepoliaClient.sendTransaction({
to: ETHEREUM_SEPOLIA_TOKEN_MESSENGER,
data: encodeFunctionData({
abi: [
{
type: 'function',
name: 'depositForBurn',
stateMutability: 'nonpayable',
inputs: [
{ name: 'amount', type: 'uint256' },
{ name: 'destinationDomain', type: 'uint32' },
{ name: 'mintRecipient', type: 'bytes32' },
{ name: 'burnToken', type: 'address' },
{ name: 'destinationCaller', type: 'bytes32' },
{ name: 'maxFee', type: 'uint256' },
{ name: 'minFinalityThreshold', type: 'uint32' },
],
outputs: [],
},
],
functionName: 'depositForBurn',
args: [
AMOUNT,
AVALANCHE_FUJI_DOMAIN,
DESTINATION_ADDRESS_BYTES32,
ETHEREUM_SEPOLIA_USDC,
DESTINATION_CALLER_BYTES32,
maxFee,
1000, // minFinalityThreshold (1000 or less for Fast Transfer)
],
}),
})
console.log(`Burn Tx: ${burnTx}`)
return burnTx
}
In this step, you retrieve the attestation required to complete the CCTP transfer.
srcDomain
argument from the
CCTP Domain for your source chain.transactionHash
from the value returned by sendTransaction
within
the burnUSDC
function above.This step is essential for verifying the burn event before proceeding with the transfer.
async function retrieveAttestation(transactionHash) {
console.log('Retrieving attestation...')
const url = `https://iris-api-sandbox.circle.com/v2/messages/${ETHEREUM_SEPOLIA_DOMAIN}?transactionHash=${transactionHash}`
while (true) {
try {
const response = await axios.get(url)
if (response.status === 404) {
console.log('Waiting for attestation...')
}
if (response.data?.messages?.[0]?.status === 'complete') {
console.log('Attestation retrieved successfully!')
return response.data.messages[0]
}
console.log('Waiting for attestation...')
await new Promise((resolve) => setTimeout(resolve, 5000))
} catch (error) {
console.error('Error fetching attestation:', error.message)
await new Promise((resolve) => setTimeout(resolve, 5000))
}
}
}
In this final step, you call the receiveMessage
function from the
MessageTransmitterV2 contract
deployed on the Avalanche Fuji testnet to mint USDC on that destination
chain.
This step finalizes the CCTP transfer, making the USDC available on the destination chain.
async function mintUSDC(attestation) {
console.log('Minting USDC on Avalanche Fuji...')
const mintTx = await avalancheClient.sendTransaction({
to: AVALANCHE_FUJI_MESSAGE_TRANSMITTER,
data: encodeFunctionData({
abi: [
{
type: 'function',
name: 'receiveMessage',
stateMutability: 'nonpayable',
inputs: [
{ name: 'message', type: 'bytes' },
{ name: 'attestation', type: 'bytes' },
],
outputs: [],
},
],
functionName: 'receiveMessage',
args: [attestation.message, attestation.attestation],
}),
})
console.log(`Mint Tx: ${mintTx}`)
}
Now that you understand the core steps for programmatically transferring
USDC from Ethereum Sepolia to Avalanche Fuji using CCTP V2, create a
transfer.js
in your project directory and populate it with the sample code
below.
Note: The source wallet must contain native testnet tokens (to cover gas fees) and testnet USDC to complete the transfer.
// Import environment variables
import 'dotenv/config'
import { createWalletClient, http, encodeFunctionData } from 'viem'
import { privateKeyToAccount } from 'viem/accounts'
import { sepolia, avalancheFuji } from 'viem/chains'
import axios from 'axios'
// ============ Configuration Constants ============
// Authentication
const PRIVATE_KEY = process.env.PRIVATE_KEY
const account = privateKeyToAccount(`0x${PRIVATE_KEY}`)
// Contract Addresses
const ETHEREUM_SEPOLIA_USDC = '0x1c7d4b196cb0c7b01d743fbc6116a902379c7238'
const ETHEREUM_SEPOLIA_TOKEN_MESSENGER =
'0x8fe6b999dc680ccfdd5bf7eb0974218be2542daa'
const AVALANCHE_FUJI_MESSAGE_TRANSMITTER =
'0xe737e5cebeeba77efe34d4aa090756590b1ce275'
// Transfer Parameters
const DESTINATION_ADDRESS = 'your-wallet-address' // Address to receive minted tokens on destination chain
const AMOUNT = 1_000_000n // Set transfer amount in 10^6 subunits (1 USDC; change as needed)
const maxFee = 500n // Set fast transfer max fee in 10^6 subunits (0.0005 USDC; change as needed)
// Bytes32 Formatted Parameters
const DESTINATION_ADDRESS_BYTES32 = `0x000000000000000000000000${DESTINATION_ADDRESS.slice(2)}` // Destination address in bytes32 format
const DESTINATION_CALLER_BYTES32 =
'0x0000000000000000000000000000000000000000000000000000000000000000' // Empty bytes32 allows any address to call MessageTransmitterV2.receiveMessage()
// Chain-specific Parameters
const ETHEREUM_SEPOLIA_DOMAIN = 0 // Source domain ID for Ethereum Sepolia testnet
const AVALANCHE_FUJI_DOMAIN = 1 // Destination domain ID for Avalanche Fuji testnet
// Set up wallet clients
const sepoliaClient = createWalletClient({
chain: sepolia,
transport: http(),
account,
})
const avalancheClient = createWalletClient({
chain: avalancheFuji,
transport: http(),
account,
})
async function approveUSDC() {
console.log('Approving USDC transfer...')
const approveTx = await sepoliaClient.sendTransaction({
to: ETHEREUM_SEPOLIA_USDC,
data: encodeFunctionData({
abi: [
{
type: 'function',
name: 'approve',
stateMutability: 'nonpayable',
inputs: [
{ name: 'spender', type: 'address' },
{ name: 'amount', type: 'uint256' },
],
outputs: [{ name: '', type: 'bool' }],
},
],
functionName: 'approve',
args: [ETHEREUM_SEPOLIA_TOKEN_MESSENGER, 10_000_000_000n], // Set max allowance in 10^6 subunits (10,000 USDC; change as needed)
}),
})
console.log(`USDC Approval Tx: ${approveTx}`)
}
async function burnUSDC() {
console.log('Burning USDC on Ethereum Sepolia...')
const burnTx = await sepoliaClient.sendTransaction({
to: ETHEREUM_SEPOLIA_TOKEN_MESSENGER,
data: encodeFunctionData({
abi: [
{
type: 'function',
name: 'depositForBurn',
stateMutability: 'nonpayable',
inputs: [
{ name: 'amount', type: 'uint256' },
{ name: 'destinationDomain', type: 'uint32' },
{ name: 'mintRecipient', type: 'bytes32' },
{ name: 'burnToken', type: 'address' },
{ name: 'destinationCaller', type: 'bytes32' },
{ name: 'maxFee', type: 'uint256' },
{ name: 'minFinalityThreshold', type: 'uint32' },
],
outputs: [],
},
],
functionName: 'depositForBurn',
args: [
AMOUNT,
AVALANCHE_FUJI_DOMAIN,
DESTINATION_ADDRESS_BYTES32,
ETHEREUM_SEPOLIA_USDC,
DESTINATION_CALLER_BYTES32,
maxFee,
1000, // minFinalityThreshold (1000 or less for Fast Transfer)
],
}),
})
console.log(`Burn Tx: ${burnTx}`)
return burnTx
}
async function retrieveAttestation(transactionHash) {
console.log('Retrieving attestation...')
const url = `https://iris-api-sandbox.circle.com/v2/messages/${ETHEREUM_SEPOLIA_DOMAIN}?transactionHash=${transactionHash}`
while (true) {
try {
const response = await axios.get(url)
if (response.status === 404) {
console.log('Waiting for attestation...')
}
if (response.data?.messages?.[0]?.status === 'complete') {
console.log('Attestation retrieved successfully!')
return response.data.messages[0]
}
console.log('Waiting for attestation...')
await new Promise((resolve) => setTimeout(resolve, 5000))
} catch (error) {
console.error('Error fetching attestation:', error.message)
await new Promise((resolve) => setTimeout(resolve, 5000))
}
}
}
async function mintUSDC(attestation) {
console.log('Minting USDC on Avalanche Fuji...')
const mintTx = await avalancheClient.sendTransaction({
to: AVALANCHE_FUJI_MESSAGE_TRANSMITTER,
data: encodeFunctionData({
abi: [
{
type: 'function',
name: 'receiveMessage',
stateMutability: 'nonpayable',
inputs: [
{ name: 'message', type: 'bytes' },
{ name: 'attestation', type: 'bytes' },
],
outputs: [],
},
],
functionName: 'receiveMessage',
args: [attestation.message, attestation.attestation],
}),
})
console.log(`Mint Tx: ${mintTx}`)
}
async function main() {
await approveUSDC()
const burnTx = await burnUSDC()
const attestation = await retrieveAttestation(burnTx)
await mintUSDC(attestation)
console.log('USDC transfer completed!')
}
main().catch(console.error)
The transfer.js
script provides a complete end-to-end solution for
transfering USDC in CCTP v2 with a non-custodial wallet. In the next
section, you can test the script.
To test the script, run the following command:
node transfer.js
Once the script runs and the transfer is finalized, a confirmation receipt is logged in the console.
Rate Limit:
The attestation service rate limit is 35 requests per second. If you exceed this limit, the service blocks all API requests for the next 5 minutes and returns an HTTP 429 (Too Many Requests) response.
You have successfully transferred USDC between two EVM-compatible chains using CCTP end-to-end!