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};
fn format_eth_balance(balance: U256) -> String {
let eth = balance.to::<u128>() as f64 / 1e18;
format!("{eth:.6}")
}
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> {
dotenv().ok();
tracing_subscriber::fmt()
.with_max_level(tracing::Level::INFO)
.init();
println!("๐งช CCTP v2 Testnet Transfer: Arbitrum Sepolia โ Base Sepolia");
println!("====================================================\n");
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");
let signer: PrivateKeySigner = private_key_str
.parse()
.expect("Invalid TESTNET_PRIVATE_KEY format");
let wallet_address = signer.address();
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");
let wallet = EthereumWallet::from(signer);
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");
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");
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");
let min_eth = U256::from(1_000_000_000_000_000u64); let min_usdc = U256::from(1_000_000u64);
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");
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");
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");
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");
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");
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");
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");
let amount = U256::from(1_000_000);
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");
println!("\n๐ Starting Transfer...\n");
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}"
);
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");
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(())
}