Skip to main content

mfm_collectors_evm/
lib.rs

1//! EVM collectors.
2//!
3//! This crate defines typed adapters over the generic `IoCall` surface for EVM JSON-RPC reads.
4//! It intentionally does NOT perform IO itself.
5//!
6//! # Examples
7//!
8//! ```rust
9//! use mfm_collectors_evm::{fact_key_for_jsonrpc_call, JsonRpcCall};
10//! use mfm_machine::ids::StateId;
11//!
12//! let state_id = StateId::must_new("machine.read.balance".to_string());
13//! let call = JsonRpcCall::new("eth_chainId", serde_json::json!([]));
14//! let key = fact_key_for_jsonrpc_call(&state_id, &call).expect("fact key");
15//!
16//! assert!(key.0.starts_with("mfm:evm|state:"));
17//! ```
18#![warn(missing_docs)]
19
20use serde::{Deserialize, Serialize};
21
22use mfm_machine::errors::{ErrorCategory, ErrorInfo, IoError};
23use mfm_machine::hashing::{artifact_id_for_json, CanonicalJsonError};
24use mfm_machine::ids::{ErrorCode, FactKey, StateId};
25use mfm_machine::io::{IoCall, IoProvider, IoResult};
26
27/// Canonical namespace used for EVM JSON-RPC `IoCall`s.
28pub const NAMESPACE_EVM: &str = "evm";
29
30/// Minimal JSON-RPC request shape used by the EVM IO helpers.
31#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
32pub struct JsonRpcCall {
33    /// JSON-RPC method name such as `eth_call`.
34    pub method: String,
35    /// JSON-RPC params array or object payload.
36    pub params: serde_json::Value,
37}
38
39impl JsonRpcCall {
40    /// Creates a new JSON-RPC request wrapper.
41    pub fn new(method: impl Into<String>, params: serde_json::Value) -> Self {
42        Self {
43            method: method.into(),
44            params,
45        }
46    }
47}
48
49/// Error returned when a fact key cannot be derived from a request.
50#[derive(Clone, Debug, PartialEq, Eq)]
51pub enum FactKeyDerivationError {
52    /// The request could not be canonically hashed for fact recording.
53    NotCanonical(CanonicalJsonError),
54}
55
56impl std::fmt::Display for FactKeyDerivationError {
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        match self {
59            FactKeyDerivationError::NotCanonical(e) => write!(f, "request not canonical: {e}"),
60        }
61    }
62}
63
64impl std::error::Error for FactKeyDerivationError {}
65
66/// Derives a deterministic fact key for an EVM JSON-RPC request.
67pub fn fact_key_for_jsonrpc_call(
68    state_id: &StateId,
69    call: &JsonRpcCall,
70) -> Result<FactKey, FactKeyDerivationError> {
71    let request =
72        serde_json::to_value(call).expect("JsonRpcCall must be serializable to serde_json::Value");
73    let req_id = artifact_id_for_json(&request).map_err(FactKeyDerivationError::NotCanonical)?;
74    Ok(FactKey(format!(
75        "mfm:evm|state:{}|req:{}",
76        state_id.as_str(),
77        req_id.0
78    )))
79}
80
81/// Wraps a typed JSON-RPC call in the generic `IoCall` envelope.
82pub fn evm_io_call(call: JsonRpcCall, fact_key: FactKey) -> IoCall {
83    let request =
84        serde_json::to_value(call).expect("JsonRpcCall must be serializable to serde_json::Value");
85    IoCall {
86        namespace: NAMESPACE_EVM.to_string(),
87        request,
88        fact_key: Some(fact_key),
89    }
90}
91
92/// Error returned while parsing hex-encoded numeric responses.
93#[derive(Clone, Debug, PartialEq, Eq)]
94pub enum ParseHexError {
95    /// The string did not start with `0x`.
96    MissingPrefix,
97    /// The string was `0x` with no digits.
98    Empty,
99    /// The value was not valid hexadecimal.
100    Invalid,
101    /// The value could not fit in `u64`.
102    Overflow,
103}
104
105impl std::fmt::Display for ParseHexError {
106    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
107        let s = match self {
108            ParseHexError::MissingPrefix => "missing 0x prefix",
109            ParseHexError::Empty => "empty hex string",
110            ParseHexError::Invalid => "invalid hex",
111            ParseHexError::Overflow => "hex value overflowed u64",
112        };
113        write!(f, "{s}")
114    }
115}
116
117impl std::error::Error for ParseHexError {}
118
119/// Parses a `0x`-prefixed quantity string into `u64`.
120pub fn parse_u64_hex(s: &str) -> Result<u64, ParseHexError> {
121    let Some(rest) = s.strip_prefix("0x") else {
122        return Err(ParseHexError::MissingPrefix);
123    };
124    if rest.is_empty() {
125        return Err(ParseHexError::Empty);
126    }
127    if rest.len() > 16 {
128        return Err(ParseHexError::Overflow);
129    }
130    u64::from_str_radix(rest, 16).map_err(|_| ParseHexError::Invalid)
131}
132
133/// Parses a JSON value that should contain a `0x`-prefixed quantity string.
134pub fn parse_u64_hex_value(v: &serde_json::Value) -> Result<u64, ParseHexError> {
135    let Some(s) = v.as_str() else {
136        return Err(ParseHexError::Invalid);
137    };
138    parse_u64_hex(s)
139}
140
141fn info(code: &'static str, category: ErrorCategory, message: &'static str) -> ErrorInfo {
142    ErrorInfo {
143        code: ErrorCode(code.to_string()),
144        category,
145        retryable: false,
146        message: message.to_string(),
147        details: None,
148    }
149}
150
151fn io_other(code: &'static str, category: ErrorCategory, message: &'static str) -> IoError {
152    IoError::Other(info(code, category, message))
153}
154
155/// First-class EVM client wrapper over `IoProvider`.
156///
157/// States should use this instead of hand-rolling JSON-RPC requests.
158pub struct EvmIoClient<'a> {
159    state_id: StateId,
160    io: &'a mut dyn IoProvider,
161}
162
163impl<'a> EvmIoClient<'a> {
164    /// Creates a new client for the given state and IO provider.
165    pub fn new(state_id: StateId, io: &'a mut dyn IoProvider) -> Self {
166        Self { state_id, io }
167    }
168
169    /// Returns the state identifier used when deriving fact keys.
170    pub fn state_id(&self) -> &StateId {
171        &self.state_id
172    }
173
174    /// Returns the underlying IO provider.
175    pub fn io_mut(&mut self) -> &mut dyn IoProvider {
176        self.io
177    }
178
179    /// Executes a JSON-RPC call through the generic IO provider.
180    pub async fn call(&mut self, call: JsonRpcCall) -> Result<IoResult, IoError> {
181        let key = fact_key_for_jsonrpc_call(&self.state_id, &call).map_err(|e| match e {
182            FactKeyDerivationError::NotCanonical(CanonicalJsonError::FloatNotAllowed) => io_other(
183                "evm_request_not_canonical",
184                ErrorCategory::ParsingInput,
185                "evm request was not canonical-json-hashable (floats are forbidden)",
186            ),
187            FactKeyDerivationError::NotCanonical(CanonicalJsonError::SecretsNotAllowed) => {
188                io_other(
189                    "secrets_detected",
190                    ErrorCategory::Unknown,
191                    "evm request contained secrets (policy forbids persisting secrets)",
192                )
193            }
194        })?;
195
196        self.io.call(evm_io_call(call, key)).await
197    }
198
199    /// Fetches the remote chain ID and parses it as `u64`.
200    pub async fn chain_id_u64(&mut self) -> Result<u64, IoError> {
201        let res = self
202            .call(JsonRpcCall::new("eth_chainId", serde_json::json!([])))
203            .await?;
204        parse_u64_hex_value(&res.response).map_err(|_| {
205            io_other(
206                "evm_response_invalid",
207                ErrorCategory::ParsingInput,
208                "evm response was not a hex u64",
209            )
210        })
211    }
212
213    /// Fetches the latest block number and parses it as `u64`.
214    pub async fn block_number_u64(&mut self) -> Result<u64, IoError> {
215        let res = self
216            .call(JsonRpcCall::new("eth_blockNumber", serde_json::json!([])))
217            .await?;
218        parse_u64_hex_value(&res.response).map_err(|_| {
219            io_other(
220                "evm_response_invalid",
221                ErrorCategory::ParsingInput,
222                "evm response was not a hex u64",
223            )
224        })
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use async_trait::async_trait;
232    use mfm_machine::ids::{ArtifactId, FactKey};
233
234    #[test]
235    fn fact_key_is_stable_for_same_call() {
236        let sid = StateId::must_new("m.main.chain_id".to_string());
237        let call = JsonRpcCall::new("eth_chainId", serde_json::json!([]));
238
239        let k1 = fact_key_for_jsonrpc_call(&sid, &call).expect("key");
240        let k2 = fact_key_for_jsonrpc_call(&sid, &call).expect("key");
241        assert_eq!(k1, k2);
242    }
243
244    #[test]
245    fn parse_u64_hex_basic() {
246        assert_eq!(parse_u64_hex("0x0").unwrap(), 0);
247        assert_eq!(parse_u64_hex("0x1").unwrap(), 1);
248        assert_eq!(parse_u64_hex("0x7b").unwrap(), 123);
249    }
250
251    #[test]
252    fn parse_u64_hex_rejects_bad_inputs() {
253        assert_eq!(
254            parse_u64_hex("1").unwrap_err(),
255            ParseHexError::MissingPrefix
256        );
257        assert_eq!(parse_u64_hex("0x").unwrap_err(), ParseHexError::Empty);
258        assert_eq!(parse_u64_hex("0xzz").unwrap_err(), ParseHexError::Invalid);
259        assert_eq!(
260            parse_u64_hex("0x0123456789abcdef0").unwrap_err(),
261            ParseHexError::Overflow
262        );
263    }
264
265    struct FixedIo {
266        response: serde_json::Value,
267    }
268
269    #[async_trait]
270    impl IoProvider for FixedIo {
271        async fn call(&mut self, _call: IoCall) -> Result<IoResult, IoError> {
272            Ok(IoResult {
273                response: self.response.clone(),
274                recorded_payload_id: None,
275            })
276        }
277
278        async fn record_value(
279            &mut self,
280            _key: FactKey,
281            _value: serde_json::Value,
282        ) -> Result<ArtifactId, IoError> {
283            Ok(ArtifactId("0".repeat(64)))
284        }
285
286        async fn get_recorded_fact(
287            &mut self,
288            _key: &FactKey,
289        ) -> Result<Option<ArtifactId>, IoError> {
290            Ok(None)
291        }
292
293        async fn now_millis(&mut self) -> Result<u64, IoError> {
294            Ok(0)
295        }
296
297        async fn random_bytes(&mut self, _n: usize) -> Result<Vec<u8>, IoError> {
298            Ok(Vec::new())
299        }
300    }
301
302    #[tokio::test]
303    async fn evm_client_parses_chain_id() {
304        let mut io = FixedIo {
305            response: serde_json::json!("0x1"),
306        };
307        let mut client =
308            EvmIoClient::new(StateId::must_new("m.main.chain_id".to_string()), &mut io);
309        let id = client.chain_id_u64().await.expect("chain id");
310        assert_eq!(id, 1);
311    }
312}