diff --git a/Cargo.lock b/Cargo.lock index 15b6f89..c90798b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1393,9 +1393,9 @@ checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" [[package]] name = "form_urlencoded" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ "percent-encoding", ] @@ -1924,9 +1924,9 @@ checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "idna" -version = "1.0.3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ "idna_adapter", "smallvec", @@ -2379,9 +2379,9 @@ checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] name = "percent-encoding" -version = "2.3.1" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" @@ -2570,6 +2570,7 @@ version = "0.1.0" dependencies = [ "alloy", "anyhow", + "async-trait", "getrandom 0.2.16", "once_cell", "reqwest 0.11.27", @@ -2577,6 +2578,7 @@ dependencies = [ "serde_json", "thiserror 1.0.69", "tokio", + "url", ] [[package]] @@ -3628,13 +3630,14 @@ checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] name = "url" -version = "2.5.4" +version = "2.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" dependencies = [ "form_urlencoded", "idna", "percent-encoding", + "serde", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index b368aab..7093cb8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,8 @@ reqwest = { version = "0.11.27", features = ["json"] } serde_json = "1.0.111" thiserror = "1.0.56" serde = "1.0.195" +async-trait = "0.1" +url = "2.5.7" [target.'cfg(target_family = "wasm")'.dependencies] getrandom = { version = "0.2.11", features = ["js", "js-sys"] } diff --git a/src/error.rs b/src/error.rs index 5e3e33a..4c11c90 100644 --- a/src/error.rs +++ b/src/error.rs @@ -11,9 +11,64 @@ use std::{ sync::{Mutex, MutexGuard, PoisonError}, }; use thiserror::Error; +use url::Url; pub const SELECTOR_REGISTRY_URL: &str = "https://api.openchain.xyz/signature-database/v1/lookup"; +/// Trait for pluggable error selector registries. +/// +/// Implement this trait to provide alternative lookup sources +/// (e.g. local cache, different HTTP service, bundled table). +#[cfg_attr(target_family = "wasm", async_trait::async_trait(?Send))] +#[cfg_attr(not(target_family = "wasm"), async_trait::async_trait)] +pub trait ErrorRegistry: Send + Sync { + /// Lookup candidate ABI errors for a given 4-byte selector. + async fn lookup(&self, selector: [u8; 4]) -> Result, AbiDecodeFailedErrors>; +} + +/// Default OpenChain-backed registry implementation. +pub struct OpenChainRegistry { + client: Client, + url: Url, +} + +impl Default for OpenChainRegistry { + fn default() -> Self { + Self { + client: Client::new(), + url: Url::parse(SELECTOR_REGISTRY_URL).unwrap(), + } + } +} + +#[cfg_attr(target_family = "wasm", async_trait::async_trait(?Send))] +#[cfg_attr(not(target_family = "wasm"), async_trait::async_trait)] +impl ErrorRegistry for OpenChainRegistry { + async fn lookup(&self, selector: [u8; 4]) -> Result, AbiDecodeFailedErrors> { + let selector_hash = alloy::primitives::hex::encode_prefixed(selector); + let response = self + .client + .get(self.url.as_ref()) + .query(&vec![ + ("function", selector_hash.as_str()), + ("filter", "true"), + ]) + .header("accept", "application/json") + .send() + .await? + .json::() + .await?; + + Ok(response["result"]["function"][selector_hash] + .as_array() + .into_iter() + .flat_map(|selectors| selectors.iter()) + .filter_map(|opt_selector| opt_selector["name"].as_str()) + .filter_map(|name| name.parse::().ok()) + .collect()) + } +} + // panic selector pub const PANIC_SIG: &str = "Panic(uint256)"; pub const PANIC_SELECTOR: [u8; 4] = [0x4e, 0x48, 0x7b, 0x71]; // 0x4e487b71 @@ -22,6 +77,10 @@ pub const PANIC_SELECTOR: [u8; 4] = [0x4e, 0x48, 0x7b, 0x71]; // 0x4e487b71 pub static SELECTORS: Lazy>> = Lazy::new(|| Mutex::new(HashMap::new())); +/// Default registry instance reused across calls to avoid repeatedly +/// constructing a reqwest::Client. +pub static DEFAULT_REGISTRY: Lazy = Lazy::new(OpenChainRegistry::default); + #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, Error)] pub enum AbiDecodedErrorType { Unknown(Vec), @@ -63,9 +122,11 @@ impl AbiDecodedErrorType { Ok(selectors.get(&selector_hash).cloned()) } - /// decodes an error returned from calling a contract by searching its selector in registry + /// Decode an error selector with optional registry injection. + /// If `registry` is `None`, uses the default OpenChain-backed registry. pub async fn selector_registry_abi_decode( error_data: &[u8], + registry: Option<&dyn ErrorRegistry>, ) -> Result { if error_data.is_empty() { return Err(AbiDecodeFailedErrors::NoData); @@ -76,7 +137,6 @@ impl AbiDecodedErrorType { )); } let (hash_bytes, args_data) = error_data.split_at(4); - let selector_hash = alloy::primitives::hex::encode_prefixed(hash_bytes); let selector_hash_bytes: [u8; 4] = hash_bytes .try_into() .map_err(|_| AbiDecodeFailedErrors::InvalidSelectorHash(hash_bytes.to_vec()))?; @@ -100,43 +160,34 @@ impl AbiDecodedErrorType { return Ok(Self::Unknown(error_data.to_vec())); } - let client = Client::builder().build()?; - let response = client - .get(SELECTOR_REGISTRY_URL) - .query(&vec![ - ("function", selector_hash.as_str()), - ("filter", "true"), - ]) - .header("accept", "application/json") - .send() - .await? - .json::() - .await?; - - if let Some(selectors) = response["result"]["function"][selector_hash].as_array() { - for opt_selector in selectors { - if let Some(selector) = opt_selector["name"].as_str() { - if let Ok(error) = selector.parse::() { - if let Ok(result) = error.abi_decode_input(args_data) { - // cache the fetched selector - { - let mut cached_selectors = SELECTORS.lock()?; - cached_selectors.insert(selector_hash_bytes, error.clone()); - }; - return Ok(Self::Known { - sig: error.signature(), - name: error.name, - args: result.iter().map(|v| format!("{:?}", v)).collect(), - data: error_data.to_vec(), - }); - } - } - } - } - Ok(Self::Unknown(error_data.to_vec())) - } else { - Ok(Self::Unknown(error_data.to_vec())) - } + let registry = match registry { + Some(r) => r, + None => &*DEFAULT_REGISTRY as &dyn ErrorRegistry, + }; + + // consult the registry + let candidates = registry.lookup(selector_hash_bytes).await?; + Ok(candidates + .into_iter() + .find_map(|error| { + let result = error.abi_decode_input(args_data).ok()?; + + // cache the fetched selector + let mut cached_selectors = match SELECTORS.lock() { + Ok(lock) => lock, + Err(e) => return Some(Err(e)), + }; + cached_selectors.insert(selector_hash_bytes, error.clone()); + + Some(Ok(Self::Known { + sig: error.signature(), + name: error.name, + args: result.iter().map(|v| format!("{:?}", v)).collect(), + data: error_data.to_vec(), + })) + }) + .transpose()? + .unwrap_or_else(|| Self::Unknown(error_data.to_vec()))) } /// Decodes an error by checking if it is a Panic(uint256) and returns `None` if @@ -184,7 +235,26 @@ impl AbiDecodedErrorType { AbiDecodeFailedErrors::InvalidJsonRpcResponse(val.get().to_string()) })?; let decoded_data = decode(unwrapped.as_bytes())?; - return Self::selector_registry_abi_decode(&decoded_data).await; + return Self::selector_registry_abi_decode(&decoded_data, None).await; + } + } + Err(AbiDecodeFailedErrors::InvalidJsonRpcResponse( + err.to_string(), + )) + } + + /// Variant of JSON-RPC error decoding that accepts an injected registry. + pub async fn try_from_json_rpc_error_with_registry( + err: ErrorPayload, + registry: &dyn ErrorRegistry, + ) -> Result { + if err.message.contains("revert") { + if let Some(val) = &err.data { + let unwrapped: String = serde_json::from_str(val.get()).map_err(|_| { + AbiDecodeFailedErrors::InvalidJsonRpcResponse(val.get().to_string()) + })?; + let decoded_data = decode(unwrapped.as_bytes())?; + return Self::selector_registry_abi_decode(&decoded_data, Some(registry)).await; } } Err(AbiDecodeFailedErrors::InvalidJsonRpcResponse( @@ -220,10 +290,29 @@ mod tests { use super::*; use serde_json::value::RawValue; + struct FakeRegistry; + + #[cfg_attr(target_family = "wasm", async_trait::async_trait(?Send))] + #[cfg_attr(not(target_family = "wasm"), async_trait::async_trait)] + impl ErrorRegistry for FakeRegistry { + async fn lookup( + &self, + selector: [u8; 4], + ) -> Result, AbiDecodeFailedErrors> { + // 0x1ac66908 + if selector == [0x1a, 0xc6, 0x69, 0x08] { + let e: AlloyError = "UnexpectedOperandValue()".parse().unwrap(); + Ok(vec![e]) + } else { + Ok(vec![]) + } + } + } + #[tokio::test] async fn test_error_decoder() { let data = vec![26, 198, 105, 8]; - let res = AbiDecodedErrorType::selector_registry_abi_decode(&data.clone()) + let res = AbiDecodedErrorType::selector_registry_abi_decode(&data, Some(&FakeRegistry)) .await .expect("failed to get error selector"); assert_eq!( @@ -240,7 +329,7 @@ mod tests { #[tokio::test] async fn test_error_decoder_unknown() { let data = vec![26, 198, 105, 9]; - let res = AbiDecodedErrorType::selector_registry_abi_decode(&data.clone()) + let res = AbiDecodedErrorType::selector_registry_abi_decode(&data, Some(&FakeRegistry)) .await .expect("failed to get error selector"); assert_eq!(AbiDecodedErrorType::Unknown(data), res); @@ -249,7 +338,7 @@ mod tests { #[tokio::test] async fn test_error_decoder_invalid_selector() { let data = vec![26, 198, 105]; - let res = AbiDecodedErrorType::selector_registry_abi_decode(&data.clone()) + let res = AbiDecodedErrorType::selector_registry_abi_decode(&data, Some(&FakeRegistry)) .await .expect_err("expected error"); match res { @@ -261,7 +350,7 @@ mod tests { #[tokio::test] async fn test_error_decoder_no_data() { let data = vec![]; - let res = AbiDecodedErrorType::selector_registry_abi_decode(&data.clone()) + let res = AbiDecodedErrorType::selector_registry_abi_decode(&data, Some(&FakeRegistry)) .await .expect_err("expected error"); match res { @@ -272,8 +361,10 @@ mod tests { #[tokio::test] async fn test_error_decoder_cache() { + // ensure cache is empty for this test + clear_cache(); let data = vec![26, 198, 105, 8]; - let res = AbiDecodedErrorType::selector_registry_abi_decode(&data.clone()) + let res = AbiDecodedErrorType::selector_registry_abi_decode(&data, Some(&FakeRegistry)) .await .expect("failed to get error selector"); assert_eq!( @@ -304,15 +395,25 @@ mod tests { assert_eq!(None, res); } + fn clear_cache() { + let mut cache = SELECTORS + .lock() + .expect("failed to lock selectors cache for clearing"); + cache.clear(); + } + #[tokio::test] async fn test_error_decoder_json_rpc_error() { let data = vec![26, 198, 105, 8]; let encoded = encode(&data); - let res = AbiDecodedErrorType::try_from_json_rpc_error(ErrorPayload { - code: 3, - data: Some(RawValue::from_string(format!(r#""{encoded}""#)).unwrap()), - message: "execution reverted".into(), - }) + let res = AbiDecodedErrorType::try_from_json_rpc_error_with_registry( + ErrorPayload { + code: 3, + data: Some(RawValue::from_string(format!(r#""{encoded}""#)).unwrap()), + message: "execution reverted".into(), + }, + &FakeRegistry, + ) .await .expect("failed to get error selector"); assert_eq!( @@ -452,4 +553,31 @@ mod tests { res ); } + + #[tokio::test] + async fn test_openchain_registry_live_lookup_known_selector() { + clear_cache(); + + let data = vec![0x1a, 0xc6, 0x69, 0x08]; + + let registry = OpenChainRegistry::default(); + let res = AbiDecodedErrorType::selector_registry_abi_decode(&data, Some(®istry)) + .await + .expect("OpenChain lookup failed"); + + match res { + AbiDecodedErrorType::Known { + name, + args, + sig, + data: decoded, + } => { + assert_eq!(decoded, data); + assert!(args.is_empty(), "expected zero-arg error match"); + assert!(!name.is_empty(), "expected non-empty error name"); + assert!(sig.ends_with(')'), "expected error-like signature"); + } + AbiDecodedErrorType::Unknown(_) => panic!("expected a known error from OpenChain"), + } + } }