From 790b7cb5f12aaf228bee0347eacee4e0e10f0ce9 Mon Sep 17 00:00:00 2001 From: dougefresh Date: Thu, 16 Oct 2025 19:00:57 +0000 Subject: [PATCH 1/6] feat: split burn and recv functions --- .github/dependabot.yml | 3 +- .github/workflows/pr-review.yml | 6 +- src/bridge/evm.rs | 48 +++- src/chain.rs | 385 +++++++++++++++++--------------- tests/integration.rs | 35 ++- 5 files changed, 282 insertions(+), 195 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index c11fc03..b756b0a 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,7 +5,8 @@ updates: schedule: interval: "weekly" ignore: + - dependency-name: "bincode*" - dependency-name: "solana*" - dependency-name: "spl-*" - - dependency-name: "*" + - dependency-name: ".*" update-types: ["version-update:semver-major"] 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/src/bridge/evm.rs b/src/bridge/evm.rs index c26d919..63af7c9 100644 --- a/src/bridge/evm.rs +++ b/src/bridge/evm.rs @@ -1,6 +1,7 @@ use { super::Cctp, crate::{ + Attestation, CctpChain, ERC20, EvmBridgeResult, @@ -40,20 +41,17 @@ impl< } #[instrument(skip(self,max_fee,destination_caller,min_finality_threshold), level = Level::INFO)] - pub async fn bridge( + 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 erc20 = ERC20::new(usdc_address, source_provider); @@ -102,6 +100,14 @@ impl< ))) .watch() .await?; + + Ok((burn_hash, approval_hash)) + } + + #[instrument(skip(self), level = Level::INFO)] + pub async fn recv(&self, burn_hash: TxHash) -> Result<(Attestation, TxHash)> { + let destination_provider = self.destination_provider(); + let message_transmitter: EvmAddress = self.message_transmitter_contract()?.try_into()?; let attestation = self .get_attestation_with_retry(format!("0x{}", encode(burn_hash)), None, Some(10)) .await?; @@ -114,14 +120,32 @@ impl< attestation.attestation.clone().into(), ); - info!("receiving {amount} on chain {}", self.destination_chain(),); - let recv_hash = recv_message_tx - .send() - .await? - .with_required_confirmations(2) - .with_timeout(Some(Duration::from_secs(90))) - .watch() + info!("receiving on chain {}", self.destination_chain(),); + Ok(( + attestation, + recv_message_tx + .send() + .await? + .with_required_confirmations(2) + .with_timeout(Some(Duration::from_secs(90))) + .watch() + .await?, + )) + } + + #[instrument(skip(self,max_fee,destination_caller,min_finality_threshold), level = Level::INFO)] + pub async fn bridge( + &self, + amount: alloy_primitives::U256, + destination_caller: Option, + max_fee: Option, + min_finality_threshold: Option, + // attestation_poll_interval: Option, + ) -> Result { + let (burn_hash, approval_hash) = self + .burn(amount, destination_caller, max_fee, min_finality_threshold) .await?; + let (attestation, recv_hash) = self.recv(burn_hash).await?; Ok(EvmBridgeResult { approval: approval_hash, diff --git a/src/chain.rs b/src/chain.rs index 441ed90..458cd6d 100644 --- a/src/chain.rs +++ b/src/chain.rs @@ -280,177 +280,214 @@ 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); + } + + #[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(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/tests/integration.rs b/tests/integration.rs index c185a6a..18b6892 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 { @@ -82,13 +84,11 @@ async fn test_reclaim() -> Result<()> { } #[tokio::test] -async fn test_evm() -> Result<()> { +async fn test_evm_bridge() -> 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, base_provider, @@ -101,6 +101,31 @@ async fn test_evm() -> Result<()> { 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(10), None, None, None).await?; + info!( + "burn {burn_hash} approval {}", + approval_hash.unwrap_or_default() + ); + + let (attest, recv_hash) = bridge.recv(burn_hash).await?; + info!("attest {attest} recv {recv_hash}"); + Ok(()) +} + #[tokio::test] async fn test_evm_sol() -> Result<()> { setup(); From a4e62b9452af91a0f4f340747e567fd96e8db0dc Mon Sep 17 00:00:00 2001 From: dougefresh Date: Thu, 16 Oct 2025 19:46:12 +0000 Subject: [PATCH 2/6] fix: pr review --- .github/dependabot.yml | 2 +- src/bridge/evm.rs | 49 +++++++++++++++++++++++++++++++----------- src/chain.rs | 6 +++--- src/error.rs | 3 +++ tests/integration.rs | 12 +++++++++-- 5 files changed, 53 insertions(+), 19 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b756b0a..a8e8fc7 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,5 +8,5 @@ updates: - dependency-name: "bincode*" - dependency-name: "solana*" - dependency-name: "spl-*" - - dependency-name: ".*" + - dependency-name: "*" update-types: ["version-update:semver-major"] diff --git a/src/bridge/evm.rs b/src/bridge/evm.rs index 63af7c9..07054bc 100644 --- a/src/bridge/evm.rs +++ b/src/bridge/evm.rs @@ -66,7 +66,7 @@ impl< .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)) @@ -105,11 +105,20 @@ impl< } #[instrument(skip(self), level = Level::INFO)] - pub async fn recv(&self, burn_hash: TxHash) -> Result<(Attestation, TxHash)> { + pub async fn recv( + &self, + burn_hash: TxHash, + max_attempts: Option, + poll_interval: Option, + ) -> Result<(Attestation, TxHash)> { let destination_provider = self.destination_provider(); let message_transmitter: EvmAddress = self.message_transmitter_contract()?.try_into()?; let attestation = self - .get_attestation_with_retry(format!("0x{}", encode(burn_hash)), None, Some(10)) + .get_attestation_with_retry( + format!("0x{}", encode(burn_hash)), + max_attempts, + poll_interval, + ) .await?; let message_transmitter = @@ -120,14 +129,16 @@ impl< attestation.attestation.clone().into(), ); - info!("receiving on chain {}", self.destination_chain(),); + info!("receiving on chain {}", self.destination_chain()); Ok(( attestation, recv_message_tx .send() .await? .with_required_confirmations(2) - .with_timeout(Some(Duration::from_secs(90))) + .with_timeout(Some(Duration::from_secs( + self.source_chain().confirmation_average_time_seconds()?, + ))) .watch() .await?, )) @@ -140,18 +151,30 @@ impl< destination_caller: Option, max_fee: Option, min_finality_threshold: Option, - // attestation_poll_interval: Option, + max_attempts: Option, + attestation_poll_interval: Option, ) -> Result { + // verify addresses resolve to real evm before burning. See recv method for more + // details. + let _: EvmAddress = self.message_transmitter_contract()?.try_into()?; let (burn_hash, approval_hash) = self .burn(amount, destination_caller, max_fee, min_finality_threshold) .await?; - let (attestation, recv_hash) = self.recv(burn_hash).await?; - Ok(EvmBridgeResult { - approval: approval_hash, - burn: burn_hash, - recv: recv_hash, - attestation, - }) + match self + .recv(burn_hash, max_attempts, attestation_poll_interval) + .await + { + Ok((attestation, recv_hash)) => Ok(EvmBridgeResult { + approval: approval_hash, + burn: burn_hash, + recv: recv_hash, + attestation, + }), + Err(e) => Err(crate::Error::ReceiveFailedAfterBurn { + burn_hash: format!("{}", burn_hash), + reason: e.to_string(), + }), + } } } diff --git a/src/chain.rs b/src/chain.rs index 458cd6d..8f59020 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, diff --git a/src/error.rs b/src/error.rs index 6941c9e..c37abed 100644 --- a/src/error.rs +++ b/src/error.rs @@ -70,6 +70,9 @@ pub enum Error { #[error("failed to get solana fee recipient account: {0}")] SolanaFeeRecipientError(String), + + #[error("Receive failed after successful burn (tx: {burn_hash}): {reason}")] + ReceiveFailedAfterBurn { burn_hash: String, reason: String }, } pub type Result = std::result::Result; diff --git a/tests/integration.rs b/tests/integration.rs index 18b6892..554fab7 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -96,7 +96,9 @@ async fn test_evm_bridge() -> Result<()> { NamedChain::BaseSepolia, recipient, ); - let result = bridge.bridge(U256::from(10), None, None, None).await?; + let result = bridge + .bridge(U256::from(10), None, None, None, None, None) + .await?; info!("bridge result {}", result); Ok(()) } @@ -116,12 +118,18 @@ async fn test_evm_burn_recv_split() -> Result<()> { recipient, ); let (burn_hash, approval_hash) = bridge.burn(U256::from(10), 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, recv_hash) = bridge.recv(burn_hash).await?; + let (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" + ); info!("attest {attest} recv {recv_hash}"); Ok(()) } From 8e49e4492c852687ac313bda203f0ad50e8ae7d2 Mon Sep 17 00:00:00 2001 From: dougefresh Date: Fri, 17 Oct 2025 09:07:41 +0000 Subject: [PATCH 3/6] fix: remove burn function --- src/attestation.rs | 2 +- src/bridge.rs | 35 +++++++++++++++++++++++++++++-- src/bridge/evm.rs | 49 +++++--------------------------------------- src/error.rs | 3 --- tests/integration.rs | 28 +++++-------------------- 5 files changed, 44 insertions(+), 73 deletions(-) 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..b0e1b52 100644 --- a/src/bridge.rs +++ b/src/bridge.rs @@ -10,7 +10,11 @@ use { }, alloy_chains::{Chain, 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}, @@ -114,7 +118,7 @@ impl Display for EvmBridgeResult { } } -#[derive(Clone, Debug)] +#[derive(Clone)] pub struct Cctp { source_provider: SrcProvider, destination_provider: DstProvider, @@ -124,6 +128,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 +212,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 diff --git a/src/bridge/evm.rs b/src/bridge/evm.rs index 07054bc..c1c4c0e 100644 --- a/src/bridge/evm.rs +++ b/src/bridge/evm.rs @@ -4,14 +4,13 @@ use { 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, @@ -40,7 +39,7 @@ impl< } } - #[instrument(skip(self,max_fee,destination_caller,min_finality_threshold), level = Level::INFO)] + #[instrument(skip(max_fee,destination_caller,min_finality_threshold), level = Level::INFO)] pub async fn burn( &self, amount: alloy_primitives::U256, @@ -69,7 +68,7 @@ impl< let approval_hash: Option = if current_allowance < amount { debug!("Approving allowance"); let approve_hash = erc20 - .approve(token_messenger, U256::from(10)) + .approve(token_messenger, U256::from(amount)) .send() .await? .watch() @@ -104,7 +103,7 @@ impl< Ok((burn_hash, approval_hash)) } - #[instrument(skip(self), level = Level::INFO)] + #[instrument(level = Level::INFO)] pub async fn recv( &self, burn_hash: TxHash, @@ -114,11 +113,7 @@ impl< let destination_provider = self.destination_provider(); let message_transmitter: EvmAddress = self.message_transmitter_contract()?.try_into()?; let attestation = self - .get_attestation_with_retry( - format!("0x{}", encode(burn_hash)), - max_attempts, - poll_interval, - ) + .get_attestation_evm(burn_hash, max_attempts, poll_interval) .await?; let message_transmitter = @@ -143,38 +138,4 @@ impl< .await?, )) } - - #[instrument(skip(self,max_fee,destination_caller,min_finality_threshold), level = Level::INFO)] - pub async fn bridge( - &self, - amount: alloy_primitives::U256, - destination_caller: Option, - max_fee: Option, - min_finality_threshold: Option, - max_attempts: Option, - attestation_poll_interval: Option, - ) -> Result { - // verify addresses resolve to real evm before burning. See recv method for more - // details. - let _: EvmAddress = self.message_transmitter_contract()?.try_into()?; - let (burn_hash, approval_hash) = self - .burn(amount, destination_caller, max_fee, min_finality_threshold) - .await?; - - match self - .recv(burn_hash, max_attempts, attestation_poll_interval) - .await - { - Ok((attestation, recv_hash)) => Ok(EvmBridgeResult { - approval: approval_hash, - burn: burn_hash, - recv: recv_hash, - attestation, - }), - Err(e) => Err(crate::Error::ReceiveFailedAfterBurn { - burn_hash: format!("{}", burn_hash), - reason: e.to_string(), - }), - } - } } diff --git a/src/error.rs b/src/error.rs index c37abed..6941c9e 100644 --- a/src/error.rs +++ b/src/error.rs @@ -70,9 +70,6 @@ pub enum Error { #[error("failed to get solana fee recipient account: {0}")] SolanaFeeRecipientError(String), - - #[error("Receive failed after successful burn (tx: {burn_hash}): {reason}")] - ReceiveFailedAfterBurn { burn_hash: String, reason: String }, } pub type Result = std::result::Result; diff --git a/tests/integration.rs b/tests/integration.rs index 554fab7..8ae8f1b 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -83,26 +83,6 @@ async fn test_reclaim() -> Result<()> { Ok(()) } -#[tokio::test] -async fn test_evm_bridge() -> 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 result = bridge - .bridge(U256::from(10), None, None, None, None, None) - .await?; - info!("bridge result {}", result); - Ok(()) -} - #[tokio::test] async fn test_evm_burn_recv_split() -> Result<()> { setup(); @@ -117,19 +97,21 @@ async fn test_evm_burn_recv_split() -> Result<()> { NamedChain::BaseSepolia, recipient, ); - let (burn_hash, approval_hash) = bridge.burn(U256::from(10), None, None, None).await?; + 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, recv_hash) = bridge.recv(burn_hash, None, None).await?; + 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(()) } From 4480be3b0f479859f623996254f6c144b3851ae6 Mon Sep 17 00:00:00 2001 From: dougefresh Date: Fri, 17 Oct 2025 09:49:52 +0000 Subject: [PATCH 4/6] feat: check burn amount --- src/bridge/evm.rs | 28 ++++++++++++++++++++-------- src/chain.rs | 31 +++++++++++++++++++++++++++++++ src/error.rs | 8 +++++++- tests/integration.rs | 24 ++++++++++++++++++++++++ 4 files changed, 82 insertions(+), 9 deletions(-) diff --git a/src/bridge/evm.rs b/src/bridge/evm.rs index c1c4c0e..5d3cd14 100644 --- a/src/bridge/evm.rs +++ b/src/bridge/evm.rs @@ -53,6 +53,11 @@ impl< let token_messenger: EvmAddress = self.token_messenger_contract()?.try_into()?; let destination_domain = self.destination_domain_id()?; let usdc_address = self.source_chain().usdc_token_address()?.try_into()?; + let confirm_timeout = Some(Duration::from_secs( + self.source_chain().confirmation_average_time_seconds()?, + )); + // TODO make configurable or add to chain a helper method + let confirmations = 2; let erc20 = ERC20::new(usdc_address, source_provider); let usdc_balance = erc20 @@ -61,6 +66,9 @@ 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() @@ -71,6 +79,8 @@ impl< .approve(token_messenger, U256::from(amount)) .send() .await? + .with_required_confirmations(confirmations) + .with_timeout(confirm_timeout) .watch() .await?; info!("Approved USDC spending: {}", approve_hash); @@ -93,10 +103,8 @@ 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(confirm_timeout) .watch() .await?; @@ -112,6 +120,12 @@ impl< ) -> Result<(Attestation, TxHash)> { let destination_provider = self.destination_provider(); let message_transmitter: EvmAddress = self.message_transmitter_contract()?.try_into()?; + // TODO make configurable or add to chain a helper method + let confirmations = 2; + let confirm_timeout = Some(Duration::from_secs( + self.destination_chain() + .confirmation_average_time_seconds()?, + )); let attestation = self .get_attestation_evm(burn_hash, max_attempts, poll_interval) .await?; @@ -130,10 +144,8 @@ impl< recv_message_tx .send() .await? - .with_required_confirmations(2) - .with_timeout(Some(Duration::from_secs( - self.source_chain().confirmation_average_time_seconds()?, - ))) + .with_required_confirmations(confirmations) + .with_timeout(confirm_timeout) .watch() .await?, )) diff --git a/src/chain.rs b/src/chain.rs index 8f59020..b1e3a12 100644 --- a/src/chain.rs +++ b/src/chain.rs @@ -330,6 +330,16 @@ mod tests { 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(); @@ -385,6 +395,27 @@ mod tests { 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)] diff --git a/src/error.rs b/src/error.rs index 6941c9e..153c3ae 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 { @@ -70,6 +73,9 @@ pub enum Error { #[error("failed to get solana fee recipient account: {0}")] SolanaFeeRecipientError(String), + + #[error("Insufficient balance balance={0} amount={1}")] + InsufficientBalance(U256, U256), } pub type Result = std::result::Result; diff --git a/tests/integration.rs b/tests/integration.rs index 8ae8f1b..fe2312e 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -83,6 +83,30 @@ async fn test_reclaim() -> Result<()> { Ok(()) } +#[tokio::test] +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(); + + let bridge = Cctp::new( + sepolia_provider, + base_provider, + NamedChain::Sepolia, + NamedChain::BaseSepolia, + recipient, + ); + let too_much: u64 = 10_000_000_000 * 1_000_000; // 10 billion USDC with 6 decimals, if I had this, I wouldn't be doing this test + 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(); From eb5301db6a3bdf167c6bb960b5a45929f5c184bd Mon Sep 17 00:00:00 2001 From: dougefresh Date: Fri, 17 Oct 2025 12:42:23 +0000 Subject: [PATCH 5/6] update README and remove bincode --- .github/dependabot.yml | 1 - Cargo.toml | 1 - README.md | 64 ++++++++++++++++++++++++++++++++++++------ examples/common/mod.rs | 2 +- examples/evm_sol.rs | 2 +- examples/sol_evm.rs | 32 +++++++++++++-------- src/bridge/evm.rs | 53 +++++++++++++++++----------------- src/error.rs | 5 +--- 8 files changed, 107 insertions(+), 53 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a8e8fc7..c11fc03 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,7 +5,6 @@ updates: schedule: interval: "weekly" ignore: - - dependency-name: "bincode*" - dependency-name: "solana*" - dependency-name: "spl-*" - dependency-name: "*" diff --git a/Cargo.toml b/Cargo.toml index f257330..e6b54ba 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" } diff --git a/README.md b/README.md index 0c967ec..2bf4b0a 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 [CTCP](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/bridge/evm.rs b/src/bridge/evm.rs index 5d3cd14..38f03a6 100644 --- a/src/bridge/evm.rs +++ b/src/bridge/evm.rs @@ -53,11 +53,11 @@ impl< let token_messenger: EvmAddress = self.token_messenger_contract()?.try_into()?; let destination_domain = self.destination_domain_id()?; let usdc_address = self.source_chain().usdc_token_address()?.try_into()?; - let confirm_timeout = Some(Duration::from_secs( - self.source_chain().confirmation_average_time_seconds()?, - )); // TODO make configurable or add to chain a helper method let confirmations = 2; + let confirm_timeout = Some(Duration::from_secs( + self.source_chain().confirmation_average_time_seconds()? * confirmations, + )); let erc20 = ERC20::new(usdc_address, source_provider); let usdc_balance = erc20 @@ -111,25 +111,16 @@ impl< Ok((burn_hash, approval_hash)) } - #[instrument(level = Level::INFO)] - pub async fn recv( - &self, - burn_hash: TxHash, - max_attempts: Option, - poll_interval: Option, - ) -> Result<(Attestation, TxHash)> { + 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()?; // TODO make configurable or add to chain a helper method let confirmations = 2; let confirm_timeout = Some(Duration::from_secs( self.destination_chain() - .confirmation_average_time_seconds()?, + .confirmation_average_time_seconds()? + * confirmations, )); - let attestation = self - .get_attestation_evm(burn_hash, max_attempts, poll_interval) - .await?; - let message_transmitter = MessageTransmitter::new(message_transmitter, destination_provider); @@ -139,15 +130,27 @@ impl< ); info!("receiving on chain {}", self.destination_chain()); - Ok(( - attestation, - recv_message_tx - .send() - .await? - .with_required_confirmations(confirmations) - .with_timeout(confirm_timeout) - .watch() - .await?, - )) + Ok(recv_message_tx + .send() + .await? + .with_required_confirmations(confirmations) + .with_timeout(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?; + + let hash = self.recv_with_attestation(&attestation).await?; + Ok((attestation, hash)) } } diff --git a/src/error.rs b/src/error.rs index 153c3ae..b4907b8 100644 --- a/src/error.rs +++ b/src/error.rs @@ -62,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), @@ -74,7 +71,7 @@ pub enum Error { #[error("failed to get solana fee recipient account: {0}")] SolanaFeeRecipientError(String), - #[error("Insufficient balance balance={0} amount={1}")] + #[error("Insufficient balance have {0} need {1}")] InsufficientBalance(U256, U256), } From f195dc7cf524779818edfa1c3a6ee674cccc7c1e Mon Sep 17 00:00:00 2001 From: dougefresh Date: Fri, 17 Oct 2025 13:38:32 +0000 Subject: [PATCH 6/6] use get_chain_confirmation_config --- Cargo.toml | 1 + README.md | 2 +- src/bridge.rs | 25 ++++++++++++++----------- src/bridge/evm.rs | 25 ++++++++----------------- src/bridge/solana.rs | 2 +- tests/integration.rs | 5 ++++- 6 files changed, 29 insertions(+), 31 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index e6b54ba..2b35240 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,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 2bf4b0a..6dce50c 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ ## About -cctp-bridge is a Rust-based helper library for the Cross-Chain Token Protocol [CTCP](https://developers.circle.com/cctp). It facilitates the transfer of USDC between different blockchain networks. +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) diff --git a/src/bridge.rs b/src/bridge.rs index b0e1b52..eb5c788 100644 --- a/src/bridge.rs +++ b/src/bridge.rs @@ -8,7 +8,7 @@ use { CctpChain, error::{Error, Result}, }, - alloy_chains::{Chain, NamedChain}, + alloy_chains::{Chain, ChainKind, NamedChain}, alloy_network::Ethereum, alloy_primitives::{ FixedBytes, @@ -21,9 +21,9 @@ use { solana_signature::Signature as SolanaSignature, std::{ fmt::{Debug, Display}, - thread::sleep, time::Duration, }, + tokio::time::sleep, tracing::{Level, debug, error, info, instrument, trace}, }; @@ -55,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 @@ -269,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; } @@ -282,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; } @@ -353,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 38f03a6..b343d31 100644 --- a/src/bridge/evm.rs +++ b/src/bridge/evm.rs @@ -13,7 +13,6 @@ use { 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 @@ -53,11 +52,8 @@ impl< let token_messenger: EvmAddress = self.token_messenger_contract()?.try_into()?; let destination_domain = self.destination_domain_id()?; let usdc_address = self.source_chain().usdc_token_address()?.try_into()?; - // TODO make configurable or add to chain a helper method - let confirmations = 2; - let confirm_timeout = Some(Duration::from_secs( - self.source_chain().confirmation_average_time_seconds()? * confirmations, - )); + let (confirmations, confirm_timeout) = + super::get_chain_confirmation_config(&self.source_chain); let erc20 = ERC20::new(usdc_address, source_provider); let usdc_balance = erc20 @@ -76,11 +72,11 @@ impl< let approval_hash: Option = if current_allowance < amount { debug!("Approving allowance"); let approve_hash = erc20 - .approve(token_messenger, U256::from(amount)) + .approve(token_messenger, amount) .send() .await? .with_required_confirmations(confirmations) - .with_timeout(confirm_timeout) + .with_timeout(Some(confirm_timeout)) .watch() .await?; info!("Approved USDC spending: {}", approve_hash); @@ -104,7 +100,7 @@ impl< .send_transaction(burn_tx) .await? .with_required_confirmations(confirmations) - .with_timeout(confirm_timeout) + .with_timeout(Some(confirm_timeout)) .watch() .await?; @@ -114,13 +110,8 @@ impl< 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()?; - // TODO make configurable or add to chain a helper method - let confirmations = 2; - let confirm_timeout = Some(Duration::from_secs( - self.destination_chain() - .confirmation_average_time_seconds()? - * confirmations, - )); + let (confirmations, confirm_timeout) = + super::get_chain_confirmation_config(self.destination_chain()); let message_transmitter = MessageTransmitter::new(message_transmitter, destination_provider); @@ -134,7 +125,7 @@ impl< .send() .await? .with_required_confirmations(confirmations) - .with_timeout(confirm_timeout) + .with_timeout(Some(confirm_timeout)) .watch() .await?) } 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/tests/integration.rs b/tests/integration.rs index fe2312e..53f6710 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -68,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(); @@ -97,7 +100,7 @@ async fn test_burn_too_much() -> Result<()> { NamedChain::BaseSepolia, recipient, ); - let too_much: u64 = 10_000_000_000 * 1_000_000; // 10 billion USDC with 6 decimals, if I had this, I wouldn't be doing this test + 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");