From 03533f92168e172514bbbbecd27a2c046006b82e Mon Sep 17 00:00:00 2001 From: Sameh Abouelsaad Date: Fri, 16 May 2025 02:06:41 +0300 Subject: [PATCH] tests: Add browser WASM tests for evm_client --- docs/build_instructions.md | 13 +++ evm_client/Cargo.toml | 16 ++- evm_client/src/lib.rs | 123 ++++++++++++++++++++-- evm_client/src/provider.rs | 111 +++++++++++++++++++ evm_client/src/signer.rs | 81 ++++++++++++++ evm_client/src/utils.rs | 25 +++++ evm_client/tests/balance.rs | 69 ++++++++++++ evm_client/tests/evm_client.rs | 59 +++++++++++ evm_client/tests/wasm.rs | 50 +++++++++ vault/src/data.rs | 2 +- vault/src/lib.rs | 2 +- vault/tests/dev-dependencies-tempfile.txt | 1 - 12 files changed, 542 insertions(+), 10 deletions(-) create mode 100644 evm_client/src/provider.rs create mode 100644 evm_client/src/signer.rs create mode 100644 evm_client/src/utils.rs create mode 100644 evm_client/tests/balance.rs create mode 100644 evm_client/tests/evm_client.rs create mode 100644 evm_client/tests/wasm.rs delete mode 100644 vault/tests/dev-dependencies-tempfile.txt diff --git a/docs/build_instructions.md b/docs/build_instructions.md index 408d585..396d158 100644 --- a/docs/build_instructions.md +++ b/docs/build_instructions.md @@ -65,6 +65,19 @@ This document outlines the steps and requirements to guarantee that both native --- +## Browser (WASM) Testing for evm_client + +To run browser-based tests for `evm_client`: + +```sh +cd evm_client +wasm-pack test --headless --firefox +# or +wasm-pack test --headless --chrome +``` + +This will compile your crate to WASM and run the tests in a real browser environment. + ## 6. Checklist for Compliance - [ ] No unconditional `tokio` usage in library code - [ ] All dependencies are WASM-compatible (where needed) diff --git a/evm_client/Cargo.toml b/evm_client/Cargo.toml index 5e8ab80..cfeaff0 100644 --- a/evm_client/Cargo.toml +++ b/evm_client/Cargo.toml @@ -9,5 +9,19 @@ path = "src/lib.rs" [dependencies] vault = { path = "../vault" } async-trait = "0.1" -alloy = "0.6" thiserror = "1" +alloy-rlp = { version = "0.3.11", features = ["derive"] } +alloy-primitives = "1.1.0" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +log = "0.4" +hex = "0.4" +reqwest = { version = "0.11", features = ["json"] } +k256 = { version = "0.13", features = ["ecdsa"] } +gloo-net = { version = "0.5", features = ["http"] } + +[dev-dependencies] +wasm-bindgen-test = "0.3" + +[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } diff --git a/evm_client/src/lib.rs b/evm_client/src/lib.rs index 628db3c..21fab96 100644 --- a/evm_client/src/lib.rs +++ b/evm_client/src/lib.rs @@ -2,6 +2,12 @@ +pub mod signer; +pub mod provider; +pub mod utils; + +pub use signer::Signer; +pub use provider::EvmProvider; #[derive(Debug, thiserror::Error)] pub enum EvmError { @@ -9,15 +15,120 @@ pub enum EvmError { Rpc(String), #[error("Vault error: {0}")] Vault(String), + #[error("Unknown network")] + UnknownNetwork, + #[error("No provider selected")] + NoNetwork, } -pub struct EvmClient { - // ... fields for RPC, vault, etc. +use std::collections::HashMap; + +pub struct EvmClient { + providers: HashMap, + current: Option, + signer: S, } -impl EvmClient { - pub async fn connect(_rpc_url: &str) -> Result { - todo!("Implement connect") +impl EvmClient { + pub fn new(signer: S) -> Self { + Self { + providers: HashMap::new(), + current: None, + signer, + } + } + pub fn add_provider(&mut self, key: String, provider: EvmProvider) { + self.providers.insert(key, provider); + } + pub fn set_current(&mut self, key: &str) -> Result<(), EvmError> { + if self.providers.contains_key(key) { + self.current = Some(key.to_string()); + Ok(()) + } else { + Err(EvmError::UnknownNetwork) + } + } + pub fn current_provider(&self) -> Result<&EvmProvider, EvmError> { + self.current + .as_ref() + .and_then(|k| self.providers.get(k)) + .ok_or(EvmError::NoNetwork) + } + pub async fn get_balance(&self, address: &str) -> Result { + let provider = self.current_provider()?; + provider.get_balance(address).await + } + pub async fn transfer(&self, to: &str, amount: u128) -> Result { + use crate::provider::{Transaction, parse_signature_rs_v}; +use std::str::FromStr; + use alloy_primitives::{Address, U256, Bytes}; + use log::debug; + + let provider = self.current_provider()?; + let chain_id = match provider { + crate::provider::EvmProvider::Http { chain_id, .. } => *chain_id, + }; + // 1. Fetch nonce for sender + let from = self.signer.address(); + let nonce = provider.get_nonce(&from).await?; + // 2. Build tx struct + let tx = Transaction { + nonce, + to: Address::from_str(to).map_err(|e| EvmError::Rpc(format!("Invalid to address: {e}")))?, + value: U256::from(amount), + gas: U256::from(21000), + gas_price: U256::from(1_000_000_000u64), // 1 gwei + data: Bytes::default(), + chain_id, + }; + debug!("transfer: tx={:?}", tx); + // 3. RLP-encode unsigned + let unsigned = tx.rlp_encode_unsigned(); + // 4. Sign + let signature = self.signer.sign(&unsigned).await?; + let (r, s, v) = parse_signature_rs_v(&signature, chain_id).ok_or_else(|| EvmError::Rpc("Invalid signature length".into()))?; + // 5. RLP-encode signed + // Define a tuple for the signed transaction fields in the correct order + let nonce_bytes = tx.nonce.to_be_bytes::<32>(); + let gas_price_bytes = tx.gas_price.to_be_bytes::<32>(); + let gas_bytes = tx.gas.to_be_bytes::<32>(); + let value_bytes = tx.value.to_be_bytes::<32>(); + let v_bytes = U256::from(v).to_be_bytes::<32>(); + let r_bytes = r.to_be_bytes::<32>(); + let s_bytes = s.to_be_bytes::<32>(); + let fields: Vec<&[u8]> = vec![ + &nonce_bytes, + &gas_price_bytes, + &gas_bytes, + tx.to.as_slice(), + &value_bytes, + tx.data.as_ref(), + &v_bytes, + &r_bytes, + &s_bytes, + ]; + let mut raw_tx = Vec::new(); + alloy_rlp::encode_list::<&[u8], [u8]>(&fields, &mut raw_tx); + // 6. Send + let raw_hex = format!("0x{}", hex::encode(&raw_tx)); + let data = serde_json::json!({ + "jsonrpc": "2.0", + "method": "eth_sendRawTransaction", + "params": [raw_hex], + "id": 1 + }); + let url = match provider { + crate::provider::EvmProvider::Http { url, .. } => url, + }; + let resp = crate::utils::http_post(url, &data.to_string()).await?; + if let Some(err) = resp.get("error") { + return Err(EvmError::Rpc(format!("eth_sendRawTransaction error: {err:?}"))); + } + if let Some(result) = resp.get("result") { + if let Some(tx_hash) = result.as_str() { + return Ok(tx_hash.to_string()); + } + } + Err(EvmError::Rpc("eth_sendRawTransaction: No result field in response".to_string())) } - // ... other API stubs } diff --git a/evm_client/src/provider.rs b/evm_client/src/provider.rs new file mode 100644 index 0000000..dd97cda --- /dev/null +++ b/evm_client/src/provider.rs @@ -0,0 +1,111 @@ +use crate::{EvmError, Signer}; + +pub enum EvmProvider { + Http { name: String, url: String, chain_id: u64 }, +} + +use log::{debug, error}; +use alloy_primitives::{Address, U256, Bytes}; + + +#[derive(Debug, Clone)] +pub struct Transaction { + pub nonce: U256, + pub to: Address, + pub value: U256, + pub gas: U256, + pub gas_price: U256, + pub data: Bytes, + pub chain_id: u64, +} + +impl Transaction { + pub fn rlp_encode_unsigned(&self) -> Vec { + let nonce_bytes = self.nonce.to_be_bytes::<32>(); + let gas_price_bytes = self.gas_price.to_be_bytes::<32>(); + let gas_bytes = self.gas.to_be_bytes::<32>(); + let value_bytes = self.value.to_be_bytes::<32>(); + let chain_id_bytes = self.chain_id.to_be_bytes(); + let fields: Vec<&[u8]> = vec![ + &nonce_bytes, + &gas_price_bytes, + &gas_bytes, + self.to.as_slice(), + &value_bytes, + self.data.as_ref(), + &chain_id_bytes, + &[0u8][..], + &[0u8][..], + ]; + let mut out = Vec::new(); + alloy_rlp::encode_list::<&[u8], [u8]>(&fields, &mut out); + out + } +} + +impl EvmProvider { + pub async fn get_nonce(&self, address: &str) -> Result { + debug!("get_nonce: address={}", address); + let data = serde_json::json!({ + "jsonrpc": "2.0", + "method": "eth_getTransactionCount", + "params": [address, "pending"], + "id": 1 + }); + let url = match self { + EvmProvider::Http { url, .. } => url, + }; + let resp = crate::utils::http_post(url, &data.to_string()).await?; + let nonce_hex = resp["result"].as_str().ok_or_else(|| { + error!("No result in eth_getTransactionCount response: {:?}", resp); + EvmError::Rpc("No result".into()) + })?; + Ok(U256::from_str_radix(nonce_hex.trim_start_matches("0x"), 16).unwrap_or(U256::ZERO)) + } + + pub async fn get_balance(&self, address: &str) -> Result { + debug!("get_balance: address={}", address); + let data = serde_json::json!({ + "jsonrpc": "2.0", + "method": "eth_getBalance", + "params": [address, "latest"], + "id": 1 + }); + let url = match self { + EvmProvider::Http { url, .. } => url, + }; + let resp = crate::utils::http_post(url, &data.to_string()).await?; + let balance_hex = resp["result"].as_str().ok_or_else(|| { + error!("No result in eth_getBalance response: {:?}", resp); + EvmError::Rpc("No result".into()) + })?; + Ok(u128::from_str_radix(balance_hex.trim_start_matches("0x"), 16).unwrap_or(0)) + } + + /// Deprecated: Use EvmClient::transfer instead. + pub async fn send_transaction(&self, _tx: &Transaction, _signer: &S) -> Result { + panic!("send_transaction is deprecated. Use EvmClient::transfer instead."); + } +} + +/// Helper to parse a 65-byte secp256k1 signature into (r, s, v) for EVM. +/// Assumes signature is [r (32 bytes) | s (32 bytes) | v (1 byte)] +pub fn parse_signature_rs_v(sig: &[u8], chain_id: u64) -> Option<(U256, U256, u64)> { + if sig.len() != 65 { + return None; + } + let r = U256::from_be_bytes::<32>(sig[0..32].try_into().unwrap()); + let s = U256::from_be_bytes::<32>(sig[32..64].try_into().unwrap()); + let mut v = sig[64] as u64; + // EIP-155: v = recid + 35 + chain_id * 2 + if v < 27 { v += 27; } + v = v + chain_id * 2 + 8; + Some((r, s, v)) +} + +// Example usage: +// let (r, s, v) = parse_signature_rs_v(&signature, tx.chain_id).unwrap(); +// Use these for EVM transaction serialization. + +// (Remove old sign_and_serialize placeholder) + diff --git a/evm_client/src/signer.rs b/evm_client/src/signer.rs new file mode 100644 index 0000000..f9e8d92 --- /dev/null +++ b/evm_client/src/signer.rs @@ -0,0 +1,81 @@ +use async_trait::async_trait; +use crate::EvmError; + +// Native: Only compile for non-WASM +#[cfg(not(target_arch = "wasm32"))] +#[async_trait] +pub trait Signer: Send + Sync { + async fn sign(&self, message: &[u8]) -> Result, EvmError>; + fn address(&self) -> String; +} + +// WASM: Only compile for WASM +#[cfg(target_arch = "wasm32")] +#[async_trait(?Send)] +pub trait Signer { + async fn sign(&self, message: &[u8]) -> Result, EvmError>; + fn address(&self) -> String; +} + + + +// --- Implementation for vault::SessionManager --- + +#[cfg(target_arch = "wasm32")] +#[async_trait::async_trait(?Send)] +impl Signer for vault::SessionManager { + async fn sign(&self, message: &[u8]) -> Result, EvmError> { + log::debug!("SessionManager::sign called"); + self.sign(message) + .await + .map_err(|e| { + log::error!("Vault signing error: {}", e); + EvmError::Vault(e.to_string()) + }) + } + fn address(&self) -> String { + log::debug!("SessionManager::address called"); + self.current_keypair() + .map(|k| { + if k.key_type == vault::KeyType::Secp256k1 { + let pubkey = &k.public_key; + use alloy_primitives::keccak256; + let hash = keccak256(&pubkey[1..]); + format!("0x{}", hex::encode(&hash[12..])) + } else { + format!("0x{}", hex::encode(&k.public_key)) + } + }) + .unwrap_or_default() + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[async_trait::async_trait] + +impl Signer for vault::SessionManager { + async fn sign(&self, message: &[u8]) -> Result, EvmError> { + log::debug!("SessionManager::sign called"); + self.sign(message) + .await + .map_err(|e| { + log::error!("Vault signing error: {}", e); + EvmError::Vault(e.to_string()) + }) + } + fn address(&self) -> String { + log::debug!("SessionManager::address called"); + self.current_keypair() + .map(|k| { + if k.key_type == vault::KeyType::Secp256k1 { + let pubkey = &k.public_key; + use alloy_primitives::keccak256; + let hash = keccak256(&pubkey[1..]); + format!("0x{}", hex::encode(&hash[12..])) + } else { + format!("0x{}", hex::encode(&k.public_key)) + } + }) + .unwrap_or_default() + } +} diff --git a/evm_client/src/utils.rs b/evm_client/src/utils.rs new file mode 100644 index 0000000..82b6681 --- /dev/null +++ b/evm_client/src/utils.rs @@ -0,0 +1,25 @@ +use crate::EvmError; + +#[cfg(not(target_arch = "wasm32"))] +pub async fn http_post(url: &str, body: &str) -> Result { + let resp = reqwest::Client::new() + .post(url) + .header("content-type", "application/json") + .body(body.to_owned()) + .send() + .await + .map_err(|e| EvmError::Rpc(e.to_string()))?; + let json = resp.json().await.map_err(|e| EvmError::Rpc(e.to_string()))?; + Ok(json) +} + +#[cfg(target_arch = "wasm32")] +pub async fn http_post(url: &str, body: &str) -> Result { + use gloo_net::http::Request; + let resp = Request::post(url) + .header("content-type", "application/json") + .body(body).map_err(|e| EvmError::Rpc(e.to_string()))? + .send().await.map_err(|e| EvmError::Rpc(e.to_string()))?; + let json = resp.json().await.map_err(|e| EvmError::Rpc(e.to_string()))?; + Ok(json) +} diff --git a/evm_client/tests/balance.rs b/evm_client/tests/balance.rs new file mode 100644 index 0000000..6db8d54 --- /dev/null +++ b/evm_client/tests/balance.rs @@ -0,0 +1,69 @@ +use evm_client::{EvmClient, EvmProvider, Signer}; + +// Dummy signer that returns a known Ethereum address (Vitalik's address) +struct DummySigner; + +#[cfg(not(target_arch = "wasm32"))] +#[async_trait::async_trait] +impl Signer for DummySigner { + async fn sign(&self, _message: &[u8]) -> Result, evm_client::EvmError> { + Err(evm_client::EvmError::Vault("sign not implemented".to_string())) + } + fn address(&self) -> String { + // Vitalik's main address (has funds on mainnet) + "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045".to_string() + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[tokio::test] +async fn test_get_balance_vitalik() { + // Use a public Ethereum mainnet RPC + let provider = EvmProvider::Http { + name: "mainnet".to_string(), + url: "https://eth.drpc.org".to_string(), + chain_id: 1, + }; + let signer = DummySigner; + let mut client = EvmClient::new(signer); + client.add_provider("mainnet".to_string(), provider); + client.set_current("mainnet").unwrap(); + let balance = client.get_balance("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045").await.unwrap(); + assert!(balance > 0, "Vitalik's balance should be greater than zero"); +} + +#[cfg(target_arch = "wasm32")] +use wasm_bindgen_test::*; + +#[cfg(target_arch = "wasm32")] +#[async_trait::async_trait(?Send)] +impl Signer for DummySigner { + async fn sign(&self, _message: &[u8]) -> Result, evm_client::EvmError> { + Err(evm_client::EvmError::Vault("sign not implemented".to_string())) + } + fn address(&self) -> String { + // Vitalik's main address (has funds on mainnet) + "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045".to_string() + } +} + +#[cfg(target_arch = "wasm32")] +wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser); + +#[cfg(target_arch = "wasm32")] +#[wasm_bindgen_test(async)] +async fn test_get_balance_vitalik_browser() { + let provider = EvmProvider::Http { + name: "mainnet".to_string(), + url: "https://eth.drpc.org".to_string(), + chain_id: 1, + }; + let signer = DummySigner; + let mut client = EvmClient::new(signer); + client.add_provider("mainnet".to_string(), provider); + client.set_current("mainnet").unwrap(); + let balance = client.get_balance("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045").await; + assert!(balance.is_ok(), "Balance query should succeed in browser"); + let balance = balance.unwrap(); + assert!(balance > 0u128, "Vitalik's balance should be greater than zero"); +} diff --git a/evm_client/tests/evm_client.rs b/evm_client/tests/evm_client.rs new file mode 100644 index 0000000..fd790dc --- /dev/null +++ b/evm_client/tests/evm_client.rs @@ -0,0 +1,59 @@ +#![cfg(not(target_arch = "wasm32"))] +// tests/evm_client.rs +use evm_client::{EvmClient, EvmProvider, Signer, EvmError}; + +struct DummySigner; + +#[cfg(target_arch = "wasm32")] +#[async_trait::async_trait(?Send)] +impl Signer for DummySigner { + async fn sign(&self, _message: &[u8]) -> Result, EvmError> { + Ok(vec![0u8; 65]) // dummy signature + } + fn address(&self) -> String { + "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045".to_string() + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[async_trait::async_trait] +impl Signer for DummySigner { + async fn sign(&self, _message: &[u8]) -> Result, EvmError> { + Ok(vec![0u8; 65]) // dummy signature + } + fn address(&self) -> String { + "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045".to_string() + } +} + +#[tokio::test] +async fn test_transfer_rlp_encoding() { + let provider = EvmProvider::Http { + name: "mainnet".to_string(), + url: "https://rpc.ankr.com/eth".to_string(), + chain_id: 1, + }; + let signer = DummySigner; + let mut client = EvmClient::new(signer); + client.add_provider("mainnet".to_string(), provider); + client.set_current("mainnet").unwrap(); + // Use a dummy transfer (will fail to send, but will test RLP logic) + let result = client.transfer("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", 1u128).await; + // Should fail due to dummy signature, but should not panic or error at RLP encoding + assert!(matches!(result, Err(EvmError::Rpc(_)) | Err(EvmError::Vault(_)))); +} + +#[tokio::test] +async fn test_transfer_invalid_address() { + let provider = EvmProvider::Http { + name: "mainnet".to_string(), + url: "https://rpc.ankr.com/eth".to_string(), + chain_id: 1, + }; + let signer = DummySigner; + let mut client = EvmClient::new(signer); + client.add_provider("mainnet".to_string(), provider); + client.set_current("mainnet").unwrap(); + let result = client.transfer("invalid_address", 1u128).await; + assert!(matches!(result, Err(EvmError::Rpc(_)))); +} diff --git a/evm_client/tests/wasm.rs b/evm_client/tests/wasm.rs new file mode 100644 index 0000000..0dec357 --- /dev/null +++ b/evm_client/tests/wasm.rs @@ -0,0 +1,50 @@ +#![cfg(target_arch = "wasm32")] +use wasm_bindgen_test::*; +wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser); + +use evm_client::{EvmClient, EvmProvider, Signer, EvmError}; + +struct DummySigner; + +#[async_trait::async_trait(?Send)] +impl Signer for DummySigner { + async fn sign(&self, _message: &[u8]) -> Result, EvmError> { + Ok(vec![0u8; 65]) // dummy signature + } + fn address(&self) -> String { + "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045".to_string() + } +} + +#[wasm_bindgen_test(async)] +async fn test_get_balance_vitalik_browser() { + let provider = EvmProvider::Http { + name: "mainnet".to_string(), + url: "https://eth.drpc.org".to_string(), + chain_id: 1, + }; + let signer = DummySigner; + let mut client = EvmClient::new(signer); + client.add_provider("mainnet".to_string(), provider); + client.set_current("mainnet").unwrap(); + let balance = client.get_balance("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045").await; + assert!(balance.is_ok(), "Balance query should succeed in browser"); + let balance = balance.unwrap(); + assert!(balance > 0u128, "Vitalik's balance should be greater than zero"); +} + +#[wasm_bindgen_test(async)] +async fn test_transfer_rlp_encoding_browser() { + let provider = EvmProvider::Http { + name: "mainnet".to_string(), + url: "https://eth.drpc.org".to_string(), + chain_id: 1, + }; + let signer = DummySigner; + let mut client = EvmClient::new(signer); + client.add_provider("mainnet".to_string(), provider); + client.set_current("mainnet").unwrap(); + let result = client.transfer("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", 1u128).await; + assert!(matches!(result, Err(EvmError::Rpc(_)) | Err(EvmError::Vault(_)))); +} + diff --git a/vault/src/data.rs b/vault/src/data.rs index 8c34d53..3ecaa88 100644 --- a/vault/src/data.rs +++ b/vault/src/data.rs @@ -32,7 +32,7 @@ pub struct KeyEntry { pub metadata: Option, } -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub enum KeyType { Secp256k1, Ed25519, diff --git a/vault/src/lib.rs b/vault/src/lib.rs index d50ed74..ec072c5 100644 --- a/vault/src/lib.rs +++ b/vault/src/lib.rs @@ -12,7 +12,7 @@ mod session; mod utils; -use kvstore::KVStore; +pub use kvstore::traits::KVStore; use data::*; use error::VaultError; use crate::crypto::random_salt; diff --git a/vault/tests/dev-dependencies-tempfile.txt b/vault/tests/dev-dependencies-tempfile.txt deleted file mode 100644 index c28bbb0..0000000 --- a/vault/tests/dev-dependencies-tempfile.txt +++ /dev/null @@ -1 +0,0 @@ -tempfile = "3.10"