diff --git a/.github/workflows/pr-review.yml b/.github/workflows/pr-review.yml index c4f59e4..df6cc89 100644 --- a/.github/workflows/pr-review.yml +++ b/.github/workflows/pr-review.yml @@ -1,12 +1,12 @@ name: PR Review on: - workflow_dispatch: -# pull_request: -# types: [opened, synchronize, ready_for_review, reopened] + pull_request: + types: [opened, synchronize, ready_for_review, reopened] jobs: review-with-tracking: + if: contains(github.event.pull_request.labels.*.name, 'jobtaker') runs-on: ${{ vars.RUNNER }} permissions: contents: read diff --git a/Cargo.toml b/Cargo.toml index f257330..2b35240 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,6 @@ alloy-provider = { version = "1", default-features = false, features = [ alloy-rpc-types = "1" alloy-sol-types = { version = "1", features = ["json"] } alloy-transport = { version = "1", default-features = false } -bincode = "1" nitrogen-circle-message-transmitter-v2-encoder = { git = "https://github.com/CarteraMesh/nitrogen.git", branch = "main" } nitrogen-circle-token-messenger-minter-v2-encoder = { git = "https://github.com/CarteraMesh/nitrogen.git", branch = "main" } nitrogen-instruction-builder = { git = "https://github.com/CarteraMesh/nitrogen.git", branch = "main" } @@ -42,6 +41,7 @@ solana-signature = "2" solana-signer = "2" spl-associated-token-account = { version = "7.0.0", features = ["no-entrypoint"] } thiserror = "2" +tokio = { version = "1", default-features = false, features = ["time"] } tracing = "0.1" [dev-dependencies] diff --git a/README.md b/README.md index 0c967ec..6dce50c 100644 --- a/README.md +++ b/README.md @@ -5,14 +5,62 @@ [![CI](https://github.com/CarteraMesh/cctp-bridge/workflows/test/badge.svg)](https://github.com/CarteraMesh/cctp-bridge/actions) [![Cov](https://codecov.io/github/CarteraMesh/cctp-bridge/graph/badge.svg?token=dILa1k9tlW)](https://codecov.io/github/CarteraMesh/cctp-bridge) -## Installation - -### Cargo - -* Install the rust toolchain in order to have cargo installed by following - [this](https://www.rust-lang.org/tools/install) guide. -* run `cargo install cctp-bridge` - +## About + +cctp-bridge is a Rust-based helper library for the Cross-Chain Token Protocol [CCTP](https://developers.circle.com/cctp). It facilitates the transfer of USDC between different blockchain networks. +This crates provides flexible control over the transfer process, allowing users to customize various aspects of the transfer. + +This project is a fork of the [cctp-rs](https://github.com/semiotic-ai/cctp-rs) [crate](https://crates.io/crates/cctp-rs) + +## Example + +```rust + +mod common; + +use { + alloy_chains::NamedChain, + alloy_provider::WalletProvider, + cctp_bridge::{Cctp, SolanSigners}, + common::*, + solana_signer::Signer, + tracing::info, +}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + dotenvy::dotenv().ok(); + tracing_subscriber::fmt::init(); + // Setup wallets + let base_sepolia_wallet_provider = evm_base_setup()?; + let (solana_keypair, rpc) = solana_setup()?; + info!( + "solana address {} sends to base address {}", + solana_keypair.pubkey(), + base_sepolia_wallet_provider.default_signer_address() + ); + + // Convenience wrapper for cctp_bridge::SolanaProvider trait + let rpc_wrapper: cctp_bridge::SolanaWrapper = rpc.into(); + // Convenience wrapper for solana_signer::Signer for use of CCTP operations + let signers = SolanSigners::new(solana_keypair); + + let bridge = Cctp::new_solana_evm( + rpc_wrapper, + base_sepolia_wallet_provider, + cctp_bridge::SOLANA_DEVNET, // source chain + NamedChain::BaseSepolia, // destination chain + ); + // 0.000010 USDC to base sepolia + let result = bridge.bridge_sol_evm(10, signers, None, None, None).await?; + println!("Solana burn txHash {}", result.burn); + println!( + "Base Receive txHash {}", + alloy_primitives::hex::encode(result.recv) + ); + Ok(()) +} +``` ## Development diff --git a/examples/common/mod.rs b/examples/common/mod.rs index c242f4d..6aecfc3 100644 --- a/examples/common/mod.rs +++ b/examples/common/mod.rs @@ -10,7 +10,7 @@ use { }; #[allow(dead_code)] -pub fn evm_setup() -> anyhow::Result { +pub fn evm_base_setup() -> anyhow::Result { let secret_key = env::var("EVM_SECRET_KEY").expect("EVM_SECRET_KEY not set"); let wallet = PrivateKeySigner::from_str(&secret_key).expect("Invalid private key"); let api_key = env::var("ALCHEMY_API_KEY").expect("ALCHEMY_API_KEY not set"); diff --git a/examples/evm_sol.rs b/examples/evm_sol.rs index 263c834..eff11f8 100644 --- a/examples/evm_sol.rs +++ b/examples/evm_sol.rs @@ -13,7 +13,7 @@ use { async fn main() -> anyhow::Result<()> { dotenvy::dotenv().ok(); tracing_subscriber::fmt::init(); - let base_provider = common::evm_setup()?; + let base_provider = common::evm_base_setup()?; let (owner, rpc) = common::solana_setup()?; info!( "solana address {} sends to base address {}", diff --git a/examples/sol_evm.rs b/examples/sol_evm.rs index b30b64d..676f6b4 100644 --- a/examples/sol_evm.rs +++ b/examples/sol_evm.rs @@ -3,7 +3,8 @@ mod common; use { alloy_chains::NamedChain, alloy_provider::WalletProvider, - cctp_bridge::{Cctp, SolanSigners, SolanaWrapper}, + cctp_bridge::{Cctp, SolanSigners}, + common::*, solana_signer::Signer, tracing::info, }; @@ -12,25 +13,32 @@ use { async fn main() -> anyhow::Result<()> { dotenvy::dotenv().ok(); tracing_subscriber::fmt::init(); - let base_provider = common::evm_setup()?; - let (owner, rpc) = common::solana_setup()?; + // Setup wallets + let base_sepolia_wallet_provider = evm_base_setup()?; + let (solana_keypair, rpc) = solana_setup()?; info!( "solana address {} sends to base address {}", - owner.pubkey(), - base_provider.default_signer_address() + solana_keypair.pubkey(), + base_sepolia_wallet_provider.default_signer_address() ); - let rpc: SolanaWrapper = rpc.into(); + // Convenience wrapper for cctp_bridge::SolanaProvider trait + let rpc_wrapper: cctp_bridge::SolanaWrapper = rpc.into(); + // Convenience wrapper for solana_signer::Signer for use of CCTP operations + let signers = SolanSigners::new(solana_keypair); let bridge = Cctp::new_solana_evm( - rpc, - base_provider, + rpc_wrapper, + base_sepolia_wallet_provider, cctp_bridge::SOLANA_DEVNET, NamedChain::BaseSepolia, ); - let result = bridge - .bridge_sol_evm(10, SolanSigners::new(owner), None, None, None) - .await?; - println!("success {result}"); + // 0.000010 USDC to base sepolia + let result = bridge.bridge_sol_evm(10, signers, None, None, None).await?; + println!("Solana burn txHash {}", result.burn); + println!( + "Base Receive txHash {}", + alloy_primitives::hex::encode(result.recv) + ); Ok(()) } diff --git a/src/attestation.rs b/src/attestation.rs index 43a69ec..af43be2 100644 --- a/src/attestation.rs +++ b/src/attestation.rs @@ -5,7 +5,7 @@ use { }; /// To be passed to message transmitter to claim/mint -#[derive(Clone)] +#[derive(Clone, Eq, PartialEq)] pub struct Attestation { pub attestation: Vec, pub message: Vec, diff --git a/src/bridge.rs b/src/bridge.rs index 1383791..eb5c788 100644 --- a/src/bridge.rs +++ b/src/bridge.rs @@ -8,18 +8,22 @@ use { CctpChain, error::{Error, Result}, }, - alloy_chains::{Chain, NamedChain}, + alloy_chains::{Chain, ChainKind, NamedChain}, alloy_network::Ethereum, - alloy_primitives::{FixedBytes, TxHash, hex}, + alloy_primitives::{ + FixedBytes, + TxHash, + hex::{self, encode}, + }, alloy_provider::Provider, alloy_sol_types::SolEvent, reqwest::{Client, Response}, solana_signature::Signature as SolanaSignature, std::{ fmt::{Debug, Display}, - thread::sleep, time::Duration, }, + tokio::time::sleep, tracing::{Level, debug, error, info, instrument, trace}, }; @@ -51,12 +55,15 @@ pub const CHAIN_CONFIRMATION_CONFIG: &[(NamedChain, u64, Duration)] = &[ ]; /// Gets the chain-specific confirmation configuration -pub fn get_chain_confirmation_config(chain: &NamedChain) -> (u64, Duration) { - CHAIN_CONFIRMATION_CONFIG - .iter() - .find(|(ch, _, _)| ch == chain) - .map(|(_, confirmations, timeout)| (*confirmations, *timeout)) - .unwrap_or((1, DEFAULT_CONFIRMATION_TIMEOUT)) +pub fn get_chain_confirmation_config(chain: &Chain) -> (u64, Duration) { + match chain.kind() { + ChainKind::Named(n) => CHAIN_CONFIRMATION_CONFIG + .iter() + .find(|(ch, _, _)| ch == n) + .map(|(_, confirmations, timeout)| (*confirmations, *timeout)) + .unwrap_or((1, DEFAULT_CONFIRMATION_TIMEOUT)), + ChainKind::Id(_) => (2, Duration::from_secs(4)), // TODO add specific timeout for id chain + } } /// For solana reclaim accounts @@ -114,7 +121,7 @@ impl Display for EvmBridgeResult { } } -#[derive(Clone, Debug)] +#[derive(Clone)] pub struct Cctp { source_provider: SrcProvider, destination_provider: DstProvider, @@ -124,6 +131,18 @@ pub struct Cctp { client: Client, } +impl Debug for Cctp { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let src_domain = self.source_chain.cctp_domain_id().unwrap_or(u32::MAX); + let dst_domain = self.destination_chain.cctp_domain_id().unwrap_or(u32::MAX); + write!( + f, + "CCTP[{}({})->{}({})]", + self.source_chain, src_domain, self.destination_chain, dst_domain + ) + } +} + impl Cctp { /// Returns the CCTP API URL for the current environment pub fn api_url(&self) -> &'static str { @@ -196,6 +215,21 @@ impl Cctp { ) } + /// Wrapper call to [`get_attestation_with_retry`] for evm [`TxHash`] + pub async fn get_attestation_evm( + &self, + message_hash: TxHash, + max_attempts: Option, + poll_interval: Option, + ) -> Result { + self.get_attestation_with_retry( + format!("0x{}", encode(message_hash)), + max_attempts, + poll_interval, + ) + .await + } + /// Gets the attestation for a message hash from the CCTP API /// /// # Arguments @@ -238,7 +272,7 @@ impl Cctp { if response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS { let secs = 5 * 60; debug!(sleep_secs = ?secs, "Rate limit exceeded, waiting before retrying"); - sleep(Duration::from_secs(secs)); + sleep(Duration::from_secs(secs)).await; continue; } @@ -251,7 +285,7 @@ impl Cctp { poll_interval = ?poll_interval, "Attestation not found (404), waiting before retrying" ); - sleep(Duration::from_secs(poll_interval)); + sleep(Duration::from_secs(poll_interval)).await; continue; } @@ -322,7 +356,7 @@ impl Cctp { poll_interval = ?poll_interval, "Attestation pending, waiting before retrying" ); - sleep(Duration::from_secs(poll_interval)); + sleep(Duration::from_secs(poll_interval)).await; } } } diff --git a/src/bridge/evm.rs b/src/bridge/evm.rs index c26d919..b343d31 100644 --- a/src/bridge/evm.rs +++ b/src/bridge/evm.rs @@ -1,19 +1,18 @@ use { super::Cctp, crate::{ + Attestation, CctpChain, ERC20, - EvmBridgeResult, MessageTransmitter, TokenMessengerContract, error::Result, }, alloy_chains::NamedChain, alloy_network::Ethereum, - alloy_primitives::{Address as EvmAddress, TxHash, hex::encode, ruint::aliases::U256}, + alloy_primitives::{Address as EvmAddress, TxHash, ruint::aliases::U256}, alloy_provider::{Provider, WalletProvider}, reqwest::Client, - std::time::Duration, tracing::{Level, debug, info, instrument}, }; // EVM to EVM bridging implementation @@ -39,23 +38,22 @@ impl< } } - #[instrument(skip(self,max_fee,destination_caller,min_finality_threshold), level = Level::INFO)] - pub async fn bridge( + #[instrument(skip(max_fee,destination_caller,min_finality_threshold), level = Level::INFO)] + pub async fn burn( &self, amount: alloy_primitives::U256, destination_caller: Option, max_fee: Option, min_finality_threshold: Option, - // attestation_poll_interval: Option, - ) -> Result { + ) -> Result<(TxHash, Option)> { info!("burning {amount}"); let source_provider = self.source_provider(); - let destination_provider = self.destination_provider(); let recipient: EvmAddress = self.recipient().try_into()?; let token_messenger: EvmAddress = self.token_messenger_contract()?.try_into()?; - let message_transmitter: EvmAddress = self.message_transmitter_contract()?.try_into()?; let destination_domain = self.destination_domain_id()?; let usdc_address = self.source_chain().usdc_token_address()?.try_into()?; + let (confirmations, confirm_timeout) = + super::get_chain_confirmation_config(&self.source_chain); let erc20 = ERC20::new(usdc_address, source_provider); let usdc_balance = erc20 @@ -64,16 +62,21 @@ impl< .await?; debug!("balance {usdc_balance}"); + if usdc_balance < amount { + return Err(crate::Error::InsufficientBalance(usdc_balance, amount)); + } let current_allowance = erc20 .allowance(source_provider.default_signer_address(), token_messenger) .call() .await?; - let approval_hash: Option = if current_allowance < U256::from(10) { + let approval_hash: Option = if current_allowance < amount { debug!("Approving allowance"); let approve_hash = erc20 - .approve(token_messenger, U256::from(10)) + .approve(token_messenger, amount) .send() .await? + .with_required_confirmations(confirmations) + .with_timeout(Some(confirm_timeout)) .watch() .await?; info!("Approved USDC spending: {}", approve_hash); @@ -96,16 +99,19 @@ impl< let burn_hash = source_provider .send_transaction(burn_tx) .await? - .with_required_confirmations(2) - .with_timeout(Some(Duration::from_secs( - self.source_chain().confirmation_average_time_seconds()?, - ))) + .with_required_confirmations(confirmations) + .with_timeout(Some(confirm_timeout)) .watch() .await?; - let attestation = self - .get_attestation_with_retry(format!("0x{}", encode(burn_hash)), None, Some(10)) - .await?; + Ok((burn_hash, approval_hash)) + } + + pub async fn recv_with_attestation(&self, attestation: &Attestation) -> Result { + let destination_provider = self.destination_provider(); + let message_transmitter: EvmAddress = self.message_transmitter_contract()?.try_into()?; + let (confirmations, confirm_timeout) = + super::get_chain_confirmation_config(self.destination_chain()); let message_transmitter = MessageTransmitter::new(message_transmitter, destination_provider); @@ -114,20 +120,28 @@ impl< attestation.attestation.clone().into(), ); - info!("receiving {amount} on chain {}", self.destination_chain(),); - let recv_hash = recv_message_tx + info!("receiving on chain {}", self.destination_chain()); + Ok(recv_message_tx .send() .await? - .with_required_confirmations(2) - .with_timeout(Some(Duration::from_secs(90))) + .with_required_confirmations(confirmations) + .with_timeout(Some(confirm_timeout)) .watch() + .await?) + } + + #[instrument(level = Level::INFO)] + pub async fn recv( + &self, + burn_hash: TxHash, + max_attempts: Option, + poll_interval: Option, + ) -> Result<(Attestation, TxHash)> { + let attestation = self + .get_attestation_evm(burn_hash, max_attempts, poll_interval) .await?; - Ok(EvmBridgeResult { - approval: approval_hash, - burn: burn_hash, - recv: recv_hash, - attestation, - }) + let hash = self.recv_with_attestation(&attestation).await?; + Ok((attestation, hash)) } } diff --git a/src/bridge/solana.rs b/src/bridge/solana.rs index 4b6ec81..d8e2082 100644 --- a/src/bridge/solana.rs +++ b/src/bridge/solana.rs @@ -262,7 +262,7 @@ impl + WalletProvider + Clone, DstProvider: Sola .allowance(source_provider.default_signer_address(), token_messenger) .call() .await?; - if current_allowance < U256::from(10) { + if current_allowance < 10 { debug!("Approving allowance"); let approve_hash = erc20 .approve(token_messenger, U256::from(10)) diff --git a/src/chain.rs b/src/chain.rs index 441ed90..b1e3a12 100644 --- a/src/chain.rs +++ b/src/chain.rs @@ -70,8 +70,8 @@ impl CctpChain for Chain { match self.kind() { ChainKind::Named(n) => n.confirmation_average_time_seconds(), ChainKind::Id(id) => match *id { - SOLANA_DEVNET_ID => Ok(2), - SOLANA_MAINNET_ID => Ok(2), + SOLANA_DEVNET_ID => Ok(4), + SOLANA_MAINNET_ID => Ok(4), _ => Err(Error::ChainNotSupported { chain: self.to_string(), }), @@ -261,7 +261,7 @@ impl CctpChain for NamedChain { Arbitrum => ARBITRUM_USDC_CONTRACT, Avalanche => AVALANCHE_USDC_CONTRACT, Base => BASE_USDC_CONTRACT, - Optimism => OPTIMISM_MESSAGE_TRANSMITTER_ADDRESS, + Optimism => OPTIMISM_USDC_CONTRACT, Polygon => POLYGON_USDC_CONTRACT, // Testnets ArbitrumSepolia => ARBITRUM_SEPOLIA_USDC_CONTRACT, @@ -280,177 +280,245 @@ impl CctpChain for NamedChain { } } -// #[cfg(test)] -// mod tests { -// use super::*; -// use alloy_chains::NamedChain; -// use rstest::rstest; - -// #[rstest] -// #[case(NamedChain::Mainnet, true)] -// #[case(NamedChain::Arbitrum, true)] -// #[case(NamedChain::Base, true)] -// #[case(NamedChain::Optimism, true)] -// #[case(NamedChain::Unichain, true)] -// #[case(NamedChain::Avalanche, true)] -// #[case(NamedChain::Polygon, true)] -// #[case(NamedChain::Sepolia, true)] -// #[case(NamedChain::ArbitrumSepolia, true)] -// #[case(NamedChain::AvalancheFuji, true)] -// #[case(NamedChain::BaseSepolia, true)] -// #[case(NamedChain::OptimismSepolia, true)] -// #[case(NamedChain::PolygonAmoy, true)] -// #[case(NamedChain::BinanceSmartChain, false)] -// #[case(NamedChain::Fantom, false)] -// fn test_is_supported(#[case] chain: NamedChain, #[case] expected: bool) { -// assert_eq!(chain.is_supported(), expected); -// } - -// #[rstest] -// #[case(NamedChain::Mainnet, 19 * 60)] -// #[case(NamedChain::Arbitrum, 19 * 60)] -// #[case(NamedChain::Base, 19 * 60)] -// #[case(NamedChain::Optimism, 19 * 60)] -// #[case(NamedChain::Unichain, 19 * 60)] -// #[case(NamedChain::Avalanche, 20)] -// #[case(NamedChain::Polygon, 8 * 60)] -// #[case(NamedChain::Sepolia, 60)] -// #[case(NamedChain::ArbitrumSepolia, 20)] -// #[case(NamedChain::AvalancheFuji, 20)] -// #[case(NamedChain::BaseSepolia, 20)] -// #[case(NamedChain::OptimismSepolia, 20)] -// #[case(NamedChain::PolygonAmoy, 20)] -// fn test_confirmation_average_time_seconds_supported_chains( -// #[case] chain: NamedChain, -// #[case] expected: u64, -// ) { -// assert_eq!(chain.confirmation_average_time_seconds().unwrap(), -// expected); } - -// #[test] -// fn test_confirmation_average_time_seconds_unsupported_chain() { -// let result = -// NamedChain::BinanceSmartChain.confirmation_average_time_seconds(); -// assert!(result.is_err()); -// assert!(matches!( -// result.unwrap_err(), -// CctpError::ChainNotSupported { .. } -// )); -// } - -// #[rstest] -// #[case(NamedChain::Arbitrum, ARBITRUM_DOMAIN_ID)] -// #[case(NamedChain::ArbitrumSepolia, ARBITRUM_DOMAIN_ID)] -// #[case(NamedChain::Avalanche, AVALANCHE_DOMAIN_ID)] -// #[case(NamedChain::Base, BASE_DOMAIN_ID)] -// #[case(NamedChain::BaseSepolia, BASE_DOMAIN_ID)] -// #[case(NamedChain::Mainnet, ETHEREUM_DOMAIN_ID)] -// #[case(NamedChain::Sepolia, ETHEREUM_DOMAIN_ID)] -// #[case(NamedChain::Optimism, OPTIMISM_DOMAIN_ID)] -// #[case(NamedChain::Polygon, POLYGON_DOMAIN_ID)] -// #[case(NamedChain::Unichain, UNICHAIN_DOMAIN_ID)] -// fn test_cctp_domain_id_supported_chains(#[case] chain: NamedChain, -// #[case] expected: u32) { assert_eq!(chain.cctp_domain_id().unwrap(), -// expected); } - -// #[test] -// fn test_cctp_domain_id_unsupported_chain() { -// let result = NamedChain::BinanceSmartChain.cctp_domain_id(); -// assert!(result.is_err()); -// assert!(matches!( -// result.unwrap_err(), -// CctpError::ChainNotSupported { .. } -// )); -// } - -// #[rstest] -// #[case(NamedChain::Arbitrum, ARBITRUM_TOKEN_MESSENGER_ADDRESS)] -// #[case(NamedChain::ArbitrumSepolia, -// ARBITRUM_SEPOLIA_TOKEN_MESSENGER_ADDRESS)] #[case(NamedChain::Avalanche, -// AVALANCHE_TOKEN_MESSENGER_ADDRESS)] #[case(NamedChain::Base, -// BASE_TOKEN_MESSENGER_ADDRESS)] #[case(NamedChain::BaseSepolia, -// BASE_SEPOLIA_TOKEN_MESSENGER_ADDRESS)] #[case(NamedChain::Sepolia, -// ETHEREUM_SEPOLIA_TOKEN_MESSENGER_ADDRESS)] #[case(NamedChain::Mainnet, -// ETHEREUM_TOKEN_MESSENGER_ADDRESS)] #[case(NamedChain::Optimism, -// OPTIMISM_TOKEN_MESSENGER_ADDRESS)] #[case(NamedChain::Polygon, -// POLYGON_CCTP_TOKEN_MESSENGER)] #[case(NamedChain::Unichain, -// UNICHAIN_CCTP_TOKEN_MESSENGER)] -// fn test_token_messenger_address_supported_chains( -// #[case] chain: NamedChain, -// #[case] expected_str: &str, -// ) { -// let result = chain.token_messenger_address().unwrap(); -// let expected: Address = expected_str.parse().unwrap(); -// assert_eq!(result, expected); -// } - -// #[test] -// fn test_token_messenger_address_unsupported_chain() { -// let result = NamedChain::BinanceSmartChain.token_messenger_address(); -// assert!(result.is_err()); -// assert!(matches!( -// result.unwrap_err(), -// CctpError::ChainNotSupported { .. } -// )); -// } - -// #[rstest] -// #[case(NamedChain::Arbitrum, ARBITRUM_MESSAGE_TRANSMITTER_ADDRESS)] -// #[case(NamedChain::Avalanche, AVALANCHE_MESSAGE_TRANSMITTER_ADDRESS)] -// #[case(NamedChain::Base, BASE_MESSAGE_TRANSMITTER_ADDRESS)] -// #[case(NamedChain::Mainnet, ETHEREUM_MESSAGE_TRANSMITTER_ADDRESS)] -// #[case(NamedChain::Optimism, OPTIMISM_MESSAGE_TRANSMITTER_ADDRESS)] -// #[case(NamedChain::Polygon, POLYGON_CCTP_MESSAGE_TRANSMITTER)] -// #[case( -// NamedChain::ArbitrumSepolia, -// ARBITRUM_SEPOLIA_MESSAGE_TRANSMITTER_ADDRESS -// )] -// #[case(NamedChain::BaseSepolia, -// BASE_SEPOLIA_MESSAGE_TRANSMITTER_ADDRESS)] #[case(NamedChain::Sepolia, -// ETHEREUM_SEPOLIA_MESSAGE_TRANSMITTER_ADDRESS)] -// #[case(NamedChain::Unichain, UNICHAIN_CCTP_MESSAGE_TRANSMITTER)] -// fn test_message_transmitter_address_supported_chains( -// #[case] chain: NamedChain, -// #[case] expected_str: &str, -// ) { -// let result = chain.message_transmitter_address().unwrap(); -// let expected: Address = expected_str.parse().unwrap(); -// assert_eq!(result, expected); -// } - -// #[test] -// fn test_message_transmitter_address_unsupported_chain() { -// let result = -// NamedChain::BinanceSmartChain.message_transmitter_address(); assert! -// (result.is_err()); assert!(matches!( -// result.unwrap_err(), -// CctpError::ChainNotSupported { .. } -// )); -// } - -// #[test] -// fn test_address_parsing_validation() { -// // All addresses should be valid Ethereum addresses -// for chain in [ -// NamedChain::Mainnet, -// NamedChain::Arbitrum, -// NamedChain::Base, -// NamedChain::Optimism, -// NamedChain::Unichain, -// NamedChain::Avalanche, -// NamedChain::Polygon, -// NamedChain::Sepolia, -// NamedChain::ArbitrumSepolia, -// NamedChain::BaseSepolia, -// ] { -// assert!( -// chain.token_messenger_address().is_ok(), -// "Token messenger address should be valid for {chain:?}" -// ); -// assert!( -// chain.message_transmitter_address().is_ok(), -// "Message transmitter address should be valid for {chain:?}" -// ); -// } -// } -// } +#[cfg(test)] +mod tests { + use { + super::*, + crate::{SOLANA_DEVNET, SOLANA_MAINNET}, + alloy_chains::NamedChain, + rstest::rstest, + }; + + #[rstest] + #[case(NamedChain::Mainnet, true)] + #[case(NamedChain::Arbitrum, true)] + #[case(NamedChain::Base, true)] + #[case(NamedChain::Optimism, true)] + #[case(NamedChain::Unichain, true)] + #[case(NamedChain::Avalanche, true)] + #[case(NamedChain::Polygon, true)] + #[case(NamedChain::Sepolia, true)] + #[case(NamedChain::ArbitrumSepolia, true)] + #[case(NamedChain::AvalancheFuji, true)] + #[case(NamedChain::BaseSepolia, true)] + #[case(NamedChain::OptimismSepolia, true)] + #[case(NamedChain::PolygonAmoy, true)] + #[case(NamedChain::BinanceSmartChain, false)] + #[case(NamedChain::Fantom, false)] + fn test_is_supported(#[case] chain: NamedChain, #[case] expected: bool) { + assert_eq!(chain.is_supported(), expected); + } + + #[rstest] + #[case(NamedChain::Mainnet, 19 * 60)] + #[case(NamedChain::Arbitrum, 19 * 60)] + #[case(NamedChain::Base, 19 * 60)] + #[case(NamedChain::Optimism, 19 * 60)] + #[case(NamedChain::Unichain, 19 * 60)] + #[case(NamedChain::Avalanche, 20)] + #[case(NamedChain::Polygon, 8 * 60)] + #[case(NamedChain::Sepolia, 60)] + #[case(NamedChain::ArbitrumSepolia, 20)] + #[case(NamedChain::AvalancheFuji, 20)] + #[case(NamedChain::BaseSepolia, 20)] + #[case(NamedChain::OptimismSepolia, 20)] + #[case(NamedChain::PolygonAmoy, 20)] + fn test_confirmation_average_time_seconds_supported_chains( + #[case] chain: NamedChain, + #[case] expected: u64, + ) { + assert_eq!(chain.confirmation_average_time_seconds().unwrap(), expected); + } + + #[rstest] + #[case(SOLANA_DEVNET, 4)] + #[case(SOLANA_MAINNET, 4)] + fn test_sol_confirmation_average_time_seconds_supported_chains( + #[case] chain: Chain, + #[case] expected: u64, + ) { + assert_eq!(chain.confirmation_average_time_seconds().unwrap(), expected); + } + + #[test] + fn test_confirmation_average_time_seconds_unsupported_chain() { + let result = NamedChain::BinanceSmartChain.confirmation_average_time_seconds(); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + Error::ChainNotSupported { .. } + )); + } + + #[rstest] + #[case(NamedChain::Arbitrum, ARBITRUM_DOMAIN_ID)] + #[case(NamedChain::ArbitrumSepolia, ARBITRUM_DOMAIN_ID)] + #[case(NamedChain::Avalanche, AVALANCHE_DOMAIN_ID)] + #[case(NamedChain::Base, BASE_DOMAIN_ID)] + #[case(NamedChain::BaseSepolia, BASE_DOMAIN_ID)] + #[case(NamedChain::Mainnet, ETHEREUM_DOMAIN_ID)] + #[case(NamedChain::Sepolia, ETHEREUM_DOMAIN_ID)] + #[case(NamedChain::Optimism, OPTIMISM_DOMAIN_ID)] + #[case(NamedChain::Polygon, POLYGON_DOMAIN_ID)] + #[case(NamedChain::Unichain, UNICHAIN_DOMAIN_ID)] + fn test_cctp_domain_id_supported_chains(#[case] chain: NamedChain, #[case] expected: u32) { + assert_eq!(chain.cctp_domain_id().unwrap(), expected); + } + + #[test] + fn test_cctp_domain_id_unsupported_chain() { + let result = NamedChain::BinanceSmartChain.cctp_domain_id(); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + Error::ChainNotSupported { .. } + )); + } + + #[rstest] + #[case(NamedChain::Arbitrum, ARBITRUM_TOKEN_MESSENGER_ADDRESS)] + #[case(NamedChain::ArbitrumSepolia, ARBITRUM_SEPOLIA_TOKEN_MESSENGER_ADDRESS)] + #[case(NamedChain::Avalanche, AVALANCHE_TOKEN_MESSENGER_ADDRESS)] + #[case(NamedChain::Base, BASE_TOKEN_MESSENGER_ADDRESS)] + #[case(NamedChain::BaseSepolia, BASE_SEPOLIA_TOKEN_MESSENGER_ADDRESS)] + #[case(NamedChain::Sepolia, ETHEREUM_SEPOLIA_TOKEN_MESSENGER_ADDRESS)] + #[case(NamedChain::Mainnet, ETHEREUM_TOKEN_MESSENGER_ADDRESS)] + #[case(NamedChain::Optimism, OPTIMISM_TOKEN_MESSENGER_ADDRESS)] + #[case(NamedChain::Polygon, POLYGON_CCTP_TOKEN_MESSENGER)] + #[case(NamedChain::Unichain, UNICHAIN_CCTP_TOKEN_MESSENGER)] + fn test_token_messenger_address_supported_chains( + #[case] chain: NamedChain, + #[case] expected_addr: alloy_primitives::Address, + ) -> anyhow::Result<()> { + let result: alloy_primitives::Address = chain.token_messenger_address()?.try_into()?; + assert_eq!(result, expected_addr); + Ok(()) + } + + #[rstest] + #[case(NamedChain::Mainnet, ETHEREUM_USDC_CONTRACT)] + #[case(NamedChain::Arbitrum, ARBITRUM_USDC_CONTRACT)] + #[case(NamedChain::Avalanche, AVALANCHE_USDC_CONTRACT)] + #[case(NamedChain::Base, BASE_USDC_CONTRACT)] + #[case(NamedChain::Optimism, OPTIMISM_USDC_CONTRACT)] + #[case(NamedChain::Polygon, POLYGON_USDC_CONTRACT)] + #[case(NamedChain::ArbitrumSepolia, ARBITRUM_SEPOLIA_USDC_CONTRACT)] + #[case(NamedChain::Sepolia, ETHEREUM_SEPOLIA_USDC_CONTRACT)] + #[case(NamedChain::BaseSepolia, BASE_SEPOLIA_USDC_CONTRACT)] + #[case(NamedChain::OptimismSepolia, OPTIMISM_SEPOLIA_USDC_CONTRACT)] + #[case(NamedChain::Unichain, UNICHAIN_USDC_CONTRACT)] + fn test_evm_usdc_address( + #[case] chain: NamedChain, + #[case] expected_addr: alloy_primitives::Address, + ) -> anyhow::Result<()> { + let result: alloy_primitives::Address = chain.usdc_token_address()?.try_into()?; + assert_eq!(result, expected_addr); + Ok(()) + } + + #[rstest] + #[case(SOLANA_DEVNET, SOLANA_DEVNET_USDC_TOKEN)] + #[case(SOLANA_MAINNET, SOLANA_MAINNET_USDC_TOKEN)] + fn test_sol_usdc_address( + #[case] chain: Chain, + #[case] expected_addr: solana_pubkey::Pubkey, + ) -> anyhow::Result<()> { + let result: solana_pubkey::Pubkey = chain.usdc_token_address()?.try_into()?; + assert_eq!(result, expected_addr); + Ok(()) + } + + #[rstest] + #[case(SOLANA_DEVNET, nitrogen_circle_token_messenger_minter_v2_encoder::ID)] + #[case(SOLANA_MAINNET, nitrogen_circle_token_messenger_minter_v2_encoder::ID)] + fn test_sol_token_messenger_address_supported_chains( + #[case] chain: Chain, + #[case] expected_addr: solana_pubkey::Pubkey, + ) -> anyhow::Result<()> { + let result: solana_pubkey::Pubkey = chain.token_messenger_address()?.try_into()?; + assert_eq!(result, expected_addr); + Ok(()) + } + + #[rstest] + #[case(SOLANA_DEVNET, nitrogen_circle_message_transmitter_v2_encoder::ID)] + #[case(SOLANA_MAINNET, nitrogen_circle_message_transmitter_v2_encoder::ID)] + fn test_sol_messenger_address_supported_chains( + #[case] chain: Chain, + #[case] expected_addr: solana_pubkey::Pubkey, + ) -> anyhow::Result<()> { + let result: solana_pubkey::Pubkey = chain.message_transmitter_address()?.try_into()?; + assert_eq!(result, expected_addr); + Ok(()) + } + + #[test] + fn test_token_messenger_address_unsupported_chain() { + let result = NamedChain::BinanceSmartChain.token_messenger_address(); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + Error::ChainNotSupported { .. } + )); + } + + #[rstest] + #[case(NamedChain::Arbitrum, ARBITRUM_MESSAGE_TRANSMITTER_ADDRESS)] + #[case(NamedChain::Avalanche, AVALANCHE_MESSAGE_TRANSMITTER_ADDRESS)] + #[case(NamedChain::Base, BASE_MESSAGE_TRANSMITTER_ADDRESS)] + #[case(NamedChain::Mainnet, ETHEREUM_MESSAGE_TRANSMITTER_ADDRESS)] + #[case(NamedChain::Optimism, OPTIMISM_MESSAGE_TRANSMITTER_ADDRESS)] + #[case(NamedChain::Polygon, POLYGON_CCTP_MESSAGE_TRANSMITTER)] + #[case( + NamedChain::ArbitrumSepolia, + ARBITRUM_SEPOLIA_MESSAGE_TRANSMITTER_ADDRESS + )] + #[case(NamedChain::BaseSepolia, BASE_SEPOLIA_MESSAGE_TRANSMITTER_ADDRESS)] + #[case(NamedChain::Sepolia, ETHEREUM_SEPOLIA_MESSAGE_TRANSMITTER_ADDRESS)] + #[case(NamedChain::Unichain, UNICHAIN_CCTP_MESSAGE_TRANSMITTER)] + fn test_message_transmitter_address_supported_chains( + #[case] chain: NamedChain, + #[case] expected_addr: alloy_primitives::Address, + ) -> anyhow::Result<()> { + let result: alloy_primitives::Address = + chain.message_transmitter_address().unwrap().try_into()?; + assert_eq!(result, expected_addr); + Ok(()) + } + + #[test] + fn test_message_transmitter_address_unsupported_chain() { + let result = NamedChain::BinanceSmartChain.message_transmitter_address(); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + Error::ChainNotSupported { .. } + )); + } + + #[test] + fn test_address_parsing_validation() { + // All addresses should be valid Ethereum addresses + for chain in [ + NamedChain::Mainnet, + NamedChain::Arbitrum, + NamedChain::Base, + NamedChain::Optimism, + NamedChain::Unichain, + NamedChain::Avalanche, + NamedChain::Polygon, + NamedChain::Sepolia, + NamedChain::ArbitrumSepolia, + NamedChain::BaseSepolia, + ] { + assert!( + chain.token_messenger_address().is_ok(), + "Token messenger address should be valid for {chain:?}" + ); + assert!( + chain.message_transmitter_address().is_ok(), + "Message transmitter address should be valid for {chain:?}" + ); + } + } +} diff --git a/src/error.rs b/src/error.rs index 6941c9e..b4907b8 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,4 +1,7 @@ -use {alloy_primitives::hex::FromHexError, thiserror::Error}; +use { + alloy_primitives::{hex::FromHexError, ruint::aliases::U256}, + thiserror::Error, +}; #[derive(Error, Debug)] pub enum Error { @@ -59,9 +62,6 @@ pub enum Error { #[error("max fee {0} > amount {1}")] SolanaInvalidFee(u64, u64), - #[error(transparent)] - BincodeError(#[from] bincode::Error), - #[error(transparent)] SolanaSendError(#[from] nitrogen_instruction_builder::Error), @@ -70,6 +70,9 @@ pub enum Error { #[error("failed to get solana fee recipient account: {0}")] SolanaFeeRecipientError(String), + + #[error("Insufficient balance have {0} need {1}")] + InsufficientBalance(U256, U256), } pub type Result = std::result::Result; diff --git a/tests/integration.rs b/tests/integration.rs index c185a6a..53f6710 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -44,11 +44,13 @@ pub fn evm_setup(base_sepolia: bool) -> anyhow::Result anyhow::Result<(Keypair, RpcClient)> { let kp_file = env::var("KEYPAIR_FILE").ok(); let owner = if let Some(kp) = kp_file { @@ -66,6 +68,9 @@ pub fn solana_setup() -> anyhow::Result<(Keypair, RpcClient)> { Ok((owner, rpc)) } +const fn usdc_amount(dollars: u64) -> u64 { + dollars * 1_000_000 // USDC has 6 decimals +} #[tokio::test] async fn test_reclaim() -> Result<()> { setup(); @@ -82,12 +87,11 @@ async fn test_reclaim() -> Result<()> { } #[tokio::test] -async fn test_evm() -> Result<()> { +async fn test_burn_too_much() -> Result<()> { setup(); let sepolia_provider = evm_setup(false)?; let base_provider = evm_setup(true)?; let recipient = base_provider.default_signer_address(); - info!("evm address {recipient}"); let bridge = Cctp::new( sepolia_provider, @@ -96,8 +100,46 @@ async fn test_evm() -> Result<()> { NamedChain::BaseSepolia, recipient, ); - let result = bridge.bridge(U256::from(10), None, None, None).await?; - info!("bridge result {}", result); + let too_much: u64 = usdc_amount(10_000_000_000); + let result = bridge.burn(U256::from(too_much), None, None, None).await; + assert!(result.is_err(), "Should fail with insufficient balance"); + + let e = result.unwrap_err(); + assert!(matches!(e, cctp_bridge::Error::InsufficientBalance(_, _))); + println!("error {e}"); + Ok(()) +} + +#[tokio::test] +async fn test_evm_burn_recv_split() -> Result<()> { + setup(); + let sepolia_provider = evm_setup(false)?; + let base_provider = evm_setup(true)?; + let recipient = base_provider.default_signer_address(); + + let bridge = Cctp::new( + sepolia_provider, + base_provider, + NamedChain::Sepolia, + NamedChain::BaseSepolia, + recipient, + ); + let (burn_hash, approval_hash) = bridge.burn(U256::from(15), None, None, None).await?; + assert!(!burn_hash.is_zero(), "Burn hash should not be zero"); + info!( + "burn {burn_hash} approval {}", + approval_hash.unwrap_or_default() + ); + let attest = bridge.get_attestation_evm(burn_hash, None, None).await?; + let (recv_attest, recv_hash) = bridge.recv(burn_hash, None, None).await?; + assert!(!recv_hash.is_zero(), "Receive hash should not be zero"); + assert!( + !attest.attestation.is_empty(), + "Attestation should not be empty" + ); + assert!(!attest.message.is_empty(), "Message should not be empty"); + assert_eq!(recv_attest, attest); + info!("attest {attest} recv {recv_hash}"); Ok(()) }