diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 451e346..d96f844 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -152,7 +152,6 @@ jobs: - name: cargo hack run: cargo hack --feature-powerset check - cargo_sort: runs-on: ${{ vars.RUNNER }} steps: @@ -193,4 +192,31 @@ jobs: run: cargo install cargo-machete - name: Check unused Cargo dependencies run: cargo machete - + + examples: + runs-on: ${{ vars.RUNNER }} + if: false + steps: + - name: Checkout + uses: actions/checkout@v5 + - uses: ubicloud/rust-cache@v2 + with: + cache-on-failure: "true" + cache-all-crates: "true" + cache-workspace-crates: "true" + - name: Stable Rust + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + - name: Run examples + env: + RUST_BACKTRACE: 1 + RUST_LOG: info,cctp_bridge=debug + ALCHEMY_API_KEY: ${{ secrets.ALCHEMY_API_KEY }} + EVM_SECRET_KEY: ${{ secrets.EVM_SECRET_KEY }} + TEST_PRIVATE_KEY: ${{ secrets.TEST_PRIVATE_KEY }} + SOLANA_RPC_URL: ${{ secrets.RPC_URL }} + run: | + cargo run --example evm_sol + cargo run --example sol_evm + cargo run --example reclaim diff --git a/.github/workflows/pr-review.yml b/.github/workflows/pr-review.yml index 0961c50..c4f59e4 100644 --- a/.github/workflows/pr-review.yml +++ b/.github/workflows/pr-review.yml @@ -44,7 +44,8 @@ jobs: - Check API documentation accuracy Provide detailed feedback using inline comments for specific issues. - Use top-level comments for general observations or praise. + Use top-level comments for general observations or praise, but do not praise individual lines of code. + Do not be shy, I am a big boy and can handle criticism gracefully. I welcome feedback and suggestions. # Tools for comprehensive PR review claude_args: | diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2b032d2..1d41ff7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,41 +17,6 @@ concurrency: cancel-in-progress: true name: test jobs: - required: - runs-on: ${{ vars.RUNNER }} - name: ubuntu / ${{ matrix.toolchain }} - strategy: - matrix: - # run on stable and beta to ensure that tests won't break on the next version of the rust - # toolchain - toolchain: [stable] - steps: - - uses: actions/checkout@v5 - with: - submodules: true - - uses: ubicloud/rust-cache@v2 - with: - cache-on-failure: "true" - cache-all-crates: "true" - workspaces: | - . -> target - # Specifies what to use as the backend providing cache - # Can be set to "github", "buildjet", or "warpbuild" - # default: "github" - - name: Install ${{ matrix.toolchain }} - uses: dtolnay/rust-toolchain@master - with: - toolchain: ${{ matrix.toolchain }} - - name: cargo generate-lockfile - # enable this ci template to run regardless of whether the lockfile is checked in or not - if: hashFiles('Cargo.lock') == '' - run: cargo generate-lockfile - # https://twitter.com/jonhoo/status/1571290371124260865 - - name: cargo test --locked - run: cargo test --locked --all-features --all-targets - # https://github.com/rust-lang/cargo/issues/6669 - - name: cargo test --doc - run: cargo test --locked --all-features --doc os-check: # run cargo test on mac and windows runs-on: ${{ matrix.os }} @@ -80,6 +45,13 @@ jobs: coverage: runs-on: ${{ vars.RUNNER }} name: ubuntu / stable / coverage / ${{ matrix.features }} + env: + RUST_BACKTRACE: 1 + RUST_LOG: info,cctp_bridge=debug + ALCHEMY_API_KEY: ${{ secrets.ALCHEMY_API_KEY }} + EVM_SECRET_KEY: ${{ secrets.EVM_SECRET_KEY }} + TEST_PRIVATE_KEY: ${{ secrets.TEST_PRIVATE_KEY }} + SOLANA_RPC_URL: ${{ secrets.RPC_URL }} strategy: fail-fast: false matrix: @@ -105,9 +77,9 @@ jobs: - name: cargo llvm-cov (${{ matrix.features }}) run: | if [ "${{ matrix.features }}" == "default" ]; then - cargo llvm-cov --locked --lcov --output-path lcov-${{ matrix.features }}.info + cargo llvm-cov --locked --lcov --output-path lcov-${{ matrix.features }}.info -- --test-threads=1 else - cargo llvm-cov --locked --features "${{ matrix.features }}" --lcov --output-path lcov-${{ matrix.features }}.info + cargo llvm-cov --locked --features "${{ matrix.features }}" --lcov --output-path lcov-${{ matrix.features }}.info -- --test-threads=1 fi - name: Record Rust version run: echo "RUST=$(rustc --version)" >> "$GITHUB_ENV" diff --git a/.gitignore b/.gitignore index 5fd414c..f3fb3db 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ /target .cargo-ok .DS_Store - +.env Cargo.lock diff --git a/Cargo.toml b/Cargo.toml index d144244..f257330 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,16 +47,11 @@ tracing = "0.1" [dev-dependencies] alloy-signer-local = "1" anyhow = "1" -dotenv = "0.15" +dotenvy = "0.15" rstest = "0.26" solana-commitment-config = "2" tokio = { version = "1", features = ["test-util", "macros", "rt-multi-thread"] } -tracing-subscriber = "0.3" - -#[lints.rust] -#unused_imports = "allow" -#unused_variables = "allow" -#dead_code = "allow" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } [lints.clippy] match_like_matches_macro = "allow" diff --git a/README.md b/README.md index 0234393..0c967ec 100644 --- a/README.md +++ b/README.md @@ -55,13 +55,6 @@ cargo +nightly fmt --all cargo +nightly fmt --all -- --check ``` -## License - - * MIT license - ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT) - -at your option. - ## Contribution Unless you explicitly state otherwise, any contribution intentionally submitted diff --git a/examples/common/mod.rs b/examples/common/mod.rs new file mode 100644 index 0000000..c242f4d --- /dev/null +++ b/examples/common/mod.rs @@ -0,0 +1,39 @@ +use { + alloy_provider::{Provider, ProviderBuilder, WalletProvider}, + alloy_signer_local::PrivateKeySigner, + solana_commitment_config::CommitmentConfig, + solana_keypair::Keypair, + solana_rpc_client::nonblocking::rpc_client::RpcClient, + solana_signer::Signer, + std::{env, str::FromStr}, + tracing::info, +}; + +#[allow(dead_code)] +pub fn evm_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"); + let base_provider = ProviderBuilder::new().wallet(wallet).connect_http( + format!("https://base-sepolia.g.alchemy.com/v2/{api_key}") + .parse() + .unwrap(), + ); + Ok(base_provider) +} +pub fn solana_setup() -> anyhow::Result<(Keypair, RpcClient)> { + let kp_file = env::var("KEYPAIR_FILE").ok(); + let owner = if let Some(kp) = kp_file { + solana_keypair::read_keypair_file(&kp) + .map_err(|e| anyhow::format_err!("unable to load keypair file {kp} {e}"))? + } else { + let kp = env::var("TEST_PRIVATE_KEY").expect("TEST_PRIVATE_KEY is not set"); + Keypair::from_base58_string(&kp) + }; + let url = env::var("SOLANA_RPC_URL").expect("SOLANA_RPC_URL is not set"); + info!("using RPC {url}"); + info!("solana address {}", owner.pubkey(),); + let rpc = RpcClient::new_with_commitment(url, CommitmentConfig::finalized()); + // Your Solana setup code here + Ok((owner, rpc)) +} diff --git a/examples/evm_sol.rs b/examples/evm_sol.rs index 2f7c17f..263c834 100644 --- a/examples/evm_sol.rs +++ b/examples/evm_sol.rs @@ -1,44 +1,27 @@ +mod common; + use { alloy_chains::NamedChain, alloy_primitives::U256, - alloy_provider::{ProviderBuilder, WalletProvider}, - alloy_signer_local::PrivateKeySigner, + alloy_provider::WalletProvider, cctp_bridge::{Cctp, SolanaWrapper}, - solana_commitment_config::CommitmentConfig, - solana_rpc_client::nonblocking::rpc_client::RpcClient, solana_signer::Signer, - std::{env, str::FromStr}, tracing::info, }; #[tokio::main] async fn main() -> anyhow::Result<()> { - // Initialize tracing for better debugging - dotenv::dotenv().ok(); + dotenvy::dotenv().ok(); tracing_subscriber::fmt::init(); - let secret_key = env::var("EVM_SECRET").expect("EVM_SECRET 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"); - let kp_file = env::var("KEYPAIR_FILE").expect("KEYPAIR_FILE environment variable not set"); - let owner = solana_keypair::read_keypair_file(&kp_file) - .map_err(|e| anyhow::format_err!("unable to load keypair file {kp_file} {e}"))?; - - let base_provider = ProviderBuilder::new().wallet(wallet).connect_http( - format!("https://base-sepolia.g.alchemy.com/v2/{api_key}") - .parse() - .unwrap(), - ); - + let base_provider = common::evm_setup()?; + let (owner, rpc) = common::solana_setup()?; info!( "solana address {} sends to base address {}", owner.pubkey(), base_provider.default_signer_address() ); - let url = env::var("SOLANA_RPC_URL").expect("SOLANA_RPC_URL is not set"); - info!("using RPC {url}"); - let rpc: SolanaWrapper = - RpcClient::new_with_commitment(url, CommitmentConfig::finalized()).into(); + let rpc: SolanaWrapper = rpc.into(); let bridge = Cctp::new_evm_sol( base_provider, diff --git a/examples/reclaim.rs b/examples/reclaim.rs new file mode 100644 index 0000000..6a02fe5 --- /dev/null +++ b/examples/reclaim.rs @@ -0,0 +1,24 @@ +mod common; + +use { + cctp_bridge::{Cctp, SolanaWrapper}, + solana_signer::Signer, + tracing::info, +}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + dotenvy::dotenv().ok(); + tracing_subscriber::fmt::init(); + let (owner, rpc) = common::solana_setup()?; + info!("solana address {}", owner.pubkey(),); + let rpc: SolanaWrapper = rpc.into(); + + let bridge = Cctp::new_reclaim(rpc.clone(), rpc, cctp_bridge::SOLANA_DEVNET); + let result = bridge.reclaim(&owner).await?; + println!("reclaimed {} accounts", result.len()); + for (sig, addr) in result { + println!("reclaimed account {} with signature {}", addr, sig); + } + Ok(()) +} diff --git a/examples/recv_message.rs b/examples/recv_message.rs index 6f35c61..defa278 100644 --- a/examples/recv_message.rs +++ b/examples/recv_message.rs @@ -1,24 +1,16 @@ +mod common; + use { alloy_chains::NamedChain, cctp_bridge::{Cctp, SolanaWrapper}, - solana_commitment_config::CommitmentConfig, - solana_rpc_client::nonblocking::rpc_client::RpcClient, - std::env, - tracing::info, }; #[tokio::main] async fn main() -> anyhow::Result<()> { - // Initialize tracing for better debugging - dotenv::dotenv().ok(); + dotenvy::dotenv().ok(); tracing_subscriber::fmt::init(); - let kp_file = env::var("KEYPAIR_FILE").expect("KEYPAIR_FILE environment variable not set"); - let owner = solana_keypair::read_keypair_file(&kp_file) - .map_err(|e| anyhow::format_err!("unable to load keypair file {kp_file} {e}"))?; - let url = env::var("SOLANA_RPC_URL").expect("SOLANA_RPC_URL is not set"); - info!("using RPC {url}"); - let rpc: SolanaWrapper = - RpcClient::new_with_commitment(url, CommitmentConfig::finalized()).into(); + let (owner, rpc) = common::solana_setup()?; + let rpc: SolanaWrapper = rpc.into(); let bridge = Cctp::new_recv( rpc.clone(), rpc, diff --git a/examples/sol_evm.rs b/examples/sol_evm.rs index d5c22c1..b30b64d 100644 --- a/examples/sol_evm.rs +++ b/examples/sol_evm.rs @@ -1,41 +1,26 @@ +mod common; + use { alloy_chains::NamedChain, - alloy_provider::{ProviderBuilder, WalletProvider}, - alloy_signer_local::PrivateKeySigner, + alloy_provider::WalletProvider, cctp_bridge::{Cctp, SolanSigners, SolanaWrapper}, - solana_commitment_config::CommitmentConfig, - solana_rpc_client::nonblocking::rpc_client::RpcClient, solana_signer::Signer, - std::{env, str::FromStr}, tracing::info, }; #[tokio::main] async fn main() -> anyhow::Result<()> { - // Initialize tracing for better debugging - dotenv::dotenv().ok(); + dotenvy::dotenv().ok(); tracing_subscriber::fmt::init(); - let secret_key = env::var("EVM_SECRET").expect("EVM_SECRET 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"); - let kp_file = env::var("KEYPAIR_FILE").expect("KEYPAIR_FILE environment variable not set"); - let owner = solana_keypair::read_keypair_file(&kp_file) - .map_err(|e| anyhow::format_err!("unable to load keypair file {kp_file} {e}"))?; - - let base_provider = ProviderBuilder::new() - .wallet(wallet) - .connect_http(format!("https://base-sepolia.g.alchemy.com/v2/{api_key}").parse()?); - + let base_provider = common::evm_setup()?; + let (owner, rpc) = common::solana_setup()?; info!( "solana address {} sends to base address {}", owner.pubkey(), base_provider.default_signer_address() ); - let url = env::var("SOLANA_RPC_URL").expect("SOLANA_RPC_URL is not set"); - info!("using RPC {url}"); - let rpc: SolanaWrapper = - RpcClient::new_with_commitment(url, CommitmentConfig::finalized()).into(); + let rpc: SolanaWrapper = rpc.into(); let bridge = Cctp::new_solana_evm( rpc, diff --git a/src/lib.rs b/src/lib.rs index 7c42f1d..ed22ca2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,65 +1,3 @@ -//! # cctp-rs -//! -//! A production-ready Rust SDK for Circle's Cross-Chain Transfer Protocol -//! (CCTP). -//! -//! This library provides a safe, ergonomic interface for bridging USDC across -//! multiple blockchain networks using Circle's CCTP infrastructure. -//! -//! ## Quick Start -//! -//! ```rust,no_run -//! use { -//! alloy_chains::NamedChain, -//! alloy_primitives::FixedBytes, -//! cctp_rs::{Cctp, CctpError}, -//! }; -//! -//! # async fn example() -> Result<(), CctpError> { -//! # use alloy_provider::ProviderBuilder; -//! // Set up providers and create bridge -//! let eth_provider = ProviderBuilder::new() -//! .connect("http://localhost:8545") -//! .await?; -//! let arb_provider = ProviderBuilder::new() -//! .connect("http://localhost:8546") -//! .await?; -//! -//! let bridge = Cctp::builder() -//! .source_chain(NamedChain::Mainnet) -//! .destination_chain(NamedChain::Arbitrum) -//! .source_provider(eth_provider) -//! .destination_provider(arb_provider) -//! .recipient("0x742d35Cc6634C0532925a3b844Bc9e7595f8fA0d".parse()?) -//! .build(); -//! -//! // Get attestation for a bridge transaction -//! let message_hash: FixedBytes<32> = [0u8; 32].into(); -//! let attestation = bridge -//! .get_attestation_with_retry(message_hash, None, None) -//! .await?; -//! # Ok(()) -//! # } -//! ``` -//! -//! ## Features -//! -//! - **Type-safe contract interactions** using Alloy -//! - **Multi-chain support** for mainnet and testnet networks -//! - **Comprehensive error handling** with detailed error types -//! - **Builder pattern** for intuitive API usage -//! - **Extensive test coverage** ensuring reliability -//! -//! ## Modules -//! -//! - [`attestation`] - Types for Circle's Iris API attestation responses -//! - [`bridge`] - Core CCTP bridge implementation -//! - [`chain`] - Chain-specific configurations and the `CctpV1` trait -//! - [`error`] - Error types and result type alias -//! - [`domain_id`] - CCTP domain ID constants for supported chains -//! - [`message_transmitter`] - MessageTransmitter contract bindings -//! - [`token_messenger`] - TokenMessenger contract bindings - macro_rules! chain_id_from_reown { ($chain_str:literal) => {{ const fn const_hash(s: &str) -> u64 { diff --git a/tests/integration.rs b/tests/integration.rs new file mode 100644 index 0000000..c185a6a --- /dev/null +++ b/tests/integration.rs @@ -0,0 +1,144 @@ +use { + alloy_chains::NamedChain, + alloy_primitives::U256, + alloy_provider::{Provider, ProviderBuilder, WalletProvider}, + alloy_signer_local::PrivateKeySigner, + anyhow::Result, + cctp_bridge::{Cctp, SolanSigners, SolanaWrapper}, + solana_commitment_config::CommitmentConfig, + solana_keypair::Keypair, + solana_rpc_client::nonblocking::rpc_client::RpcClient, + solana_signer::Signer, + std::{env, str::FromStr, sync::Once}, + tracing::info, + tracing_subscriber::{EnvFilter, fmt::format::FmtSpan}, +}; + +pub static INIT: Once = Once::new(); + +#[allow(clippy::unwrap_used, clippy::missing_panics_doc)] +pub fn setup() { + INIT.call_once(|| { + if env::var("CI").is_err() { + // only load .env if not in CI + if dotenvy::dotenv_override().is_err() { + eprintln!("no .env file"); + } + } + tracing_subscriber::fmt() + .with_target(false) + .with_level(true) + .with_span_events(FmtSpan::CLOSE) + .with_env_filter(EnvFilter::from_default_env()) + .init(); + }); +} + +#[allow(dead_code)] +pub fn evm_setup(base_sepolia: bool) -> 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"); + let url = if base_sepolia { + "https://base-sepolia.g.alchemy.com/v2" + } else { + "https://eth-sepolia.g.alchemy.com/v2" + }; + let base_provider = ProviderBuilder::new() + .wallet(wallet) + .connect_http(format!("{url}/{api_key}").parse()?); + Ok(base_provider) +} +pub fn solana_setup() -> anyhow::Result<(Keypair, RpcClient)> { + let kp_file = env::var("KEYPAIR_FILE").ok(); + let owner = if let Some(kp) = kp_file { + solana_keypair::read_keypair_file(&kp) + .map_err(|e| anyhow::format_err!("unable to load keypair file {kp} {e}"))? + } else { + let kp = env::var("TEST_PRIVATE_KEY").expect("TEST_PRIVATE_KEY is not set"); + Keypair::from_base58_string(&kp) + }; + let url = env::var("SOLANA_RPC_URL").expect("SOLANA_RPC_URL is not set"); + info!("using RPC {url}"); + info!("solana address {}", owner.pubkey(),); + let rpc = RpcClient::new_with_commitment(url, CommitmentConfig::finalized()); + // Your Solana setup code here + Ok((owner, rpc)) +} + +#[tokio::test] +async fn test_reclaim() -> Result<()> { + setup(); + let (owner, rpc) = solana_setup()?; + let rpc: SolanaWrapper = rpc.into(); + + let bridge = Cctp::new_reclaim(rpc.clone(), rpc, cctp_bridge::SOLANA_DEVNET); + let result = bridge.reclaim(&owner).await?; + info!("reclaimed {} accounts", result.len()); + for (sig, addr) in result { + info!("reclaimed account {} with signature {}", addr, sig); + } + Ok(()) +} + +#[tokio::test] +async fn test_evm() -> 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, + NamedChain::Sepolia, + NamedChain::BaseSepolia, + recipient, + ); + let result = bridge.bridge(U256::from(10), None, None, None).await?; + info!("bridge result {}", result); + Ok(()) +} + +#[tokio::test] +async fn test_evm_sol() -> Result<()> { + setup(); + let sepolia_provider = evm_setup(false)?; + let (owner, rpc) = solana_setup()?; + let rpc: SolanaWrapper = rpc.into(); + + let bridge = Cctp::new_evm_sol( + sepolia_provider, + rpc, + NamedChain::Sepolia, + owner.pubkey(), + cctp_bridge::SOLANA_DEVNET, + ); + let result = bridge + .bridge_evm_sol(&owner, U256::from(10), None, None, None) + .await?; + info!("bridge result {}", result); + Ok(()) +} + +#[tokio::test] +async fn test_sol_evm() -> Result<()> { + setup(); + let base_provider = evm_setup(true)?; + let (owner, rpc) = solana_setup()?; + let rpc: SolanaWrapper = rpc.into(); + + let bridge = Cctp::new_solana_evm( + rpc, + base_provider, + cctp_bridge::SOLANA_DEVNET, + NamedChain::BaseSepolia, + ); + let result = bridge + .bridge_sol_evm(10, SolanSigners::new(owner), None, None, None) + .await?; + + info!("bridge result {}", result); + Ok(()) +}