ic-test 0.4.0

This tool helps to organize IC Rust canister testing as well as cross-testing between ICP and EVM.
Documentation
use std::{
    borrow::Cow,
    collections::BTreeMap,
    io::Read,
    sync::{Arc, Mutex},
};

use alloy::{
    network::{Ethereum, EthereumWallet, Network, TransactionBuilder},
    primitives::{Address, BlockNumber, Bytes, B256, U128, U256, U64},
    providers::{
        fillers::{
            BlobGasFiller, ChainIdFiller, FillProvider, GasFiller, JoinFill, NonceFiller,
            WalletFiller,
        },
        EthCall, EthCallMany, Identity, PendingTransactionBuilder, Provider, ProviderBuilder,
        ProviderCall, RootProvider, RpcWithBlock, SendableTx,
    },
    rpc::{
        client::NoParams,
        types::{
            erc4337::TransactionConditional,
            simulate::{SimulatePayload, SimulatedBlock},
            AccessListResult, Bundle, EthCallResponse, SyncStatus, TransactionRequest,
        },
    },
    signers::{
        k256::{elliptic_curve::SecretKey, Secp256k1},
        local::PrivateKeySigner,
    },
    transports::TransportResult,
};
use alloy_node_bindings::{Anvil, AnvilInstance};
use reqwest::Url;
use serde_json::{json, value::RawValue};

/// Represents a local Ethereum environment backed by an Anvil node.
pub struct Evm {
    rpc_url: Url,
    anvil: AnvilInstance,
    // This mutex is intentionally a sync mutex and not a tokio mutex.
    users: std::sync::Mutex<BTreeMap<Address, EvmUser>>,
}

impl Evm {
    /// New Evm environment
    pub fn new() -> Self {
        Evm::default()
    }

    /// HTTP RPC URL oif the Anvil instance
    pub fn rpc_url(&self) -> Url {
        self.rpc_url.clone()
    }

    /// Chain id of the current Anvil instance
    pub fn chain_id(&self) -> u64 {
        self.anvil.chain_id()
    }

    /// Number of test users
    pub fn test_user_count(&self) -> usize {
        self.anvil.addresses().len()
    }

    /// Return the `SecretKey` for a test account at the given index.
    ///
    /// # Panics
    /// Panics if the index is out of bounds.
    pub fn key(&self, index: usize) -> SecretKey<Secp256k1> {
        self.anvil.keys()[index].clone()
    }

    /// Return a test user at a given index.
    pub fn test_user(&self, index: usize) -> EvmUser {
        if index >= self.test_user_count() {
            panic!(
                "Reached maximum number of test users: {}",
                self.test_user_count()
            );
        }
        self.user_from(
            self.anvil.addresses()[index],
            self.anvil.keys()[index].clone(),
        )
    }

    /// Construct or retrieve an `EvmUser` for a given address and key.
    pub fn user_from(&self, address: Address, key: SecretKey<Secp256k1>) -> EvmUser {
        let mut users = self.users.lock().unwrap();
        if let Some(user) = users.get(&address) {
            return user.clone();
        }
        let signer: PrivateKeySigner = key.clone().into();
        let provider = ProviderBuilder::new()
            .wallet(EthereumWallet::from(signer))
            .connect_http(self.rpc_url.clone());
        let user = EvmUser {
            address,
            key,
            provider: Arc::new(provider),
        };
        users.insert(user.address, user.clone());
        user
    }

    /// First test user
    pub fn default_user(&self) -> EvmUser {
        self.test_user(0)
    }

    /// Send ETH from a user to another address.
    pub async fn transfer(&self, user: &EvmUser, to: Address, amount: U256) {
        let tx = TransactionRequest::default().with_to(to).with_value(amount);
        user.provider
            .send_transaction(tx)
            .await
            .unwrap()
            .get_receipt()
            .await
            .unwrap();
    }

    /// Query the ETH balance of the given address.
    pub async fn get_balance(&self, addr: Address) -> U256 {
        self.default_user()
            .provider
            .get_balance(addr)
            .await
            .unwrap()
    }

    /// Mine a single block manually via `evm_mine`.
    pub async fn mine_block(&self) {
        let response: serde_json::Value = self
            .default_user()
            .provider
            .client()
            .request("evm_mine", json!({}))
            .await
            .unwrap();
        assert_eq!(response, "0x0");
    }
}

impl Default for Evm {
    /// Initialize the Anvil test environment and capture its logs.
    fn default() -> Self {
        let mut anvil = Anvil::new().keep_stdout().try_spawn().unwrap();
        let anvil_stdout = anvil.child_mut().stdout.take();

        tokio::spawn(async {
            let mut buf = [0_u8; 4096];
            let mut mv = anvil_stdout;
            loop {
                tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
                match mv.as_mut().unwrap().read(&mut buf) {
                    Ok(len) => {
                        eprintln!(
                            "{}",
                            String::from_utf8(buf[0..len].to_vec()).unwrap_or_default()
                        );
                    }
                    Err(_) => return,
                }
            }
        });

        let anvil_url: Url = anvil.endpoint().parse().unwrap();
        Self {
            rpc_url: anvil_url,
            anvil,
            users: Mutex::new(BTreeMap::new()),
        }
    }
}

/// A fully configured EVM provider with wallet, nonce, gas, and chain ID fillers.
pub type EvmProvider = FillProvider<
    JoinFill<
        JoinFill<
            Identity,
            JoinFill<GasFiller, JoinFill<BlobGasFiller, JoinFill<NonceFiller, ChainIdFiller>>>,
        >,
        WalletFiller<EthereumWallet>,
    >,
    RootProvider,
    Ethereum,
>;

/// Represents a test user (address + provider + signer) in the EVM environment.
#[derive(Clone)]
pub struct EvmUser {
    /// Ethereum address of the user.
    pub address: Address,
    /// Secret key used for signing transactions.
    pub key: SecretKey<Secp256k1>,
    /// EVM provider configured for this user.
    pub provider: Arc<EvmProvider>,
}

#[async_trait::async_trait]
impl Provider<Ethereum> for EvmUser {
    fn root(&self) -> &RootProvider {
        self.provider.root()
    }

    fn get_accounts(&self) -> ProviderCall<NoParams, Vec<Address>> {
        self.provider.get_accounts()
    }

    fn get_blob_base_fee(&self) -> ProviderCall<NoParams, U128, u128> {
        self.provider.get_blob_base_fee()
    }

    fn get_block_number(&self) -> ProviderCall<NoParams, U64, BlockNumber> {
        self.provider.get_block_number()
    }

    fn call<'req>(
        &self,
        tx: <Ethereum as Network>::TransactionRequest,
    ) -> EthCall<Ethereum, Bytes> {
        self.provider.call(tx)
    }

    fn call_many<'req>(
        &self,
        bundles: &'req [Bundle],
    ) -> EthCallMany<'req, Ethereum, Vec<Vec<EthCallResponse>>> {
        self.provider.call_many(bundles)
    }

    fn simulate<'req>(
        &self,
        payload: &'req SimulatePayload,
    ) -> RpcWithBlock<
        &'req SimulatePayload,
        Vec<SimulatedBlock<<Ethereum as Network>::BlockResponse>>,
    > {
        self.provider.simulate(payload)
    }

    fn get_chain_id(&self) -> ProviderCall<NoParams, U64, u64> {
        self.provider.get_chain_id()
    }

    fn create_access_list<'a>(
        &self,
        request: &'a <Ethereum as Network>::TransactionRequest,
    ) -> RpcWithBlock<&'a <Ethereum as Network>::TransactionRequest, AccessListResult> {
        self.provider.create_access_list(request)
    }

    async fn send_raw_transaction(
        &self,
        encoded_tx: &[u8],
    ) -> TransportResult<PendingTransactionBuilder<Ethereum>> {
        self.provider.send_raw_transaction(encoded_tx).await
    }

    async fn send_raw_transaction_conditional(
        &self,
        encoded_tx: &[u8],
        conditional: TransactionConditional,
    ) -> TransportResult<PendingTransactionBuilder<Ethereum>> {
        self.provider
            .send_raw_transaction_conditional(encoded_tx, conditional)
            .await
    }

    async fn send_transaction_internal(
        &self,
        tx: SendableTx<Ethereum>,
    ) -> TransportResult<PendingTransactionBuilder<Ethereum>> {
        self.provider.send_transaction_internal(tx).await
    }

    fn syncing(&self) -> ProviderCall<NoParams, SyncStatus> {
        self.provider.syncing()
    }

    fn get_client_version(&self) -> ProviderCall<NoParams, String> {
        self.provider.get_client_version()
    }

    fn get_sha3(&self, data: &[u8]) -> ProviderCall<(String,), B256> {
        self.provider.get_sha3(data)
    }

    fn get_net_version(&self) -> ProviderCall<NoParams, U64, u64> {
        self.provider.get_net_version()
    }

    async fn raw_request_dyn(
        &self,
        method: Cow<'static, str>,
        params: &RawValue,
    ) -> TransportResult<Box<RawValue>> {
        self.provider.raw_request_dyn(method, params).await
    }

    fn transaction_request(&self) -> <Ethereum as Network>::TransactionRequest {
        self.provider.transaction_request()
    }
}