tests: Add browser WASM tests for evm_client

This commit is contained in:
Sameh Abouelsaad 2025-05-16 02:06:41 +03:00
parent 73233ec69b
commit 03533f9216
12 changed files with 542 additions and 10 deletions

View File

@ -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)

View File

@ -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"] }

View File

@ -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<S: Signer> {
providers: HashMap<String, EvmProvider>,
current: Option<String>,
signer: S,
}
impl EvmClient {
pub async fn connect(_rpc_url: &str) -> Result<Self, EvmError> {
todo!("Implement connect")
impl<S: Signer> EvmClient<S> {
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<u128, EvmError> {
let provider = self.current_provider()?;
provider.get_balance(address).await
}
pub async fn transfer(&self, to: &str, amount: u128) -> Result<String, EvmError> {
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
}

111
evm_client/src/provider.rs Normal file
View File

@ -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<u8> {
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<U256, EvmError> {
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<u128, EvmError> {
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<S: Signer>(&self, _tx: &Transaction, _signer: &S) -> Result<String, EvmError> {
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)

81
evm_client/src/signer.rs Normal file
View File

@ -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<Vec<u8>, 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<Vec<u8>, EvmError>;
fn address(&self) -> String;
}
// --- Implementation for vault::SessionManager ---
#[cfg(target_arch = "wasm32")]
#[async_trait::async_trait(?Send)]
impl<S: vault::KVStore> Signer for vault::SessionManager<S> {
async fn sign(&self, message: &[u8]) -> Result<Vec<u8>, 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<S: vault::KVStore + Send + Sync> Signer for vault::SessionManager<S> {
async fn sign(&self, message: &[u8]) -> Result<Vec<u8>, 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()
}
}

25
evm_client/src/utils.rs Normal file
View File

@ -0,0 +1,25 @@
use crate::EvmError;
#[cfg(not(target_arch = "wasm32"))]
pub async fn http_post(url: &str, body: &str) -> Result<serde_json::Value, EvmError> {
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<serde_json::Value, EvmError> {
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)
}

View File

@ -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<Vec<u8>, 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<Vec<u8>, 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");
}

View File

@ -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<Vec<u8>, 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<Vec<u8>, 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(_))));
}

50
evm_client/tests/wasm.rs Normal file
View File

@ -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<Vec<u8>, 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(_))));
}

View File

@ -32,7 +32,7 @@ pub struct KeyEntry {
pub metadata: Option<KeyMetadata>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum KeyType {
Secp256k1,
Ed25519,

View File

@ -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;

View File

@ -1 +0,0 @@
tempfile = "3.10"