riglr-evm-tools 0.3.0

EVM blockchain tools for the Riglr framework
Documentation
//! Integration tests for EVM tools against a containerized Ethereum node
//!
//! These tests verify transaction submission tools work correctly with a real EVM node.

#[cfg(test)]
mod tests {
    #[cfg(feature = "integration-tests")]
    use {
        alloy::primitives::{utils::format_ether, Address, U256},
        alloy::providers::{Provider, ProviderBuilder},
        alloy::rpc::types::TransactionRequest,
        riglr_config::EvmNetworkConfig,
        riglr_core::provider::ApplicationContext,
        riglr_core::signer::SignerContext,
        riglr_core::UnifiedSigner,
        riglr_evm_tools::{transaction::send_eth, LocalEvmSigner},
        std::str::FromStr,
        std::sync::Arc,
        testcontainers::{runners::AsyncRunner, GenericImage, ImageExt},
    };

    #[cfg(feature = "integration-tests")]
    async fn setup_anvil_node() -> (testcontainers::ContainerAsync<GenericImage>, String, String) {
        // Use Anvil (Foundry's local Ethereum node)
        let container = GenericImage::new("ghcr.io/foundry-rs/foundry", "latest")
            .with_cmd(vec![
                "anvil",
                "--host",
                "0.0.0.0",
                "--accounts",
                "10",
                "--balance",
                "10000",
                "--block-time",
                "1",
            ])
            .with_mapped_port(8545, 8545.into())
            .start()
            .await
            .expect("Failed to start Anvil node");

        // Get the RPC port
        let rpc_port = container
            .get_host_port_ipv4(8545)
            .await
            .expect("Failed to get RPC port");

        let rpc_url = format!("http://127.0.0.1:{}", rpc_port);

        // Anvil provides deterministic private keys
        // First account private key
        let private_key =
            "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80".to_string();

        // Wait for node to be ready
        tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;

        (container, rpc_url, private_key)
    }

    #[cfg(feature = "integration-tests")]
    async fn get_balance(provider_url: &str, address: Address) -> U256 {
        let provider = ProviderBuilder::new()
            .connect(provider_url)
            .await
            .expect("Failed to connect provider");

        provider
            .get_balance(address)
            .await
            .expect("Failed to get balance")
    }

    #[tokio::test]
    #[cfg(feature = "integration-tests")] // Requires Docker
    async fn test_evm_transfer_integration() {
        let (_container, rpc_url, private_key) = setup_anvil_node().await;

        // Create signer
        let config = EvmNetworkConfig::new("local", 31337, rpc_url.clone());
        let _signer = LocalEvmSigner::new(private_key, config).expect("Failed to create signer");

        // Set up application context
        let config = riglr_config::Config::from_env();
        let app_context = ApplicationContext::from_config(&config);
        let unified_signer: Arc<dyn UnifiedSigner> = Arc::new(_signer);

        // Get sender address
        let sender_address = Address::from_str("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266")
            .expect("Invalid sender address");

        // Create recipient address
        let recipient = Address::from_str("0x70997970C51812dc3A010C7d01b50e0d17dc79C8")
            .expect("Invalid recipient address");

        // Execute test within SignerContext
        SignerContext::with_signer(unified_signer, async move {
            // Check initial balances
            let initial_sender_balance = get_balance(&rpc_url, sender_address).await;
            let initial_recipient_balance = get_balance(&rpc_url, recipient).await;

            println!(
                "Initial sender balance: {}",
                format_ether(initial_sender_balance)
            );
            println!(
                "Initial recipient balance: {}",
                format_ether(initial_recipient_balance)
            );

            // Send 1 ETH
            let tx_hash = send_eth(recipient.to_string(), 1.0, None, &app_context)
                .await
                .expect("Failed to send ETH");

            println!("Transaction hash: {}", tx_hash);

            // Wait for transaction to be mined
            tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;

            // Check final balances
            let final_sender_balance = get_balance(&rpc_url, sender_address).await;
            let final_recipient_balance = get_balance(&rpc_url, recipient).await;

            println!(
                "Final sender balance: {}",
                format_ether(final_sender_balance)
            );
            println!(
                "Final recipient balance: {}",
                format_ether(final_recipient_balance)
            );

            // Verify transfer (1 ETH = 1e18 wei)
            let expected_amount = U256::from(1_000_000_000_000_000_000u64); // 1 ETH in wei
            assert_eq!(
                final_recipient_balance - initial_recipient_balance,
                expected_amount
            );
            assert!(final_sender_balance < initial_sender_balance - expected_amount); // Less due to gas

            Ok(())
        })
        .await
        .expect("Test should complete successfully");
    }

    #[tokio::test]
    #[cfg(feature = "integration-tests")] // Requires Docker
    async fn test_evm_contract_deployment() {
        let (_container, rpc_url, private_key) = setup_anvil_node().await;

        // Create signer
        let config = EvmNetworkConfig::new("local", 31337, rpc_url.clone());
        let _signer = LocalEvmSigner::new(private_key, config).expect("Failed to create signer");

        let unified_signer: Arc<dyn UnifiedSigner> = Arc::new(_signer);

        // Execute test within SignerContext
        SignerContext::with_signer(unified_signer, async move {
            // Simple storage contract bytecode (stores and retrieves a number)
            // contract SimpleStorage { uint256 public storedValue; function set(uint256 x) public { storedValue = x; } }
            let contract_bytecode = "0x608060405234801561001057600080fd5b50610150806100206000396000f3fe608060405234801561001057600080fd5b50600436106100365760003560e01c806360fe47b11461003b5780636d4ce63c14610057575b600080fd5b610055600480360381019061005091906100be565b610075565b005b61005f61007f565b60405161006c91906100fa565b60405180910390f35b8060008190555050565b60008054905090565b600080fd5b6000819050919050565b6100a08161008d565b81146100ab57600080fd5b50565b6000813590506100bd81610097565b92915050565b6000602082840312156100da576100d9610088565b5b60006100e8848285016100ae565b91505092915050565b6100fa8161008d565b82525050565b600060208201905061011560008301846100f1565b9291505056fea2646970667358221220";

            // Deploy contract
            let bytecode_bytes = alloy::primitives::hex::decode(contract_bytecode.strip_prefix("0x").unwrap_or(contract_bytecode))
                .expect("Invalid bytecode hex");
            let deployment_tx = TransactionRequest::default()
                .input(bytecode_bytes.into());

            let tx_json = serde_json::to_value(&deployment_tx)
                .expect("Failed to serialize deployment tx");

            let evm_signer = SignerContext::current_as_evm()
                .await
                .expect("Failed to get EVM signer");

            let deployment_hash = evm_signer
                .sign_and_send_transaction(tx_json)
                .await
                .expect("Failed to deploy contract");

            println!("Contract deployment hash: {}", deployment_hash);

            // Wait for deployment
            tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;

            // Get receipt to find contract address
            let provider = ProviderBuilder::new()
                .connect(&rpc_url)
                .await
                .expect("Failed to connect");

            let receipt = provider
                .get_transaction_receipt(deployment_hash.parse().unwrap())
                .await
                .expect("Failed to get receipt")
                .expect("Receipt not found");

            let contract_address = receipt.contract_address.expect("No contract address");
            println!("Deployed contract at: {}", contract_address);

            Ok(())
        })
        .await
        .expect("Test should complete successfully");
    }

    #[tokio::test]
    #[cfg(feature = "integration-tests")] // Requires Docker
    async fn test_evm_multiple_transfers() {
        let (_container, rpc_url, private_key) = setup_anvil_node().await;

        let config = EvmNetworkConfig::new("local", 31337, rpc_url.clone());
        let _signer = LocalEvmSigner::new(private_key, config).expect("Failed to create signer");

        let config = riglr_config::Config::from_env();
        let app_context = ApplicationContext::from_config(&config);

        // Create multiple recipients
        let recipients = vec![
            "0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
            "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC",
            "0x90F79bf6EB2c4f870365E785982E1f101E93b906",
        ];

        // Send ETH to each recipient
        for recipient in &recipients {
            let tx_hash = send_eth(recipient.to_string(), 0.1, None, &app_context)
                .await
                .expect("Failed to send ETH");
            println!("Sent to {}: {}", recipient, tx_hash);
        }

        // Wait for transactions
        tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;

        // Verify all recipients received ETH
        for recipient_str in &recipients {
            let recipient = Address::from_str(recipient_str).unwrap();
            let balance = get_balance(&rpc_url, recipient).await;
            let expected_amount = U256::from(100_000_000_000_000_000u64); // 0.1 ETH in wei
            assert!(
                balance >= expected_amount,
                "Recipient should have received ETH"
            );
        }
    }

    #[tokio::test]
    #[cfg(feature = "integration-tests")] // Requires Docker
    async fn test_evm_insufficient_funds_error() {
        let (_container, rpc_url, _) = setup_anvil_node().await;

        // Create signer with a new private key (no funds)
        let unfunded_key = "0x0000000000000000000000000000000000000000000000000000000000000001";
        let config = EvmNetworkConfig::new("local", 31337, rpc_url);
        let _signer =
            LocalEvmSigner::new(unfunded_key.to_string(), config).expect("Failed to create signer");

        let config = riglr_config::Config::from_env();
        let app_context = ApplicationContext::from_config(&config);

        // Try to send ETH without funds
        let recipient = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8";

        let result = send_eth(recipient.to_string(), 1.0, None, &app_context).await;

        // Should fail due to insufficient funds
        assert!(result.is_err());
        let error_msg = result.unwrap_err().to_string().to_lowercase();
        assert!(
            error_msg.contains("insufficient")
                || error_msg.contains("not enough")
                || error_msg.contains("funds"),
            "Error should indicate insufficient funds: {}",
            error_msg
        );
    }

    #[tokio::test]
    #[cfg(feature = "integration-tests")] // Requires Docker
    async fn test_evm_gas_estimation() {
        let (_container, rpc_url, private_key) = setup_anvil_node().await;

        let config = EvmNetworkConfig::new("local", 31337, rpc_url.clone());
        let _signer = LocalEvmSigner::new(private_key, config).expect("Failed to create signer");

        let unified_signer: Arc<dyn UnifiedSigner> = Arc::new(_signer);

        // Execute test within SignerContext
        SignerContext::with_signer(unified_signer, async move {
            // Create a complex transaction that requires gas estimation
            let recipient =
                Address::from_str("0x70997970C51812dc3A010C7d01b50e0d17dc79C8").unwrap();

            // Send with data (more expensive than simple transfer)
            let tx = TransactionRequest::default()
                .to(recipient)
                .value(U256::from(1_000_000_000_000_000u64)) // 0.001 ETH
                .input(vec![1, 2, 3, 4, 5].into()); // Some data

            let tx_json = serde_json::to_value(&tx).expect("Failed to serialize");

            let evm_signer = SignerContext::current_as_evm()
                .await
                .expect("Failed to get signer");

            let tx_hash = evm_signer
                .sign_and_send_transaction(tx_json)
                .await
                .expect("Failed to send transaction");

            println!("Transaction with data sent: {}", tx_hash);

            // Verify transaction succeeded
            tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;

            let provider = ProviderBuilder::new()
                .connect(&rpc_url)
                .await
                .expect("Failed to connect");

            let receipt = provider
                .get_transaction_receipt(tx_hash.parse().unwrap())
                .await
                .expect("Failed to get receipt")
                .expect("Receipt should exist");

            assert!(receipt.status(), "Transaction should succeed");
            assert!(
                receipt.gas_used > 21000,
                "Should use more gas than simple transfer"
            );

            Ok(())
        })
        .await
        .expect("Test should complete successfully");
    }

    #[tokio::test]
    #[cfg(feature = "integration-tests")] // Requires Docker
    async fn test_evm_concurrent_transactions() {
        let (_container, rpc_url, private_key) = setup_anvil_node().await;

        let config = EvmNetworkConfig::new("local", 31337, rpc_url.clone());
        let _signer = LocalEvmSigner::new(private_key, config).expect("Failed to create signer");

        let config = riglr_config::Config::from_env();
        let app_context = ApplicationContext::from_config(&config);

        // Recipients for concurrent transfers
        let recipients = vec![
            "0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
            "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC",
            "0x90F79bf6EB2c4f870365E785982E1f101E93b906",
            "0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65",
            "0x9965507D1a55bcC2695C58ba16FB37d819B0A4dc",
        ];

        // Send transfers concurrently
        let mut handles = vec![];

        for recipient in recipients.iter() {
            let recipient_str = recipient.to_string();
            let app_context_clone = app_context.clone();
            let handle = tokio::spawn(async move {
                send_eth(recipient_str, 0.01, None, &app_context_clone).await
            });
            handles.push(handle);
        }

        // Wait for all transfers
        let mut success_count = 0;
        for handle in handles {
            if let Ok(Ok(_)) = handle.await {
                success_count += 1;
            }
        }

        // Most should succeed (some might fail due to nonce issues)
        assert!(
            success_count >= 3,
            "At least 60% of concurrent transactions should succeed, got {}/5",
            success_count
        );
    }
}