diff --git a/crates/eth1wrap/Cargo.toml b/crates/eth1wrap/Cargo.toml index 93ab42e9..18237e97 100644 --- a/crates/eth1wrap/Cargo.toml +++ b/crates/eth1wrap/Cargo.toml @@ -13,7 +13,7 @@ thiserror.workspace = true tokio.workspace = true [dev-dependencies] -tokio.workspace = true +tokio = { workspace = true, features = ["test-util"] } [lints] workspace = true diff --git a/crates/eth1wrap/src/lib.rs b/crates/eth1wrap/src/lib.rs index b8022ad6..88a859e3 100644 --- a/crates/eth1wrap/src/lib.rs +++ b/crates/eth1wrap/src/lib.rs @@ -20,6 +20,9 @@ sol!( /// indefinitely. `tokio::time::timeout` wraps the whole retried operation here. const ERC1271_CALL_TIMEOUT_SECS: u64 = 10; +/// Magic value defined in [ERC-1271](https://eips.ethereum.org/EIPS/eip-1271). +const MAGIC_VALUE: [u8; 4] = [0x16, 0x26, 0xba, 0x7e]; + type Result = std::result::Result; /// Defines errors that can occur when interacting with the Ethereum client. @@ -96,13 +99,16 @@ impl EthClient { hash: [u8; 32], sig: &[u8], ) -> Result { - // Magic value defined in [ERC-1271](https://eips.ethereum.org/EIPS/eip-1271). - const MAGIC_VALUE: [u8; 4] = [0x16, 0x26, 0xba, 0x7e]; let EthClient::Connected(provider) = self else { return Err(EthClientError::NoExecutionEngineAddr); }; - let address = alloy::primitives::Address::parse_checksummed(contract_address, None)?; + // Any casing is accepted (no EIP-55 check), non-hex or wrong-length input + // is rejected rather than silently zero-padded/truncated. + let address = contract_address + .as_ref() + .parse::() + .map_err(alloy::primitives::AddressError::from)?; let instance = IERC1271::new(address, provider); @@ -121,8 +127,30 @@ impl EthClient { #[cfg(test)] mod tests { + use alloy::{primitives::Bytes, providers::mock::Asserter}; + use super::*; + const ADDRESS: &str = "0x5aaeb6053f3e94c9b9a09f33669435e7ef1beaed"; + + /// ABI-encodes a `bytes4` return value as its 32-byte word. + fn erc1271_return(value: [u8; 4]) -> Bytes { + let mut word = [0u8; 32]; + word[..4].copy_from_slice(&value); + Bytes::copy_from_slice(&word) + } + + fn mocked_client(asserter: &Asserter) -> EthClient { + let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone()); + EthClient::Connected(provider.erased()) + } + + async fn verify(client: &EthClient, contract_address: &str) -> Result { + client + .verify_smart_contract_based_signature(contract_address, [7u8; 32], &[1, 2, 3]) + .await + } + #[tokio::test] async fn empty_address_returns_noop_client() { let client = EthClient::new("").await.expect("noop eth client"); @@ -137,4 +165,86 @@ mod tests { assert!(matches!(err, EthClientError::NoExecutionEngineAddr)); } + + #[tokio::test] + async fn any_casing_address_reaches_erc1271_call() { + // Lowercase, checksummed and wrong-checksum forms of the same address. + for address in [ + "0x5aaeb6053f3e94c9b9a09f33669435e7ef1beaed", + "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed", + "0x5AAeb6053F3E94C9b9A09f33669435E7Ef1BeAed", + ] { + let asserter = Asserter::new(); + asserter.push_success(&erc1271_return(MAGIC_VALUE)); + + let valid = verify(&mocked_client(&asserter), address) + .await + .expect("address must parse regardless of casing"); + + assert!(valid); + } + } + + #[tokio::test] + async fn malformed_address_errors() { + let client = mocked_client(&Asserter::new()); + + for address in ["not-an-address", "0x123"] { + let err = verify(&client, address) + .await + .expect_err("malformed address must not verify"); + + assert!(matches!(err, EthClientError::InvalidAddress(_))); + } + } + + #[tokio::test] + async fn non_magic_return_is_invalid() { + for value in [[0u8; 4], [0xff; 4]] { + let asserter = Asserter::new(); + asserter.push_success(&erc1271_return(value)); + + let valid = verify(&mocked_client(&asserter), ADDRESS) + .await + .expect("call must succeed"); + + assert!(!valid); + } + } + + #[tokio::test(start_paused = true)] + async fn hanging_erc1271_call_times_out() { + // A server that accepts but never responds; paused time auto-advances + // to the call deadline, so the test never actually waits it out. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind local listener"); + let endpoint = format!("http://{}", listener.local_addr().expect("local addr")); + + tokio::spawn(async move { + let mut sockets = Vec::new(); + while let Ok((socket, _)) = listener.accept().await { + // Hold sockets open; dropping them fails the request fast + // with a transport error instead of hanging. + sockets.push(socket); + } + }); + + let client = EthClient::new(&endpoint).await.expect("eth client"); + let err = verify(&client, ADDRESS) + .await + .expect_err("hanging endpoint must time out"); + + assert!(matches!(err, EthClientError::CallTimeout)); + } + + #[tokio::test] + async fn non_empty_endpoint_returns_connected_client() { + // HTTP transports connect lazily, so nothing needs to listen here. + let client = EthClient::new("http://127.0.0.1:1") + .await + .expect("eth client"); + + assert!(matches!(client, EthClient::Connected(_))); + } }