This solutions guide explains how to bridge USDC (USD Coin) between Base and Arbitrum networks using the CDP SDK and Circle’s Cross-Chain Transfer Protocol (CCTP).

Prerequisites

Before you begin, make sure you have the following:

  • Installed the CDP SDK.
  • A CDP Secret API Key (configured in a JSON file).
  • Node.js and npm installed on your system.
  • Have a persisted funded API Wallet on the Base network (minimum of 0.005 Base mainnet ETH and some amount of USDC) and another on Arbitrum (minimum of 0.005 Arbitrum mainnet ETH). See creating a wallet to quickly spin up a 1-of-1 Developer-Managed wallet, and refer to persisting a wallet for more information on how to save it.

Step-by-Step Guide

Step 1: Set Up the Project

First, let’s set up our project, import the necessary dependencies, and declare the required variables including contract ABIs:

usdc-bridging.ts
import { Coinbase, Wallet } from "@coinbase/coinbase-sdk";
import { createPublicClient, decodeAbiParameters, http, keccak256, toBytes } from 'viem';
import { base } from 'viem/chains'
import os from "os";
import dotenv from "dotenv";

dotenv.config();

// https://developers.circle.com/stablecoins/evm-smart-contracts contains the CCTP contract addresses 
const BASE_TOKEN_MESSENGER_ADDRESS = "0x1682Ae6375C4E4A97e4B583BC394c861A46D8962";
const ARBITRUM_MESSAGE_TRANSMITTER_ADDRESS = "0xC30362313FBBA5cf9163F0bb16a0e01f01A896ca";

// https://developers.circle.com/stablecoins/usdc-on-main-networks contains the USDC contract addresses on chains
const USDC_BASE_ADDRESS = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";

const tokenMessengerAbi = [
  {
    inputs: [
      { internalType: "uint256", name: "amount", type: "uint256" },
      { internalType: "uint32", name: "destinationDomain", type: "uint32" },
      { internalType: "bytes32", name: "mintRecipient", type: "bytes32" },
      { internalType: "address", name: "burnToken", type: "address" },
    ],
    name: "depositForBurn",
    outputs: [
      { internalType: "uint64", name: "_nonce", type: "uint64" },
    ],
    stateMutability: "nonpayable",
    type: "function",
  },
];

const messageTransmitterAbi = [
  {
    inputs: [
      { internalType: "bytes", name: "message", type: "bytes" },
      { internalType: "bytes", name: "attestation", type: "bytes" },
    ],
    name: "receiveMessage",
    outputs: [
      { internalType: "bool", name: "success", type: "bool" },
    ],
    stateMutability: "nonpayable",
    type: "function",
  },
];

Step 3: Create Bridging Function and Helper Functions

Using existing Base and Arbitrum mainnet wallets, we’ll create a function to bridge USDC between the two networks.

usdc-bridging.ts
async function bridgeUSDC(baseWallet, arbitrumWallet, usdcAmount) {
    const baseUSDCBalance = await baseWallet.getBalance("usdc");
    const arbitrumUSDCBalance = await arbitrumWallet.getBalance("usdc");
    console.log("Base USDC initial balance:", baseUSDCBalance, "| Arbitrum USDC initial balance:", arbitrumUSDCBalance);

    // pad the recipient address
    const arbitrumRecipientAddress = padAddress((await arbitrumWallet.getDefaultAddress()).getId());
    
    // step 1 - approve  TokenMessenger as the spender on base
    const approveTx = await baseWallet.invokeContract({
        contractAddress: USDC_BASE_ADDRESS,
        method: "approve",
        args: {
            spender: BASE_TOKEN_MESSENGER_ADDRESS,
            value: usdcAmount.toString()
        },
    });
    await approveTx.wait();
    console.log("Approve transaction completed:", approveTx.getTransactionHash());
    
    // step 2 - call depositForBurn
    const depositTx = await baseWallet.invokeContract({
        contractAddress: BASE_TOKEN_MESSENGER_ADDRESS,
        method: "depositForBurn",
        args: {
            amount: usdcAmount.toString(), // uint256 as string
            destinationDomain: "3", // uint32 as string
            mintRecipient: arbitrumRecipientAddress, // already padded bytes32 as hex string
            burnToken: USDC_BASE_ADDRESS
        },
        abi: tokenMessengerAbi
    });
    await depositTx.wait();
    console.log("Deposit transaction completed:", depositTx.getTransactionHash());
    
    // step 3 - get the messageHash from the transaction receipt logs
    const transactionReceipt = await getTransactionReceipt(depositTx.getTransactionHash());
    const eventTopic = keccak256(toBytes('MessageSent(bytes)'));
    const log = transactionReceipt.logs.find((l) => l.topics[0] === eventTopic);
    if (!log) {
        throw new Error('MessageSent event not found in transaction logs');
    }
    const messageBytes = decodeAbiParameters([{ type: 'bytes' }], log.data)[0];
    const messageHash = keccak256(messageBytes)
    console.log("Message hash:", messageHash);

    // step 4 - wait for attestation on the message. Note that this step could take 15-30 minutes
    let attestationResponse = { status: 'pending' }
    while (attestationResponse.status != 'complete') {
    const response = await fetch(
        `https://iris-api.circle.com/attestations/${messageHash}`,
    )
    attestationResponse = await response.json()
    await new Promise((r) => setTimeout(r, 2000))
    }

    const attestationSignature = attestationResponse.attestation;
    console.log("Received attestation signature from Circle's Iris service:", attestationSignature);

    // step 5 - call receiveMessage on the arbitrum wallet MessageTransmitter
    const receiveMessageTx = await arbitrumWallet.invokeContract({
        contractAddress: ARBITRUM_MESSAGE_TRANSMITTER_ADDRESS   ,
        method: "receiveMessage",
        args: {
            message: messageBytes,
            attestation: attestationSignature
        },
        abi: messageTransmitterAbi
    });
    await receiveMessageTx.wait();
    console.log("Receive message transaction completed:", receiveMessageTx.getTransactionHash());

    const finalBaseUSDCBalance = await baseWallet.getBalance("usdc");
    const finalArbitrumUSDCBalance = await arbitrumWallet.getBalance("usdc");
    console.log("Base USDC final balance:", finalBaseUSDCBalance, "| Arbitrum USDC final balance:", finalArbitrumUSDCBalance);
}

function padAddress(address) {
    address = address.replace(/^0x/, '');
    return '0x' + address.padStart(64, '0');
}

// Helper function to fetch transaction receipt
async function getTransactionReceipt(txHash) {
    const publicClient = createPublicClient({
        chain: base,
        transport: http(),
      })
    const receipt = await publicClient.getTransactionReceipt({ 
        hash: txHash
      })
    return receipt;
}

// Helper function to fetch and load a wallet from a seed file
async function fetchWalletAndLoadSeed(walletId, seedFilePath) {
    try {
        const wallet = await Wallet.fetch(walletId);
        await wallet.loadSeed(seedFilePath);

        console.log(`Successfully loaded funded wallet: `, wallet.getId());
        return wallet;
    } catch (error) {
        console.error(
        `Error loading funded wallet ${walletId} from seed file ${seedFilePath}: `,
        error,
        );
    }
}

Step 4: Create the Main Function

Finally, let’s create the main function to orchestrate the entire process:

async function main() {
    try {
      const { BASE_WALLET_ID, ARBITRUM_WALLET_ID, SEED_FILE_PATH } = process.env;

      // Configure location to CDP Secret API Key.
      Coinbase.configureFromJson({
        filePath: `${os.homedir()}/Downloads/cdp_api_key.json`,
      });
  
      // Fetch funded Wallet.
      const baseWallet = await fetchWalletAndLoadSeed(BASE_WALLET_ID, SEED_FILE_PATH);
      const arbitrumWallet = await fetchWalletAndLoadSeed(ARBITRUM_WALLET_ID, SEED_FILE_PATH);

      // bridge 1 wei of USDC from base to arbitrum (0.000001 USDC)
      await bridgeUSDC(baseWallet, arbitrumWallet, 1);
      console.log("Bridge USDC completed");
    } catch (error) {
      console.error(`Error in bridging USDC: `, error);
    }
};

main();

This sample app is for demonstration purposes only. Make sure to secure your wallets and only use small amounts of USDC for testing to minimize risks.

Helpful Tips

  • Gas Fees: Ensure you have enough ETH on both Base and Arbitrum networks to cover gas fees for the transactions.
  • USDC Decimals: Remember that USDC uses 6 decimal places. When specifying amounts, multiply by 10^6 (e.g., 1 USDC = 1000000).
  • Contract Addresses: The contract addresses used in this guide are for mainnet. For testnet development, use the appropriate testnet contract addresses.
  • Error Handling: In a production environment, add more robust error handling and logging to manage potential issues during the bridging process.
  • Wallet Security: Be cautious with wallet seeds and private keys. Never expose them in your code or commit them to version control.
  • Rate Limiting: Be aware of rate limits when calling the Circle API for attestations. Implement appropriate waiting mechanisms if necessary.
  • Testing: Always test your bridging process with small amounts before moving larger sums.
  • Additional Networks: This guide uses Base and Arbitrum networks. You can use other networks supported by CCTP by modifying the code.