cctp-rs 5.0.0

Type-safe Rust SDK for Circle's Cross-Chain Transfer Protocol (CCTP) v1 and v2 โ€” bridge USDC across 26+ EVM chains with fast transfer support.
Documentation
// SPDX-FileCopyrightText: 2025 Semiotic AI, Inc.
//
// SPDX-License-Identifier: Apache-2.0
//! Manual Testnet Validation for CCTP v2
//!
//! This example performs an actual USDC transfer from Sepolia to Base Sepolia.
//!
//! Prerequisites:
//! - Sepolia ETH for gas
//! - Sepolia USDC from Circle faucet (<https://faucet.circle.com>/)
//! - Base Sepolia ETH for destination gas
//!
//! Environment variables (set these in .env file):
//! - `TESTNET_PRIVATE_KEY`: Your wallet private key (must start with 0x)
//! - `TESTNET_API_KEY`: Alchemy API key (used for all testnet RPCs)
//! - `BASE_SEPOLIA_RPC_URL`: (optional) Override Base Sepolia RPC
//! - `ARBITRUM_SEPOLIA_RPC_URL`: (optional) Override Arbitrum Sepolia RPC
//!
//! Run with: `cargo run --example testnet_validation`

use std::future::IntoFuture;

use alloy_chains::NamedChain;
use alloy_network::EthereumWallet;
use alloy_primitives::{address, U256};
use alloy_provider::{Provider, ProviderBuilder};
use alloy_signer_local::PrivateKeySigner;
use cctp_rs::{
    CctpError, CctpV2, CctpV2Bridge, Erc20Contract, CCTP_V2_MESSAGE_TRANSMITTER_TESTNET,
    CCTP_V2_TOKEN_MESSENGER_TESTNET,
};
use dotenvy::dotenv;
use tracing::{info_span, Instrument};

/// Format ETH balance (18 decimals) for display
fn format_eth_balance(balance: U256) -> String {
    let eth = balance.to::<u128>() as f64 / 1e18;
    format!("{eth:.6}")
}

/// Format USDC balance (6 decimals) for display
fn format_usdc_balance(balance: U256) -> String {
    let usdc = balance.to::<u128>() as f64 / 1e6;
    format!("{usdc:.6}")
}

#[tokio::main]
async fn main() -> Result<(), CctpError> {
    // Load .env file
    dotenv().ok();

    // Initialize logging
    tracing_subscriber::fmt()
        .with_max_level(tracing::Level::INFO)
        .init();

    println!("๐Ÿงช CCTP v2 Testnet Transfer: Arbitrum Sepolia โ†’ Base Sepolia");
    println!("====================================================\n");

    // Load environment variables
    let private_key_str =
        std::env::var("TESTNET_PRIVATE_KEY").expect("TESTNET_PRIVATE_KEY must be set in .env file");

    let api_key =
        std::env::var("TESTNET_API_KEY").expect("TESTNET_API_KEY must be set in .env file");

    // Parse private key and get wallet address
    let signer: PrivateKeySigner = private_key_str
        .parse()
        .expect("Invalid TESTNET_PRIVATE_KEY format");
    let wallet_address = signer.address();

    // Construct RPC URLs
    let base_sepolia_rpc = std::env::var("BASE_SEPOLIA_RPC_URL")
        .unwrap_or_else(|_| format!("https://base-sepolia.g.alchemy.com/v2/{api_key}"));
    let arbitrum_sepolia_rpc = std::env::var("ARBITRUM_SEPOLIA_RPC_URL")
        .unwrap_or_else(|_| format!("https://arbitrum-sepolia.g.alchemy.com/v2/{api_key}"));

    println!("๐Ÿ“ Configuration:");
    println!("   Wallet: {wallet_address}");
    println!("   Source: Sepolia");
    println!("   Destination: Base Sepolia");
    println!("   Arbitrum Sepolia RPC: {arbitrum_sepolia_rpc}");
    println!("   Base Sepolia RPC: {base_sepolia_rpc}\n");

    // Create wallet from signer
    let wallet = EthereumWallet::from(signer);

    // Create providers with wallet for signing transactions
    println!("1๏ธโƒฃ  Creating blockchain providers...");

    let arb_sepolia_full_rpc_url = format!("{arbitrum_sepolia_rpc}{api_key}");
    let arbitrum_sepolia_provider = ProviderBuilder::new()
        .wallet(wallet.clone())
        .connect_http(arb_sepolia_full_rpc_url.parse().unwrap());

    let base_sepolia_full_rpc_url = format!("{base_sepolia_rpc}{api_key}");
    let base_sepolia_provider = ProviderBuilder::new()
        .wallet(wallet)
        .connect_http(base_sepolia_full_rpc_url.parse().unwrap());

    println!("   โœ… Providers created (with wallet signer)\n");

    // USDC contract addresses
    let usdc_arbitrum_sepolia = address!("75faf114eafb1BDbe2F0316DF893fd58CE46AA4d");
    let usdc_base_sepolia = address!("036CbD53842c5426634e7929541eC2318f3dCF7e");

    println!("2๏ธโƒฃ  Checking balances...");

    let usdc_arb_contract = Erc20Contract::new(usdc_arbitrum_sepolia, &arbitrum_sepolia_provider);
    let usdc_base_contract = Erc20Contract::new(usdc_base_sepolia, &base_sepolia_provider);

    let (arb_eth_balance, arb_usdc_balance, base_eth_balance, base_usdc_balance) = tokio::try_join!(
        async {
            arbitrum_sepolia_provider
                .get_balance(wallet_address)
                .into_future()
                .instrument(info_span!("get_eth_balance", chain = %NamedChain::ArbitrumSepolia))
                .await
                .map_err(CctpError::from)
        },
        async {
            usdc_arb_contract
                .balance_of(wallet_address)
                .instrument(info_span!("get_usdc_balance", chain = %NamedChain::ArbitrumSepolia))
                .await
                .map_err(CctpError::from)
        },
        async {
            base_sepolia_provider
                .get_balance(wallet_address)
                .into_future()
                .instrument(info_span!("get_eth_balance", chain = %NamedChain::BaseSepolia))
                .await
                .map_err(CctpError::from)
        },
        async {
            usdc_base_contract
                .balance_of(wallet_address)
                .instrument(info_span!("get_usdc_balance", chain = %NamedChain::BaseSepolia))
                .await
                .map_err(CctpError::from)
        },
    )?;

    println!("   Arbitrum Sepolia:");
    println!(
        "     ETH Balance:  {} ETH",
        format_eth_balance(arb_eth_balance)
    );
    println!(
        "     USDC Balance: {} USDC",
        format_usdc_balance(arb_usdc_balance)
    );
    println!("   Base Sepolia:");
    println!(
        "     ETH Balance:  {} ETH",
        format_eth_balance(base_eth_balance)
    );
    println!(
        "     USDC Balance: {} USDC",
        format_usdc_balance(base_usdc_balance)
    );
    println!("   โœ… Balances retrieved\n");

    // Summary
    println!("๐Ÿ“Š Balance Summary:");
    println!("โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”");
    println!("โ”‚ Chain               โ”‚ ETH Balance      โ”‚ USDC Balance     โ”‚");
    println!("โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค");
    println!(
        "โ”‚ Arbitrum Sepolia    โ”‚ {:>16} โ”‚ {:>16} โ”‚",
        format_eth_balance(arb_eth_balance),
        format_usdc_balance(arb_usdc_balance)
    );
    println!(
        "โ”‚ Base Sepolia        โ”‚ {:>16} โ”‚ {:>16} โ”‚",
        format_eth_balance(base_eth_balance),
        format_usdc_balance(base_usdc_balance)
    );
    println!("โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜\n");

    // Check if we have sufficient balances
    let min_eth = U256::from(1_000_000_000_000_000u64); // 0.001 ETH minimum
    let min_usdc = U256::from(1_000_000u64); // 1 USDC minimum (6 decimals)

    let mut issues: Vec<String> = Vec::new();

    if arb_eth_balance < min_eth {
        issues.push(format!(
            "โŒ Insufficient ETH on Arbitrum Sepolia: {} (need >= 0.001 ETH)\n   \
             โ†’ Get testnet ETH: https://faucet.quicknode.com/arbitrum/sepolia",
            format_eth_balance(arb_eth_balance)
        ));
    }

    if base_eth_balance < min_eth {
        issues.push(format!(
            "โŒ Insufficient ETH on Base Sepolia: {} (need >= 0.001 ETH)\n   \
             โ†’ Get testnet ETH: https://faucet.quicknode.com/base/sepolia",
            format_eth_balance(base_eth_balance)
        ));
    }

    if arb_usdc_balance < min_usdc {
        issues.push(format!(
            "โŒ Insufficient USDC on Arbitrum Sepolia: {} (need >= 1 USDC)\n   \
             โ†’ Get testnet USDC: https://faucet.circle.com/",
            format_usdc_balance(arb_usdc_balance)
        ));
    }

    if !issues.is_empty() {
        println!("โš ๏ธ  Cannot proceed - insufficient balances:\n");
        for issue in &issues {
            println!("   {issue}\n");
        }
        println!("Please fund your wallet and try again.");
        return Ok(());
    }

    println!("โœ… All balance requirements met!\n");

    // Safety exit - remove this line to proceed with the actual transfer
    println!("๐Ÿ›‘ Dry run complete. To execute the actual transfer:");
    println!("   Set the environment variable: EXECUTE_TRANSFER=true");
    println!("   Then run: cargo run --example testnet_validation\n");

    if std::env::var("EXECUTE_TRANSFER").unwrap_or_default() != "true" {
        return Ok(());
    }

    println!("๐Ÿš€ EXECUTE_TRANSFER=true detected, proceeding with transfer...\n");

    // Create bridge
    println!("4๏ธโƒฃ  Setting up CCTP v2 bridge...");
    let bridge = CctpV2Bridge::builder()
        .source_chain(NamedChain::ArbitrumSepolia)
        .destination_chain(NamedChain::BaseSepolia)
        .source_provider(arbitrum_sepolia_provider)
        .destination_provider(base_sepolia_provider)
        .recipient(wallet_address)
        .build();

    println!("   โœ… Bridge created\n");

    // Display configuration
    println!("5๏ธโƒฃ  Bridge Configuration:");
    println!("   Transfer Type: Standard");
    println!("   Finality Threshold: {}", bridge.finality_threshold());
    println!("   Fast Transfer: {}", bridge.is_fast_transfer());
    println!("   Expected Settlement: 10-15 minutes\n");

    // Validate domain IDs
    println!("6๏ธโƒฃ  Domain ID Validation:");
    let source_domain = bridge.source_chain().cctp_v2_domain_id()?;
    let dest_domain = bridge.destination_domain_id()?;

    println!("   Source Domain (Arbitrum Sepolia): {source_domain}");
    println!("   Destination Domain (Base): {dest_domain}");

    assert_eq!(
        source_domain.as_u32(),
        3,
        "Arbitrum Sepolia should have domain ID 3"
    );
    assert_eq!(dest_domain.as_u32(), 6, "Base should have domain ID 6");
    println!("   โœ… Domain IDs correct\n");

    // Validate contract addresses
    println!("7๏ธโƒฃ  Contract Addresses:");
    let token_messenger = bridge.token_messenger_v2_contract()?;
    let message_transmitter = bridge.message_transmitter_v2_contract()?;

    println!("   TokenMessenger: {token_messenger}");
    println!("   MessageTransmitter: {message_transmitter}");

    let expected_tm = CCTP_V2_TOKEN_MESSENGER_TESTNET;
    let expected_mt = CCTP_V2_MESSAGE_TRANSMITTER_TESTNET;

    assert_eq!(
        token_messenger, expected_tm,
        "TokenMessenger address mismatch"
    );
    assert_eq!(
        message_transmitter, expected_mt,
        "MessageTransmitter address mismatch"
    );
    println!("   โœ… Addresses correct\n");

    // Validate API endpoint
    println!("8๏ธโƒฃ  API Endpoint:");
    let api_url = bridge.api_url();
    println!("   {}", api_url.as_str());
    assert!(
        api_url.as_str().contains("sandbox"),
        "Should use sandbox API for testnet"
    );
    println!("   โœ… Using sandbox API\n");

    // Transfer configuration
    let amount = U256::from(1_000_000); // 1 USDC (6 decimals)

    println!("9๏ธโƒฃ  Transfer Details:");
    println!("   Token: USDC (Arbitrum Sepolia)");
    println!("   Token Address: {usdc_arbitrum_sepolia}");
    println!("   Amount: 1.0 USDC");
    println!("   From: {wallet_address}");
    println!("   To: {wallet_address} (same address on Base Sepolia)\n");

    // Execute the transfer
    println!("\n๐Ÿš€ Starting Transfer...\n");

    // Check and handle ERC20 approval
    println!("๐Ÿ”Ÿ Approval Phase:");
    println!("   Checking TokenMessenger allowance...");

    let token_messenger = bridge.token_messenger_v2_contract()?;
    let current_allowance = bridge
        .get_allowance(usdc_arbitrum_sepolia, wallet_address)
        .await?;

    println!(
        "   Current allowance: {} USDC",
        format_usdc_balance(current_allowance)
    );
    println!("   TokenMessenger: {token_messenger}");

    if current_allowance < amount {
        println!("   โš ๏ธ  Insufficient allowance, sending approval transaction...");

        let approval_tx = bridge
            .approve(usdc_arbitrum_sepolia, wallet_address, amount)
            .await?;
        println!("   โœ… Approval TX: {approval_tx}");
        println!(
            "   View on Arbitrum Sepolia Etherscan: https://sepolia.arbiscan.io/tx/{approval_tx}"
        );

        // Wait for approval to be mined
        println!("   Waiting for approval confirmation...");
        tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
    } else {
        println!("   โœ… Sufficient allowance already granted");
    }

    println!("\n1๏ธโƒฃ1๏ธโƒฃ Burn Phase:");
    println!("   Burning 1 USDC on Arbitrum Sepolia...");

    let burn_tx = bridge
        .burn(amount, wallet_address, usdc_arbitrum_sepolia)
        .await?;
    println!("   โœ… Burn TX: {burn_tx}");
    println!("   View on Arbitrum Sepolia Etherscan: https://sepolia.arbiscan.io/tx/{burn_tx}");

    println!("\n1๏ธโƒฃ2๏ธโƒฃ Attestation Phase:");
    println!("   Polling Circle API for attestation and message...");
    println!("   This typically takes 10-15 minutes for Arbitrum Sepolia finality.");
    println!("   Progress will be shown every 60 seconds.\n");

    // Poll for attestation with progress updates
    // V2 API uses transaction hash, not message hash
    // IMPORTANT: get_attestation returns both the canonical message and attestation from Circle's API
    // The MessageSent event log contains zeros in the nonce field - Circle fills this in
    let (message, attestation) = bridge
        .get_attestation(burn_tx, cctp_rs::PollingConfig::fast_transfer())
        .await?;
    println!("\n   โœ… Attestation and message received!");
    println!("   Message length: {} bytes", message.len());
    println!("   Attestation length: {} bytes", attestation.len());

    println!("\n1๏ธโƒฃ3๏ธโƒฃ Mint Phase:");
    println!("   Minting 1 USDC on Base Sepolia...");

    let mint_tx = bridge.mint(message, attestation, wallet_address).await?;
    println!("   โœ… Mint TX: {mint_tx}");
    println!("   View on BaseScan: https://base-sepolia.blockscout.com/tx/{mint_tx}");

    println!("\n๐ŸŽ‰ Transfer Complete!");
    println!("   Your 1 USDC has been successfully bridged from Arbitrum Sepolia to Base Sepolia.");
    println!("\n   Summary:");
    println!("   - Burn TX: {burn_tx}");
    println!("   - Mint TX: {mint_tx}");
    println!("\nโœ… v0.15.0 Testnet Validation: PASSED");

    Ok(())
}