Skip to main content

nautilus_blockchain/rpc/
http.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16use std::{collections::HashMap, fmt::Debug, net::Ipv4Addr, num::NonZeroU32, str::FromStr};
17
18use alloy::primitives::{Address, B256, Bytes, U256};
19use bytes::Bytes as HttpBytes;
20use nautilus_core::{hex, string::secret::REDACTED};
21use nautilus_model::defi::rpc::{RpcLog, RpcNodeHttpResponse};
22use nautilus_network::{
23    http::{
24        HttpClient, HttpClientError, HttpRedirectPolicy, Method, Url,
25        create_standard_nautilus_headers,
26    },
27    ratelimiter::quota::Quota,
28};
29use serde::de::DeserializeOwned;
30
31#[cfg(feature = "hypersync")]
32use crate::rpc::types::{RpcCallResult, RpcCallTrace, RpcTransaction};
33use crate::rpc::{
34    error::{BlockchainRpcClientError, BroadcastError},
35    types::{RpcBlock, RpcBlockResponse, RpcTransactionReceipt},
36};
37
38/// Per-request timeout for execution RPC calls, in seconds.
39pub const EXECUTION_RPC_TIMEOUT_SECS: u64 = 10;
40
41/// Client for making HTTP-based RPC requests to blockchain nodes.
42///
43/// This client is designed to interact with Ethereum-compatible blockchain networks, providing
44/// methods to execute RPC calls and handle responses in a type-safe manner.
45pub struct BlockchainHttpRpcClient {
46    /// The HTTP URL for the blockchain node's RPC endpoint.
47    http_rpc_url: String,
48    /// The HTTP client for making RPC http-based requests.
49    http_client: HttpClient,
50}
51
52impl Debug for BlockchainHttpRpcClient {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        f.debug_struct(stringify!(BlockchainHttpRpcClient))
55            .field("http_rpc_url", &REDACTED)
56            .field("http_client", &self.http_client)
57            .finish()
58    }
59}
60
61impl BlockchainHttpRpcClient {
62    /// Creates a new HTTP RPC client with the given endpoint URL and optional rate limit.
63    ///
64    /// If `rpc_request_per_second` is `Some(0)` or an invalid value, rate limiting is disabled.
65    ///
66    /// # Panics
67    ///
68    /// Panics if the internal HTTP client cannot be created.
69    #[must_use]
70    pub fn new(
71        http_rpc_url: String,
72        rpc_request_per_second: Option<u32>,
73        proxy_url: Option<String>,
74    ) -> Self {
75        let default_quota =
76            rpc_request_per_second.and_then(|rps| Quota::per_second(NonZeroU32::new(rps)?));
77        let use_system_proxy = !is_canonical_loopback_endpoint(&http_rpc_url);
78        let proxy_url = if use_system_proxy { proxy_url } else { None };
79        let http_client = HttpClient::builder()
80            .headers(create_standard_nautilus_headers().into_iter().collect())
81            .maybe_default_quota(default_quota)
82            .maybe_proxy_url(proxy_url)
83            .redirect_policy(HttpRedirectPolicy::Reject)
84            .use_system_proxy(use_system_proxy)
85            .build()
86            .expect("Failed to create HTTP client");
87        Self {
88            http_rpc_url,
89            http_client,
90        }
91    }
92
93    /// Generic method that sends a JSON-RPC request and returns the raw response in bytes.
94    async fn send_rpc_request(
95        &self,
96        rpc_request: serde_json::Value,
97        timeout_secs: Option<u64>,
98    ) -> Result<HttpBytes, BlockchainRpcClientError> {
99        let body_bytes = serde_json::to_vec(&rpc_request).map_err(|e| {
100            BlockchainRpcClientError::ClientError(format!("Failed to serialize request: {e}"))
101        })?;
102
103        self.post_json_body(body_bytes, timeout_secs)
104            .await
105            .map_err(|e| BlockchainRpcClientError::ClientError(e.to_string()))
106    }
107
108    async fn post_json_body(
109        &self,
110        body_bytes: Vec<u8>,
111        timeout_secs: Option<u64>,
112    ) -> Result<HttpBytes, HttpClientError> {
113        let mut headers = HashMap::new();
114        headers.insert("Content-Type".to_string(), "application/json".to_string());
115
116        let response = self
117            .http_client
118            .request_with_url_redacted(
119                Method::POST,
120                self.http_rpc_url.clone(),
121                None,
122                Some(headers),
123                Some(body_bytes),
124                timeout_secs,
125                None,
126            )
127            .await?;
128
129        if response.status.is_redirection() {
130            return Err(HttpClientError::Error(
131                "redirect response rejected".to_string(),
132            ));
133        }
134
135        Ok(response.body)
136    }
137
138    /// Executes an Ethereum JSON-RPC call and deserializes the response into the specified type T.
139    ///
140    /// # Errors
141    ///
142    /// Returns an error if the HTTP RPC request fails or the response cannot be parsed.
143    pub async fn execute_rpc_call<T: DeserializeOwned>(
144        &self,
145        rpc_request: serde_json::Value,
146    ) -> anyhow::Result<T> {
147        self.execute_rpc_call_with_timeout(rpc_request, None).await
148    }
149
150    /// Executes an Ethereum JSON-RPC call with an optional per-request timeout and deserializes
151    /// the response into the specified type T.
152    ///
153    /// # Errors
154    ///
155    /// Returns an error if the HTTP RPC request fails or the response cannot be parsed.
156    pub async fn execute_rpc_call_with_timeout<T: DeserializeOwned>(
157        &self,
158        rpc_request: serde_json::Value,
159        timeout_secs: Option<u64>,
160    ) -> anyhow::Result<T> {
161        let bytes = self
162            .send_rpc_request(rpc_request, timeout_secs)
163            .await
164            .map_err(|e| anyhow::anyhow!("Failed to execute eth call RPC request: {e}"))?;
165        let parsed =
166            serde_json::from_slice::<RpcNodeHttpResponse<T>>(bytes.as_ref()).map_err(|e| {
167                let raw_response = String::from_utf8_lossy(bytes.as_ref());
168                let preview = rpc_response_preview(&raw_response);
169                anyhow::anyhow!("Failed to parse eth call response: {e}\nRaw response: {preview}")
170            })?;
171
172        // Check for non-standard rate limit error (e.g., Infura)
173        // These responses have code/message at top level without jsonrpc field
174        if parsed.jsonrpc.is_none()
175            && let (Some(code), Some(message)) = (parsed.code, parsed.message)
176        {
177            anyhow::bail!("RPC provider error {code}: {message}");
178        }
179
180        if let Some(error) = parsed.error {
181            anyhow::bail!("RPC error {}: {}", error.code, error.message);
182        }
183
184        parsed
185            .result
186            .ok_or_else(|| anyhow::anyhow!("Response missing both result and error fields"))
187    }
188
189    /// Creates a properly formatted `eth_call` JSON-RPC request object targeting a specific contract address with encoded function data.
190    #[must_use]
191    pub fn construct_eth_call(
192        &self,
193        to: &str,
194        call_data: &[u8],
195        block: Option<u64>,
196    ) -> serde_json::Value {
197        self.construct_eth_call_request(None, to, call_data, block)
198    }
199
200    fn construct_eth_call_request(
201        &self,
202        from: Option<&Address>,
203        to: &str,
204        call_data: &[u8],
205        block: Option<u64>,
206    ) -> serde_json::Value {
207        let encoded_data = hex::encode_prefixed(call_data);
208        let mut call = serde_json::json!({
209            "to": to,
210            "data": encoded_data
211        });
212
213        if let Some(from) = from {
214            call["from"] = serde_json::Value::String(from.to_string());
215        }
216
217        let block_param = block_parameter(block);
218
219        serde_json::json!({
220            "jsonrpc": "2.0",
221            "id": 1,
222            "method": "eth_call",
223            "params": [call, block_param]
224        })
225    }
226
227    /// Retrieves the balance of the specified Ethereum address at the given block.
228    ///
229    /// # Errors
230    ///
231    /// Returns an error if the RPC call fails or if the returned balance string cannot be parsed as a valid U256.
232    pub async fn get_balance(&self, address: &Address, block: Option<u64>) -> anyhow::Result<U256> {
233        self.get_balance_with_timeout(address, block, None).await
234    }
235
236    /// Retrieves the balance of the specified Ethereum address at the given block with an
237    /// optional per-request timeout.
238    ///
239    /// # Errors
240    ///
241    /// Returns an error if the RPC call fails or if the returned balance string cannot be parsed as a valid U256.
242    pub async fn get_balance_with_timeout(
243        &self,
244        address: &Address,
245        block: Option<u64>,
246        timeout_secs: Option<u64>,
247    ) -> anyhow::Result<U256> {
248        let block_param = block_parameter(block);
249
250        let request = serde_json::json!({
251            "jsonrpc": "2.0",
252            "id": 1,
253            "method": "eth_getBalance",
254            "params": [address, block_param]
255        });
256        let hex_string: String = self
257            .execute_rpc_call_with_timeout(request, timeout_secs)
258            .await?;
259
260        U256::from_str(&hex_string)
261            .map_err(|e| anyhow::anyhow!("Failed to parse balance hex string '{hex_string}': {e}"))
262    }
263
264    /// Retrieves logs matching the given filter criteria.
265    ///
266    /// This method calls the `eth_getLogs` RPC method to fetch event logs from the blockchain.
267    /// It's commonly used for querying historical events like token transfers, swaps, etc.
268    ///
269    /// # Errors
270    ///
271    /// Returns an error if the RPC call fails or the response cannot be parsed.
272    pub async fn get_logs(
273        &self,
274        address: Option<&Address>,
275        topics: Option<Vec<Option<String>>>,
276        from_block: u64,
277        to_block: u64,
278    ) -> anyhow::Result<Vec<RpcLog>> {
279        let mut filter = serde_json::Map::new();
280
281        filter.insert(
282            "fromBlock".to_string(),
283            serde_json::json!(format!("0x{:x}", from_block)),
284        );
285        filter.insert(
286            "toBlock".to_string(),
287            serde_json::json!(format!("0x{:x}", to_block)),
288        );
289
290        if let Some(addr) = address {
291            filter.insert(
292                "address".to_string(),
293                serde_json::json!(format!("{:?}", addr)),
294            );
295        }
296
297        if let Some(topics) = topics {
298            filter.insert("topics".to_string(), serde_json::json!(topics));
299        }
300
301        let request = serde_json::json!({
302            "jsonrpc": "2.0",
303            "id": 1,
304            "method": "eth_getLogs",
305            "params": [filter]
306        });
307
308        self.execute_rpc_call(request).await
309    }
310
311    /// Executes an execution-path JSON-RPC call with a per-request timeout and sanitized errors.
312    ///
313    /// A `null` result maps to `Ok(None)`, which is a legitimate pending response (for example a
314    /// receipt that does not exist yet), not an error. Errors carry the RPC method and numeric
315    /// code only, never the endpoint URL or request payload.
316    async fn execute_execution_rpc_call<T: DeserializeOwned>(
317        &self,
318        method: &'static str,
319        params: serde_json::Value,
320    ) -> anyhow::Result<Option<T>> {
321        let request = serde_json::json!({
322            "jsonrpc": "2.0",
323            "id": 1,
324            "method": method,
325            "params": params,
326        });
327
328        let bytes = self
329            .send_rpc_request(request, Some(EXECUTION_RPC_TIMEOUT_SECS))
330            .await
331            .map_err(|e| match e {
332                BlockchainRpcClientError::ClientError(message)
333                    if message.contains("redirect response rejected") =>
334                {
335                    anyhow::anyhow!("{method} redirect rejected")
336                }
337                _ => anyhow::anyhow!("{method} request failed"),
338            })?;
339
340        let parsed = serde_json::from_slice::<RpcNodeHttpResponse<T>>(bytes.as_ref())
341            .map_err(|_| anyhow::anyhow!("Failed to parse {method} response"))?;
342
343        if parsed.jsonrpc.is_none()
344            && let (Some(code), Some(_message)) = (parsed.code, parsed.message)
345        {
346            anyhow::bail!("{method} RPC error {code}");
347        }
348
349        if let Some(error) = parsed.error {
350            anyhow::bail!("{method} RPC error {}", error.code);
351        }
352
353        Ok(parsed.result)
354    }
355
356    /// Returns the chain ID reported by the RPC node via `eth_chainId`.
357    ///
358    /// # Errors
359    ///
360    /// Returns an error if the RPC call fails or the result is missing or malformed.
361    pub async fn chain_id(&self) -> anyhow::Result<u64> {
362        let result: Option<String> = self
363            .execute_execution_rpc_call("eth_chainId", serde_json::json!([]))
364            .await?;
365        parse_hex_quantity_result("eth_chainId", result)
366            .and_then(|v| u64::try_from(v).map_err(Into::into))
367    }
368
369    /// Returns the deployed bytecode at the given address via `eth_getCode` at the latest block.
370    ///
371    /// An address with no deployed contract returns empty bytes.
372    ///
373    /// # Errors
374    ///
375    /// Returns an error if the RPC call fails or the result is missing or malformed.
376    pub async fn get_code(&self, address: &Address) -> anyhow::Result<Bytes> {
377        self.get_code_with_block(address, None).await
378    }
379
380    #[cfg(feature = "hypersync")]
381    pub(crate) async fn get_code_at(&self, address: &Address, block: u64) -> anyhow::Result<Bytes> {
382        self.get_code_with_block(address, Some(block)).await
383    }
384
385    /// Returns the 32-byte storage value at `slot` for `address` at an explicit block.
386    ///
387    /// # Errors
388    ///
389    /// Returns an error if the RPC call fails or the result is missing or malformed.
390    #[cfg(feature = "hypersync")]
391    #[allow(
392        dead_code,
393        reason = "Used by the independent verification read inventory"
394    )]
395    pub(crate) async fn get_storage_at(
396        &self,
397        address: &Address,
398        slot: &B256,
399        block: u64,
400    ) -> anyhow::Result<B256> {
401        let result: Option<String> = self
402            .execute_execution_rpc_call(
403                "eth_getStorageAt",
404                serde_json::json!([address, slot, block_parameter(Some(block))]),
405            )
406            .await?;
407        let value = result.ok_or_else(|| anyhow::anyhow!("eth_getStorageAt returned no result"))?;
408        B256::from_str(&value)
409            .map_err(|_| anyhow::anyhow!("Failed to parse eth_getStorageAt response"))
410    }
411
412    async fn get_code_with_block(
413        &self,
414        address: &Address,
415        block: Option<u64>,
416    ) -> anyhow::Result<Bytes> {
417        let result: Option<String> = self
418            .execute_execution_rpc_call(
419                "eth_getCode",
420                serde_json::json!([address, block_parameter(block)]),
421            )
422            .await?;
423        let hex_string = result.ok_or_else(|| anyhow::anyhow!("eth_getCode returned no result"))?;
424        let stripped = hex_string.strip_prefix("0x").unwrap_or(&hex_string);
425        let bytes = hex::decode(stripped)
426            .map_err(|e| anyhow::anyhow!("Failed to decode eth_getCode result: {e}"))?;
427        Ok(Bytes::from(bytes))
428    }
429
430    /// Returns the next nonce for the given address via `eth_getTransactionCount`
431    /// with the `pending` tag, making the pending pool state authoritative.
432    ///
433    /// # Errors
434    ///
435    /// Returns an error if the RPC call fails or the result is missing or malformed.
436    pub async fn get_transaction_count_pending(&self, address: &Address) -> anyhow::Result<u64> {
437        let result: Option<String> = self
438            .execute_execution_rpc_call(
439                "eth_getTransactionCount",
440                serde_json::json!([address, "pending"]),
441            )
442            .await?;
443        parse_hex_quantity_result("eth_getTransactionCount", result)
444            .and_then(|v| u64::try_from(v).map_err(Into::into))
445    }
446
447    /// Returns the latest mined nonce for an address.
448    ///
449    /// # Errors
450    ///
451    /// Returns an error if the RPC call fails or the result is missing or malformed.
452    pub async fn get_transaction_count_latest(&self, address: &Address) -> anyhow::Result<u64> {
453        let result: Option<String> = self
454            .execute_execution_rpc_call(
455                "eth_getTransactionCount",
456                serde_json::json!([address, "latest"]),
457            )
458            .await?;
459        parse_hex_quantity_result("eth_getTransactionCount", result)
460            .and_then(|v| u64::try_from(v).map_err(Into::into))
461    }
462
463    /// Returns the transaction count for an address at an explicit block.
464    ///
465    /// # Errors
466    ///
467    /// Returns an error if the RPC call fails or the result is missing or malformed.
468    #[cfg(feature = "hypersync")]
469    #[allow(
470        dead_code,
471        reason = "Used by the independent verification read inventory"
472    )]
473    pub(crate) async fn get_transaction_count_at(
474        &self,
475        address: &Address,
476        block: u64,
477    ) -> anyhow::Result<u64> {
478        let result: Option<String> = self
479            .execute_execution_rpc_call(
480                "eth_getTransactionCount",
481                serde_json::json!([address, block_parameter(Some(block))]),
482            )
483            .await?;
484        parse_hex_quantity_result("eth_getTransactionCount", result)
485            .and_then(|v| u64::try_from(v).map_err(Into::into))
486    }
487
488    /// Executes an `eth_call` at an explicit block and returns its raw output bytes.
489    ///
490    /// # Errors
491    ///
492    /// Returns an error if the RPC call fails or the result is missing or malformed.
493    #[cfg(feature = "hypersync")]
494    #[allow(
495        dead_code,
496        reason = "Used by the independent verification read inventory"
497    )]
498    pub(crate) async fn call_at(
499        &self,
500        from: Option<&Address>,
501        to: &Address,
502        value: U256,
503        data: &[u8],
504        block: u64,
505    ) -> anyhow::Result<Bytes> {
506        match self.call_result_at(from, to, value, data, block).await? {
507            RpcCallResult::Success(bytes) => Ok(bytes),
508            RpcCallResult::Reverted => anyhow::bail!("eth_call execution reverted"),
509        }
510    }
511
512    /// Executes an explicit-height `eth_call`, preserving a recognized EVM revert as data.
513    #[cfg(feature = "hypersync")]
514    pub(crate) async fn call_result_at(
515        &self,
516        from: Option<&Address>,
517        to: &Address,
518        value: U256,
519        data: &[u8],
520        block: u64,
521    ) -> anyhow::Result<RpcCallResult> {
522        let mut call = serde_json::json!({
523            "to": to,
524            "value": format!("0x{value:x}"),
525            "data": hex::encode_prefixed(data),
526        });
527
528        if let Some(from) = from {
529            call["from"] = serde_json::json!(from);
530        }
531        let request = serde_json::json!({
532            "jsonrpc": "2.0",
533            "id": 1,
534            "method": "eth_call",
535            "params": [call, block_parameter(Some(block))],
536        });
537        let bytes = self
538            .send_rpc_request(request, Some(EXECUTION_RPC_TIMEOUT_SECS))
539            .await
540            .map_err(|e| match e {
541                BlockchainRpcClientError::ClientError(message)
542                    if message.contains("redirect response rejected") =>
543                {
544                    anyhow::anyhow!("eth_call redirect rejected")
545                }
546                _ => anyhow::anyhow!("eth_call request failed"),
547            })?;
548        let parsed = serde_json::from_slice::<RpcNodeHttpResponse<String>>(bytes.as_ref())
549            .map_err(|_| anyhow::anyhow!("Failed to parse eth_call response"))?;
550
551        if parsed.jsonrpc.is_none()
552            && let (Some(code), Some(message)) = (parsed.code, parsed.message)
553        {
554            if eth_call_error_is_revert(code, &message) {
555                return Ok(RpcCallResult::Reverted);
556            }
557            anyhow::bail!("eth_call RPC error {code}");
558        }
559
560        if let Some(error) = parsed.error {
561            if eth_call_error_is_revert(error.code, &error.message) {
562                return Ok(RpcCallResult::Reverted);
563            }
564            anyhow::bail!("eth_call RPC error {}", error.code);
565        }
566        let value = parsed
567            .result
568            .ok_or_else(|| anyhow::anyhow!("eth_call returned no result"))?;
569        let stripped = value.strip_prefix("0x").unwrap_or(&value);
570        let bytes = hex::decode(stripped)
571            .map_err(|_| anyhow::anyhow!("Failed to decode eth_call response"))?;
572        Ok(RpcCallResult::Success(Bytes::from(bytes)))
573    }
574
575    /// Estimates the gas required for a transaction via `eth_estimateGas`.
576    ///
577    /// # Errors
578    ///
579    /// Returns an error if the RPC call fails (including node-side revert of the simulated
580    /// transaction) or the result is missing or malformed.
581    pub async fn estimate_gas(
582        &self,
583        from: &Address,
584        to: &Address,
585        value: U256,
586        data: &[u8],
587    ) -> anyhow::Result<u64> {
588        self.estimate_gas_with_block(from, to, value, data, None)
589            .await
590    }
591
592    #[cfg(feature = "hypersync")]
593    pub(crate) async fn estimate_gas_at(
594        &self,
595        from: &Address,
596        to: &Address,
597        value: U256,
598        data: &[u8],
599        block: u64,
600    ) -> anyhow::Result<u64> {
601        self.estimate_gas_with_block(from, to, value, data, Some(block))
602            .await
603    }
604
605    async fn estimate_gas_with_block(
606        &self,
607        from: &Address,
608        to: &Address,
609        value: U256,
610        data: &[u8],
611        block: Option<u64>,
612    ) -> anyhow::Result<u64> {
613        let call = serde_json::json!({
614            "from": from,
615            "to": to,
616            "value": format!("0x{value:x}"),
617            "data": hex::encode_prefixed(data),
618        });
619        let params = match block {
620            Some(block) => serde_json::json!([call, block_parameter(Some(block))]),
621            None => serde_json::json!([call]),
622        };
623        let result: Option<String> = self
624            .execute_execution_rpc_call("eth_estimateGas", params)
625            .await?;
626        parse_hex_quantity_result("eth_estimateGas", result)
627            .and_then(|v| u64::try_from(v).map_err(Into::into))
628    }
629
630    /// Returns the node's suggested max priority fee per gas in wei
631    /// via `eth_maxPriorityFeePerGas`.
632    ///
633    /// # Errors
634    ///
635    /// Returns an error if the RPC call fails or the result is missing or malformed.
636    pub async fn max_priority_fee_per_gas(&self) -> anyhow::Result<u128> {
637        let result: Option<String> = self
638            .execute_execution_rpc_call("eth_maxPriorityFeePerGas", serde_json::json!([]))
639            .await?;
640        parse_hex_quantity_result("eth_maxPriorityFeePerGas", result)
641    }
642
643    /// Returns the latest block via `eth_getBlockByNumber` with the `latest` tag.
644    ///
645    /// # Errors
646    ///
647    /// Returns an error if the RPC call fails or the result is missing or malformed.
648    pub async fn latest_block(&self) -> anyhow::Result<RpcBlock> {
649        self.block_by_tag("latest", false).await
650    }
651
652    /// Returns the consensus finalized block.
653    ///
654    /// # Errors
655    ///
656    /// Returns an error if the endpoint does not support the `finalized` tag, the RPC call
657    /// fails, or the result is missing or malformed.
658    pub async fn finalized_block(&self) -> anyhow::Result<RpcBlock> {
659        self.block_by_tag("finalized", false).await.map_err(|e| {
660            anyhow::anyhow!(
661                "Failed to read the consensus finalized block; the execution endpoint must support the finalized tag: {e}"
662            )
663        })
664    }
665
666    /// Returns a numbered canonical block, optionally with full transactions.
667    ///
668    /// # Errors
669    ///
670    /// Returns an error if the RPC call fails or the result is missing or malformed.
671    pub async fn block_by_number(
672        &self,
673        number: u64,
674        full_transactions: bool,
675    ) -> anyhow::Result<RpcBlock> {
676        let block = self
677            .block_by_tag(&format!("0x{number:x}"), full_transactions)
678            .await?;
679        anyhow::ensure!(
680            block.number == number,
681            "eth_getBlockByNumber returned block {} for requested block {number}",
682            block.number
683        );
684        Ok(block)
685    }
686
687    async fn block_by_tag(&self, tag: &str, full_transactions: bool) -> anyhow::Result<RpcBlock> {
688        let result: Option<RpcBlockResponse> = self
689            .execute_execution_rpc_call(
690                "eth_getBlockByNumber",
691                serde_json::json!([tag, full_transactions]),
692            )
693            .await?;
694        let response = result.ok_or_else(|| {
695            anyhow::anyhow!("eth_getBlockByNumber returned no result for block tag {tag}")
696        })?;
697        let mut block = response.block;
698        if full_transactions {
699            block.transactions = response
700                .transactions
701                .into_iter()
702                .map(|transaction| {
703                    serde_json::from_value(transaction).map_err(|_| {
704                        anyhow::anyhow!(
705                            "Failed to parse full transaction in eth_getBlockByNumber response"
706                        )
707                    })
708                })
709                .collect::<anyhow::Result<_>>()?;
710        }
711        Ok(block)
712    }
713
714    /// Returns the receipt for the given transaction hash via `eth_getTransactionReceipt`.
715    ///
716    /// A `null` result maps to `Ok(None)`: the transaction is pending and no receipt
717    /// exists yet.
718    ///
719    /// # Errors
720    ///
721    /// Returns an error if the RPC call fails or the response is malformed.
722    pub async fn get_transaction_receipt(
723        &self,
724        tx_hash: &B256,
725    ) -> anyhow::Result<Option<RpcTransactionReceipt>> {
726        let receipt: Option<RpcTransactionReceipt> = self
727            .execute_execution_rpc_call("eth_getTransactionReceipt", serde_json::json!([tx_hash]))
728            .await?;
729
730        if receipt
731            .as_ref()
732            .is_some_and(|receipt| receipt.transaction_hash != *tx_hash)
733        {
734            anyhow::bail!(
735                "eth_getTransactionReceipt returned a receipt with a mismatched transaction hash"
736            );
737        }
738
739        Ok(receipt)
740    }
741
742    /// Returns a full transaction by hash.
743    ///
744    /// A `null` result maps to `Ok(None)` while the transaction is not available.
745    ///
746    /// # Errors
747    ///
748    /// Returns an error if the RPC call fails, the response is malformed, or the returned hash
749    /// does not match `tx_hash`.
750    #[cfg(feature = "hypersync")]
751    #[allow(
752        dead_code,
753        reason = "Used by the independent verification read inventory"
754    )]
755    pub(crate) async fn get_transaction_by_hash(
756        &self,
757        tx_hash: &B256,
758    ) -> anyhow::Result<Option<RpcTransaction>> {
759        let transaction: Option<RpcTransaction> = self
760            .execute_execution_rpc_call("eth_getTransactionByHash", serde_json::json!([tx_hash]))
761            .await?;
762
763        if transaction
764            .as_ref()
765            .is_some_and(|transaction| transaction.hash != *tx_hash)
766        {
767            anyhow::bail!("eth_getTransactionByHash returned a mismatched transaction hash");
768        }
769        Ok(transaction)
770    }
771
772    /// Returns the Geth `callTracer` tree for a transaction.
773    ///
774    /// # Errors
775    ///
776    /// Returns an error if the trace method is unavailable or the result is missing or malformed.
777    #[cfg(feature = "hypersync")]
778    #[allow(
779        dead_code,
780        reason = "Used by the independent verification read inventory"
781    )]
782    pub(crate) async fn trace_transaction_call(
783        &self,
784        tx_hash: &B256,
785    ) -> anyhow::Result<RpcCallTrace> {
786        let result: Option<RpcCallTrace> = self
787            .execute_execution_rpc_call(
788                "debug_traceTransaction",
789                serde_json::json!([
790                    tx_hash,
791                    {
792                        "tracer": "callTracer",
793                        "tracerConfig": {
794                            "onlyTopCall": false,
795                            "withLog": false,
796                        },
797                    }
798                ]),
799            )
800            .await?;
801        result.ok_or_else(|| anyhow::anyhow!("debug_traceTransaction returned no result"))
802    }
803
804    /// Probes whether the endpoint recognizes the configured `callTracer` request shape.
805    #[cfg(feature = "hypersync")]
806    pub(crate) async fn probe_call_trace(&self) -> anyhow::Result<()> {
807        let request = serde_json::json!({
808            "jsonrpc": "2.0",
809            "id": 1,
810            "method": "debug_traceTransaction",
811            "params": [
812                B256::ZERO,
813                {
814                    "tracer": "callTracer",
815                    "tracerConfig": {
816                        "onlyTopCall": false,
817                        "withLog": false,
818                    },
819                }
820            ],
821        });
822        let bytes = self
823            .send_rpc_request(request, Some(EXECUTION_RPC_TIMEOUT_SECS))
824            .await
825            .map_err(|e| match e {
826                BlockchainRpcClientError::ClientError(message)
827                    if message.contains("redirect response rejected") =>
828                {
829                    anyhow::anyhow!("debug_traceTransaction redirect rejected")
830                }
831                _ => anyhow::anyhow!("debug_traceTransaction request failed"),
832            })?;
833        let parsed =
834            serde_json::from_slice::<RpcNodeHttpResponse<serde_json::Value>>(bytes.as_ref())
835                .map_err(|_| anyhow::anyhow!("Failed to parse debug_traceTransaction response"))?;
836        if parsed.jsonrpc.is_none()
837            && let (Some(code), Some(_)) = (parsed.code, parsed.message)
838        {
839            return trace_probe_result(code);
840        }
841
842        if let Some(error) = parsed.error {
843            return trace_probe_result(error.code);
844        }
845        anyhow::ensure!(
846            parsed.result.is_some(),
847            "debug_traceTransaction returned no result"
848        );
849        Ok(())
850    }
851
852    /// Broadcasts a signed raw transaction via `eth_sendRawTransaction`.
853    ///
854    /// Broadcast failures classify before retry: an `already known` response is acceptance
855    /// (returns `expected_tx_hash`), and a timeout after sending is
856    /// [`BroadcastError::TimeoutAfterSend`] so the caller reconciles through the persisted
857    /// record rather than rebroadcasting.
858    ///
859    /// # Errors
860    ///
861    /// Returns a [`BroadcastError`] classifying the failure. Errors are sanitized: they carry
862    /// the numeric RPC code only, never the endpoint URL or the signed payload.
863    pub async fn send_raw_transaction(
864        &self,
865        raw_tx: &[u8],
866        expected_tx_hash: &B256,
867    ) -> Result<B256, BroadcastError> {
868        let request = serde_json::json!({
869            "jsonrpc": "2.0",
870            "id": 1,
871            "method": "eth_sendRawTransaction",
872            "params": [hex::encode_prefixed(raw_tx)],
873        });
874
875        let body_bytes = serde_json::to_vec(&request)
876            .map_err(|e| BroadcastError::Failed(format!("Failed to serialize request: {e}")))?;
877
878        let body = self
879            .post_json_body(body_bytes, Some(EXECUTION_RPC_TIMEOUT_SECS))
880            .await
881            .map_err(|e| classify_broadcast_transport_error(&e))?;
882
883        let parsed =
884            serde_json::from_slice::<RpcNodeHttpResponse<String>>(&body).map_err(|_| {
885                BroadcastError::Failed("Failed to parse broadcast response".to_string())
886            })?;
887
888        if let Some(error) = parsed.error {
889            let message = error.message.to_ascii_lowercase();
890            if message.contains("already known") {
891                log::warn!(
892                    "Broadcast returned 'already known' for transaction {expected_tx_hash}; treating as acceptance"
893                );
894                return Ok(*expected_tx_hash);
895            }
896
897            if message.contains("nonce too low") {
898                return Err(BroadcastError::Failed(format!(
899                    "node RPC error {} reported a consumed nonce",
900                    error.code
901                )));
902            }
903            return Err(BroadcastError::Rejected { code: error.code });
904        }
905
906        let hex_string = parsed
907            .result
908            .ok_or_else(|| BroadcastError::Failed("Broadcast returned no result".to_string()))?;
909
910        B256::from_str(&hex_string)
911            .map_err(|e| BroadcastError::Failed(format!("Failed to parse broadcast result: {e}")))
912    }
913}
914
915#[cfg(feature = "hypersync")]
916pub(crate) fn validate_execution_endpoint(
917    endpoint: &str,
918    description: &str,
919) -> anyhow::Result<Url> {
920    let url =
921        Url::parse(endpoint).map_err(|_| anyhow::anyhow!("Invalid {description} endpoint"))?;
922    anyhow::ensure!(
923        matches!(url.scheme(), "http" | "https"),
924        "{description} endpoint must use HTTPS or canonical loopback HTTP"
925    );
926    anyhow::ensure!(
927        url.host().is_some(),
928        "{description} endpoint host is required"
929    );
930    anyhow::ensure!(
931        url.fragment().is_none(),
932        "{description} endpoint fragments are unsupported"
933    );
934    anyhow::ensure!(
935        url.scheme() == "https" || is_canonical_loopback_endpoint(endpoint),
936        "{description} endpoint must use HTTPS unless its host is a canonical loopback IP literal"
937    );
938    Ok(url)
939}
940
941fn is_canonical_loopback_endpoint(endpoint: &str) -> bool {
942    let Some((scheme, rest)) = endpoint.split_once("://") else {
943        return false;
944    };
945
946    if !scheme.eq_ignore_ascii_case("http") && !scheme.eq_ignore_ascii_case("https") {
947        return false;
948    }
949    let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
950    let authority = &rest[..authority_end];
951    if authority.is_empty() || authority.contains('@') {
952        return false;
953    }
954
955    let raw_host = if let Some(suffix) = authority.strip_prefix("[::1]") {
956        if suffix.is_empty() || suffix.starts_with(':') {
957            return Url::parse(endpoint)
958                .ok()
959                .is_some_and(|url| url.host_str() == Some("[::1]"));
960        }
961        return false;
962    } else if authority.starts_with('[') {
963        return false;
964    } else {
965        authority
966            .rsplit_once(':')
967            .map_or(authority, |(host, _port)| host)
968    };
969
970    let Ok(address) = raw_host.parse::<Ipv4Addr>() else {
971        return false;
972    };
973    address.is_loopback()
974        && raw_host == address.to_string()
975        && Url::parse(endpoint).ok().is_some_and(|url| {
976            url.host_str()
977                .and_then(|host| host.parse::<Ipv4Addr>().ok())
978                == Some(address)
979        })
980}
981
982fn block_parameter(block: Option<u64>) -> serde_json::Value {
983    block.map_or_else(
984        || serde_json::json!("latest"),
985        |number| serde_json::json!(format!("0x{number:x}")),
986    )
987}
988
989#[cfg(feature = "hypersync")]
990fn eth_call_error_is_revert(code: i32, message: &str) -> bool {
991    code == 3 || message.to_ascii_lowercase().contains("revert")
992}
993
994#[cfg(feature = "hypersync")]
995fn trace_probe_result(code: i32) -> anyhow::Result<()> {
996    if matches!(code, -32_601 | -32_602) {
997        anyhow::bail!("debug_traceTransaction RPC error {code}");
998    }
999    Ok(())
1000}
1001
1002fn rpc_response_preview(raw_response: &str) -> String {
1003    if raw_response.len() <= 500 {
1004        return raw_response.to_string();
1005    }
1006
1007    let mut end = 500;
1008    while !raw_response.is_char_boundary(end) {
1009        end -= 1;
1010    }
1011    format!(
1012        "{}... (truncated, {} bytes total)",
1013        &raw_response[..end],
1014        raw_response.len()
1015    )
1016}
1017
1018/// Classifies a broadcast transport failure: a timeout after sending reconciles through the
1019/// persisted record. Any other transport failure is treated as ambiguous-after-send too, even
1020/// though some (for example connection refused) may never have reached the node; the HTTP
1021/// client does not expose the connect-phase distinction, so the conservative outcome never
1022/// risks a rebroadcast. Sanitized to never carry the endpoint URL or request payload.
1023fn classify_broadcast_transport_error(error: &HttpClientError) -> BroadcastError {
1024    match error {
1025        HttpClientError::TimeoutError(_) => BroadcastError::TimeoutAfterSend,
1026        _ => BroadcastError::Failed("transport error".to_string()),
1027    }
1028}
1029
1030/// Parses a required hex-quantity JSON-RPC result, erroring on a missing result.
1031fn parse_hex_quantity_result(method: &str, result: Option<String>) -> anyhow::Result<u128> {
1032    let hex_string = result.ok_or_else(|| anyhow::anyhow!("{method} returned no result"))?;
1033    let stripped = hex_string.strip_prefix("0x").unwrap_or(&hex_string);
1034    u128::from_str_radix(stripped, 16)
1035        .map_err(|e| anyhow::anyhow!("Failed to parse {method} result '{hex_string}': {e}"))
1036}
1037
1038#[cfg(test)]
1039pub(crate) mod tests {
1040    use alloy::primitives::{address, b256};
1041    use rstest::rstest;
1042
1043    use super::*;
1044
1045    #[rstest]
1046    fn rpc_response_preview_truncates_on_utf8_boundary() {
1047        let raw = format!("{}é", "a".repeat(499));
1048
1049        let preview = rpc_response_preview(&raw);
1050
1051        assert_eq!(
1052            preview,
1053            format!("{}... (truncated, 501 bytes total)", "a".repeat(499))
1054        );
1055    }
1056
1057    #[cfg(feature = "hypersync")]
1058    #[rstest]
1059    #[case("https://rpc.example.com/path?token=value")]
1060    #[case("https://127.0.0.1:8545")]
1061    #[case("https://[::1]:8545")]
1062    #[case("http://127.0.0.1")]
1063    #[case("http://127.255.255.254:8545/path")]
1064    #[case("http://[::1]:8545")]
1065    fn execution_endpoint_accepts_https_or_canonical_loopback(#[case] endpoint: &str) {
1066        let validated = validate_execution_endpoint(endpoint, "Test").unwrap();
1067
1068        assert_eq!(validated, Url::parse(endpoint).unwrap());
1069    }
1070
1071    #[cfg(feature = "hypersync")]
1072    #[rstest]
1073    #[case("http://rpc.example.com")]
1074    #[case("http://localhost:8545")]
1075    #[case("http://10.0.0.1:8545")]
1076    #[case("http://169.254.1.1:8545")]
1077    #[case("http://192.168.1.1:8545")]
1078    #[case("http://[::ffff:127.0.0.1]:8545")]
1079    #[case("http://127.1:8545")]
1080    #[case("http://0177.0.0.1:8545")]
1081    #[case("http://0x7f000001:8545")]
1082    #[case("http://2130706433:8545")]
1083    #[case("http://127.0.0.1.example.com:8545")]
1084    #[case("http://example.com@127.0.0.1:8545")]
1085    #[case("http://127.0.0.1.:8545")]
1086    fn execution_endpoint_rejects_noncanonical_cleartext(#[case] endpoint: &str) {
1087        let error = validate_execution_endpoint(endpoint, "Test").unwrap_err();
1088
1089        assert_eq!(
1090            error.to_string(),
1091            "Test endpoint must use HTTPS unless its host is a canonical loopback IP literal"
1092        );
1093    }
1094
1095    #[rstest]
1096    #[case("http://127.0.0.1:1")]
1097    #[case("https://127.0.0.1:1")]
1098    #[case("http://[::1]:1")]
1099    #[case("https://[::1]:1")]
1100    fn loopback_rpc_bypasses_configured_proxy(#[case] endpoint: &str) {
1101        let client = BlockchainHttpRpcClient::new(
1102            endpoint.to_string(),
1103            None,
1104            Some("not a valid proxy URL".to_string()),
1105        );
1106
1107        assert_eq!(client.http_rpc_url, endpoint);
1108    }
1109
1110    #[cfg(not(madsim))]
1111    #[rstest]
1112    fn loopback_rpc_bypasses_ambient_proxy() {
1113        let module = module_path!()
1114            .split_once("::")
1115            .expect("test module includes crate name")
1116            .1;
1117        let child_test = format!("{module}::loopback_rpc_bypasses_ambient_proxy_child");
1118        let status = std::process::Command::new(std::env::current_exe().unwrap())
1119            .args(["--exact", &child_test, "--ignored"])
1120            .env("BLOCKCHAIN_PROXY_CHILD", "1")
1121            .env("HTTP_PROXY", "http://127.0.0.1:9")
1122            .env("http_proxy", "http://127.0.0.1:9")
1123            .env_remove("NO_PROXY")
1124            .env_remove("no_proxy")
1125            .env_remove("ALL_PROXY")
1126            .env_remove("all_proxy")
1127            .status()
1128            .unwrap();
1129
1130        assert!(status.success(), "ambient proxy child failed: {status}");
1131    }
1132
1133    #[cfg(not(madsim))]
1134    #[ignore = "runs only in the isolated ambient-proxy child process"]
1135    #[tokio::test]
1136    async fn loopback_rpc_bypasses_ambient_proxy_child() {
1137        if std::env::var("BLOCKCHAIN_PROXY_CHILD").as_deref() != Ok("1") {
1138            return;
1139        }
1140        let (client, state) =
1141            client_for(MockRpcState::default().with_response("eth_chainId", CHAIN_ID_ARBITRUM))
1142                .await;
1143
1144        let chain_id = client.chain_id().await.unwrap();
1145
1146        assert_eq!(chain_id, 42_161);
1147        assert_eq!(state.recorded_requests().len(), 1);
1148    }
1149
1150    /// Mock JSON-RPC HTTP server for tests, serving canned responses from fixture files.
1151    pub(crate) mod mock {
1152        use std::{
1153            collections::{HashMap, VecDeque},
1154            net::SocketAddr,
1155            sync::Arc,
1156            time::Duration,
1157        };
1158
1159        use alloy::{consensus::TxEnvelope, eips::eip2718::Decodable2718, primitives::TxKind};
1160        use axum::{Router, extract::State, routing::post};
1161        use parking_lot::Mutex;
1162        use serde_json::Value;
1163
1164        type ResponseSequences<K> = Arc<Mutex<HashMap<K, VecDeque<String>>>>;
1165
1166        /// State for the mock JSON-RPC server: canned responses per method plus a request log.
1167        #[derive(Clone, Default)]
1168        pub(crate) struct MockRpcState {
1169            responses: HashMap<String, String>,
1170            parameter_responses: HashMap<(String, String), String>,
1171            parameter_response_sequences: ResponseSequences<(String, String)>,
1172            response_sequences: ResponseSequences<String>,
1173            call_responses: HashMap<String, String>,
1174            contract_call_responses: HashMap<(String, String), String>,
1175            call_response_sequences: ResponseSequences<String>,
1176            sleep_methods: HashMap<String, Duration>,
1177            response_releases: HashMap<String, Arc<tokio::sync::Semaphore>>,
1178            requests: Arc<Mutex<Vec<Value>>>,
1179            sent_raw_transaction: Arc<Mutex<Option<String>>>,
1180            receipt_hash_from_request: bool,
1181            send_raw_echo: bool,
1182        }
1183
1184        impl MockRpcState {
1185            /// Serves the given raw JSON-RPC response body for `method`.
1186            #[must_use]
1187            pub(crate) fn with_response(mut self, method: &str, response_json: &str) -> Self {
1188                self.responses
1189                    .insert(method.to_string(), response_json.to_string());
1190                self
1191            }
1192
1193            /// Serves a response for a method whose first parameter exactly matches `parameter`.
1194            #[cfg(feature = "hypersync")]
1195            #[must_use]
1196            pub(crate) fn with_parameter_response(
1197                mut self,
1198                method: &str,
1199                parameter: &str,
1200                response_json: &str,
1201            ) -> Self {
1202                self.parameter_responses.insert(
1203                    (method.to_string(), parameter.to_string()),
1204                    response_json.to_string(),
1205                );
1206                self
1207            }
1208
1209            /// Serves responses in order for a method whose first parameter exactly matches.
1210            #[cfg(feature = "hypersync")]
1211            #[must_use]
1212            pub(crate) fn with_parameter_response_sequence(
1213                self,
1214                method: &str,
1215                parameter: &str,
1216                responses: &[&str],
1217            ) -> Self {
1218                self.parameter_response_sequences.lock().insert(
1219                    (method.to_string(), parameter.to_string()),
1220                    responses.iter().map(ToString::to_string).collect(),
1221                );
1222                self
1223            }
1224
1225            /// Serves the given raw JSON-RPC response bodies for `method` in order.
1226            #[cfg(feature = "hypersync")]
1227            #[must_use]
1228            pub(crate) fn with_response_sequence(self, method: &str, responses: &[&str]) -> Self {
1229                self.response_sequences.lock().insert(
1230                    method.to_string(),
1231                    responses.iter().map(ToString::to_string).collect(),
1232                );
1233                self
1234            }
1235
1236            /// Rewrites receipt fixture hashes to the hash requested by each poll.
1237            #[cfg(feature = "hypersync")]
1238            #[must_use]
1239            pub(crate) fn with_receipt_hash_from_request(mut self) -> Self {
1240                self.receipt_hash_from_request = true;
1241                self
1242            }
1243
1244            /// Answers `eth_sendRawTransaction` with the hash of the submitted bytes when no
1245            /// canned response is configured, mirroring an honest node.
1246            #[cfg(feature = "hypersync")]
1247            #[must_use]
1248            pub(crate) fn with_send_raw_transaction_echo(mut self) -> Self {
1249                self.send_raw_echo = true;
1250                self
1251            }
1252
1253            /// Serves the given raw JSON-RPC response body for `eth_call` requests whose calldata
1254            /// starts with `selector` (hex with `0x` prefix).
1255            #[must_use]
1256            pub(crate) fn with_call_response(
1257                mut self,
1258                selector: &str,
1259                response_json: &str,
1260            ) -> Self {
1261                self.call_responses
1262                    .insert(selector.to_string(), response_json.to_string());
1263                self
1264            }
1265
1266            /// Serves a call response selected by both contract address and calldata selector.
1267            #[cfg(feature = "hypersync")]
1268            #[must_use]
1269            pub(crate) fn with_contract_call_response(
1270                mut self,
1271                contract: &str,
1272                selector: &str,
1273                response_json: &str,
1274            ) -> Self {
1275                self.contract_call_responses.insert(
1276                    (contract.to_ascii_lowercase(), selector.to_string()),
1277                    response_json.to_string(),
1278                );
1279                self
1280            }
1281
1282            /// Serves responses in order for calls whose calldata starts with `selector`.
1283            #[cfg(feature = "hypersync")]
1284            #[must_use]
1285            pub(crate) fn with_call_response_sequence(
1286                self,
1287                selector: &str,
1288                responses: &[&str],
1289            ) -> Self {
1290                self.call_response_sequences.lock().insert(
1291                    selector.to_string(),
1292                    responses.iter().map(ToString::to_string).collect(),
1293                );
1294                self
1295            }
1296
1297            /// Delays responses to `method` by `duration`, simulating an unresponsive node.
1298            #[cfg(feature = "hypersync")]
1299            #[must_use]
1300            pub(crate) fn with_sleep(mut self, method: &str, duration: Duration) -> Self {
1301                self.sleep_methods.insert(method.to_string(), duration);
1302                self
1303            }
1304
1305            /// Blocks each response until it consumes one semaphore permit
1306            #[cfg(feature = "hypersync")]
1307            #[must_use]
1308            pub(crate) fn with_response_release(
1309                mut self,
1310                method: &str,
1311                release: Arc<tokio::sync::Semaphore>,
1312            ) -> Self {
1313                self.response_releases.insert(method.to_string(), release);
1314                self
1315            }
1316
1317            /// Returns every JSON-RPC request body the server received, in order.
1318            #[must_use]
1319            pub(crate) fn recorded_requests(&self) -> Vec<Value> {
1320                self.requests.lock().clone()
1321            }
1322        }
1323
1324        async fn handle(State(state): State<MockRpcState>, body: String) -> String {
1325            let request: Value = serde_json::from_str(&body).unwrap_or(Value::Null);
1326            state.requests.lock().push(request.clone());
1327
1328            let method = request["method"].as_str().unwrap_or_default();
1329
1330            if method == "eth_sendRawTransaction"
1331                && let Some(raw) = request["params"][0].as_str()
1332            {
1333                *state.sent_raw_transaction.lock() = Some(raw.to_string());
1334            }
1335
1336            if let Some(duration) = state.sleep_methods.get(method) {
1337                tokio::time::sleep(*duration).await;
1338            }
1339
1340            if let Some(release) = state.response_releases.get(method) {
1341                release.acquire().await.unwrap().forget();
1342            }
1343
1344            if method == "eth_call" {
1345                let data = request["params"][0]["data"].as_str().unwrap_or_default();
1346                let selector_len = "0x".len() + 8;
1347                if data.len() >= selector_len {
1348                    let selector = &data[..selector_len];
1349                    let contract = request["params"][0]["to"]
1350                        .as_str()
1351                        .unwrap_or_default()
1352                        .to_ascii_lowercase();
1353
1354                    if let Some(response) = state
1355                        .contract_call_responses
1356                        .get(&(contract, selector.to_string()))
1357                    {
1358                        return response.clone();
1359                    }
1360
1361                    if let Some(response) = state
1362                        .call_response_sequences
1363                        .lock()
1364                        .get_mut(selector)
1365                        .and_then(VecDeque::pop_front)
1366                    {
1367                        return response;
1368                    }
1369
1370                    if let Some(response) = state.call_responses.get(selector) {
1371                        return response.clone();
1372                    }
1373                }
1374            }
1375
1376            let queued_response = state
1377                .response_sequences
1378                .lock()
1379                .get_mut(method)
1380                .and_then(VecDeque::pop_front);
1381
1382            let parameter = request["params"].get(0).and_then(Value::as_str);
1383            let queued_parameter_response = parameter.and_then(|parameter| {
1384                state
1385                    .parameter_response_sequences
1386                    .lock()
1387                    .get_mut(&(method.to_string(), parameter.to_string()))
1388                    .and_then(VecDeque::pop_front)
1389            });
1390            let parameter_response = parameter.and_then(|parameter| {
1391                state
1392                    .parameter_responses
1393                    .get(&(method.to_string(), parameter.to_string()))
1394            });
1395            let response = if let Some(response) = queued_response {
1396                response
1397            } else if let Some(response) = queued_parameter_response {
1398                response
1399            } else if let Some(response) = parameter_response {
1400                response.clone()
1401            } else if let Some(response) = state.responses.get(method) {
1402                response.clone()
1403            } else if method == "eth_getTransactionByHash" {
1404                response_from_sent_transaction(&state, false)
1405            } else if method == "debug_traceTransaction" {
1406                response_from_sent_transaction(&state, true)
1407            } else if method == "eth_sendRawTransaction" && state.send_raw_echo {
1408                echo_send_raw_transaction_hash(&request)
1409            } else {
1410                method_not_found_response()
1411            };
1412
1413            if state.receipt_hash_from_request && method == "eth_getTransactionReceipt" {
1414                receipt_response_with_requested_hash(response, &request)
1415            } else {
1416                response
1417            }
1418        }
1419
1420        fn receipt_response_with_requested_hash(response: String, request: &Value) -> String {
1421            let Some(requested_hash) = request["params"][0].as_str() else {
1422                return response;
1423            };
1424            let Ok(mut value) = serde_json::from_str::<Value>(&response) else {
1425                return response;
1426            };
1427            let Some(receipt) = value["result"].as_object_mut() else {
1428                return response;
1429            };
1430            receipt.insert(
1431                "transactionHash".to_string(),
1432                Value::String(requested_hash.to_string()),
1433            );
1434            value.to_string()
1435        }
1436
1437        fn response_from_sent_transaction(state: &MockRpcState, trace: bool) -> String {
1438            let Some(raw) = state.sent_raw_transaction.lock().clone() else {
1439                return method_not_found_response();
1440            };
1441            let stripped = raw.strip_prefix("0x").unwrap_or(&raw);
1442            let Ok(bytes) = nautilus_core::hex::decode(stripped) else {
1443                return method_not_found_response();
1444            };
1445            let Ok(TxEnvelope::Eip1559(signed)) = TxEnvelope::decode_2718_exact(&bytes) else {
1446                return method_not_found_response();
1447            };
1448            let Ok(from) = signed
1449                .signature()
1450                .recover_address_from_prehash(&signed.signature_hash())
1451            else {
1452                return method_not_found_response();
1453            };
1454            let tx = signed.tx();
1455            let TxKind::Call(to) = tx.to else {
1456                return method_not_found_response();
1457            };
1458            let reverted = state
1459                .responses
1460                .get("eth_getTransactionReceipt")
1461                .and_then(|response| serde_json::from_str::<Value>(response).ok())
1462                .is_some_and(|response| response["result"]["status"] == "0x0");
1463            let result = if trace {
1464                let mut result = serde_json::json!({
1465                    "type": "CALL",
1466                    "from": from,
1467                    "to": to,
1468                    "value": format!("0x{:x}", tx.value),
1469                    "gas": format!("0x{:x}", tx.gas_limit),
1470                    "gasUsed": "0xc3c0",
1471                    "input": nautilus_core::hex::encode_prefixed(&tx.input),
1472                    "output": "0x",
1473                    "calls": [],
1474                });
1475
1476                if reverted {
1477                    result["error"] = Value::String("execution reverted".to_string());
1478                }
1479                result
1480            } else {
1481                serde_json::json!({
1482                    "hash": signed.hash(),
1483                    "from": from,
1484                    "nonce": format!("0x{:x}", tx.nonce),
1485                    "chainId": format!("0x{:x}", tx.chain_id),
1486                    "type": "0x2",
1487                    "to": to,
1488                    "input": nautilus_core::hex::encode_prefixed(&tx.input),
1489                    "value": format!("0x{:x}", tx.value),
1490                    "gas": format!("0x{:x}", tx.gas_limit),
1491                    "maxFeePerGas": format!("0x{:x}", tx.max_fee_per_gas),
1492                    "maxPriorityFeePerGas": format!("0x{:x}", tx.max_priority_fee_per_gas),
1493                })
1494            };
1495            serde_json::json!({"jsonrpc": "2.0", "id": 1, "result": result}).to_string()
1496        }
1497
1498        /// Computes the transaction hash for an `eth_sendRawTransaction` request, mirroring
1499        /// an honest node: the keccak256 digest of the submitted EIP-2718 bytes.
1500        fn echo_send_raw_transaction_hash(request: &Value) -> String {
1501            let Some(raw) = request["params"][0].as_str() else {
1502                return method_not_found_response();
1503            };
1504            let stripped = raw.strip_prefix("0x").unwrap_or(raw);
1505            match nautilus_core::hex::decode(stripped) {
1506                Ok(bytes) => serde_json::json!({
1507                    "jsonrpc": "2.0",
1508                    "id": 1,
1509                    "result": alloy::primitives::keccak256(bytes).to_string()
1510                })
1511                .to_string(),
1512                Err(_) => method_not_found_response(),
1513            }
1514        }
1515
1516        fn method_not_found_response() -> String {
1517            serde_json::json!({
1518                "jsonrpc": "2.0",
1519                "id": 1,
1520                "error": {"code": -32601, "message": "method not found"}
1521            })
1522            .to_string()
1523        }
1524
1525        /// Starts a mock JSON-RPC server on a random localhost port and returns its address.
1526        pub(crate) async fn start_mock_rpc_server(state: MockRpcState) -> SocketAddr {
1527            let app = Router::new().route("/", post(handle)).with_state(state);
1528            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1529            let addr = listener.local_addr().unwrap();
1530            tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
1531            addr
1532        }
1533    }
1534
1535    use mock::{MockRpcState, start_mock_rpc_server};
1536
1537    const CHAIN_ID_ARBITRUM: &str =
1538        include_str!("../../test_data/execution/rpc_eth_chain_id_arbitrum.json");
1539    const GET_CODE_DEPLOYED: &str =
1540        include_str!("../../test_data/execution/rpc_eth_get_code_deployed.json");
1541    const GET_CODE_EMPTY: &str =
1542        include_str!("../../test_data/execution/rpc_eth_get_code_empty.json");
1543    const TRANSACTION_COUNT: &str =
1544        include_str!("../../test_data/execution/rpc_eth_get_transaction_count.json");
1545    const ESTIMATE_GAS: &str = include_str!("../../test_data/execution/rpc_eth_estimate_gas.json");
1546    const MAX_PRIORITY_FEE: &str =
1547        include_str!("../../test_data/execution/rpc_eth_max_priority_fee_per_gas.json");
1548    const BLOCK_BY_NUMBER: &str =
1549        include_str!("../../test_data/execution/rpc_eth_get_block_by_number.json");
1550    const RECEIPT_NULL: &str =
1551        include_str!("../../test_data/execution/rpc_eth_get_transaction_receipt_null.json");
1552    const RECEIPT_SUCCESS: &str =
1553        include_str!("../../test_data/execution/rpc_eth_get_transaction_receipt_success.json");
1554    const RECEIPT_REVERTED: &str =
1555        include_str!("../../test_data/execution/rpc_eth_get_transaction_receipt_reverted.json");
1556    const RECEIPT_STATUS_MISSING: &str = include_str!(
1557        "../../test_data/execution/rpc_eth_get_transaction_receipt_status_missing.json"
1558    );
1559    const RECEIPT_STATUS_MALFORMED: &str = include_str!(
1560        "../../test_data/execution/rpc_eth_get_transaction_receipt_status_malformed.json"
1561    );
1562    const RECEIPT_STATUS_OTHER: &str =
1563        include_str!("../../test_data/execution/rpc_eth_get_transaction_receipt_status_other.json");
1564    const RECEIPT_STATUS_NONCANONICAL: &str = include_str!(
1565        "../../test_data/execution/rpc_eth_get_transaction_receipt_status_noncanonical.json"
1566    );
1567    const SEND_RAW_TRANSACTION: &str =
1568        include_str!("../../test_data/execution/rpc_eth_send_raw_transaction.json");
1569    const SEND_RAW_TRANSACTION_ALREADY_KNOWN: &str =
1570        include_str!("../../test_data/execution/rpc_eth_send_raw_transaction_already_known.json");
1571    const SEND_RAW_TRANSACTION_REJECTED: &str =
1572        include_str!("../../test_data/execution/rpc_eth_send_raw_transaction_rejected.json");
1573    const SEND_RAW_TRANSACTION_NONCE_TOO_LOW: &str =
1574        include_str!("../../test_data/execution/rpc_eth_send_raw_transaction_nonce_too_low.json");
1575
1576    async fn client_for(state: MockRpcState) -> (BlockchainHttpRpcClient, MockRpcState) {
1577        let addr = start_mock_rpc_server(state.clone()).await;
1578        (
1579            BlockchainHttpRpcClient::new(format!("http://{addr}"), None, None),
1580            state,
1581        )
1582    }
1583
1584    #[rstest]
1585    fn debug_redacts_http_rpc_url() {
1586        const USERINFO_SECRET: &str = "http-client-userinfo-secret";
1587        const PATH_SECRET: &str = "http-client-path-secret";
1588        const QUERY_SECRET: &str = "http-client-query-secret";
1589        let http_rpc_url = format!(
1590            "https://rpc-user:{USERINFO_SECRET}@rpc.example.com/{PATH_SECRET}?api_key={QUERY_SECRET}"
1591        );
1592        let client = BlockchainHttpRpcClient::new(http_rpc_url.clone(), None, None);
1593
1594        let debug = format!("{client:?}");
1595
1596        assert!(debug.contains("http_rpc_url: \"<redacted>\""));
1597        assert!(!debug.contains(USERINFO_SECRET));
1598        assert!(!debug.contains(PATH_SECRET));
1599        assert!(!debug.contains(QUERY_SECRET));
1600        assert!(!debug.contains(&http_rpc_url));
1601    }
1602
1603    #[tokio::test]
1604    async fn chain_id_parses_hex_quantity() {
1605        let (client, _) =
1606            client_for(MockRpcState::default().with_response("eth_chainId", CHAIN_ID_ARBITRUM))
1607                .await;
1608
1609        assert_eq!(client.chain_id().await.unwrap(), 42161);
1610    }
1611
1612    #[tokio::test]
1613    async fn chain_id_error_is_sanitized_to_method_and_code() {
1614        let (client, _) = client_for(MockRpcState::default()).await;
1615
1616        let error = client.chain_id().await.unwrap_err();
1617
1618        let message = error.to_string();
1619        assert!(
1620            message.contains("eth_chainId RPC error -32601"),
1621            "was: {message}"
1622        );
1623        assert!(!message.contains("method not found"), "was: {message}");
1624    }
1625
1626    #[tokio::test]
1627    async fn get_code_returns_deployed_bytecode() {
1628        let (client, state) =
1629            client_for(MockRpcState::default().with_response("eth_getCode", GET_CODE_DEPLOYED))
1630                .await;
1631
1632        let code = client
1633            .get_code(&address!("82aF49447D8a07e3bd95BD0d56f35241523fBab1"))
1634            .await
1635            .unwrap();
1636
1637        assert!(!code.is_empty());
1638        assert!(code.starts_with(&[0x60, 0x80]));
1639        assert_eq!(
1640            state.recorded_requests()[0]["params"],
1641            serde_json::json!(["0x82af49447d8a07e3bd95bd0d56f35241523fbab1", "latest"])
1642        );
1643    }
1644
1645    #[tokio::test]
1646    async fn get_code_returns_empty_for_eoa() {
1647        let (client, _) =
1648            client_for(MockRpcState::default().with_response("eth_getCode", GET_CODE_EMPTY)).await;
1649
1650        let code = client
1651            .get_code(&address!("82aF49447D8a07e3bd95BD0d56f35241523fBab1"))
1652            .await
1653            .unwrap();
1654
1655        assert!(code.is_empty());
1656    }
1657
1658    #[tokio::test]
1659    async fn transaction_count_uses_pending_tag() {
1660        let (client, state) = client_for(
1661            MockRpcState::default().with_response("eth_getTransactionCount", TRANSACTION_COUNT),
1662        )
1663        .await;
1664
1665        let nonce = client
1666            .get_transaction_count_pending(&address!("f39Fd6e51aad88F6F4ce6aB8827279cffFb92266"))
1667            .await
1668            .unwrap();
1669
1670        assert_eq!(nonce, 7);
1671        let requests = state.recorded_requests();
1672        assert_eq!(requests.len(), 1);
1673        assert_eq!(requests[0]["method"], "eth_getTransactionCount");
1674        assert_eq!(requests[0]["params"][1], "pending");
1675    }
1676
1677    #[tokio::test]
1678    async fn estimate_gas_sends_call_object() {
1679        let (client, state) =
1680            client_for(MockRpcState::default().with_response("eth_estimateGas", ESTIMATE_GAS))
1681                .await;
1682
1683        let gas = client
1684            .estimate_gas(
1685                &address!("f39Fd6e51aad88F6F4ce6aB8827279cffFb92266"),
1686                &address!("82aF49447D8a07e3bd95BD0d56f35241523fBab1"),
1687                U256::from(1_000_000_000_000_000_000u64),
1688                &[0xd0, 0xe3, 0x0d, 0xb0],
1689            )
1690            .await
1691            .unwrap();
1692
1693        assert_eq!(gas, 65_000);
1694        let requests = state.recorded_requests();
1695        assert_eq!(requests.len(), 1);
1696        assert_eq!(requests[0]["method"], "eth_estimateGas");
1697        assert_eq!(requests[0]["params"].as_array().unwrap().len(), 1);
1698        let call = &requests[0]["params"][0];
1699        assert_eq!(call["from"], "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266");
1700        assert_eq!(call["to"], "0x82af49447d8a07e3bd95bd0d56f35241523fbab1");
1701        assert_eq!(call["value"], "0xde0b6b3a7640000");
1702        assert_eq!(call["data"], "0xd0e30db0");
1703    }
1704
1705    #[tokio::test]
1706    async fn max_priority_fee_parses_hex_quantity() {
1707        let (client, _) = client_for(
1708            MockRpcState::default().with_response("eth_maxPriorityFeePerGas", MAX_PRIORITY_FEE),
1709        )
1710        .await;
1711
1712        assert_eq!(client.max_priority_fee_per_gas().await.unwrap(), 10_000_000);
1713    }
1714
1715    #[tokio::test]
1716    async fn latest_block_parses_number_timestamp_and_base_fee() {
1717        let (client, state) = client_for(
1718            MockRpcState::default().with_response("eth_getBlockByNumber", BLOCK_BY_NUMBER),
1719        )
1720        .await;
1721
1722        let block = client.latest_block().await.unwrap();
1723
1724        assert_eq!(block.number, 30_346_560);
1725        assert_eq!(
1726            block.hash,
1727            b256!("1111111111111111111111111111111111111111111111111111111111111111")
1728        );
1729        assert_eq!(block.timestamp, 1_761_888_800);
1730        assert_eq!(block.base_fee_per_gas, Some(100_000_000));
1731        assert!(block.transactions.is_empty());
1732        let requests = state.recorded_requests();
1733        assert_eq!(requests[0]["params"][0], "latest");
1734        assert_eq!(requests[0]["params"][1], false);
1735    }
1736
1737    #[tokio::test]
1738    async fn finalized_block_uses_finalized_tag_without_transactions() {
1739        let (client, state) = client_for(
1740            MockRpcState::default().with_response("eth_getBlockByNumber", BLOCK_BY_NUMBER),
1741        )
1742        .await;
1743
1744        let block = client.finalized_block().await.unwrap();
1745
1746        assert_eq!(block.number, 30_346_560);
1747        let requests = state.recorded_requests();
1748        assert_eq!(requests.len(), 1);
1749        assert_eq!(
1750            requests[0]["params"],
1751            serde_json::json!(["finalized", false])
1752        );
1753    }
1754
1755    #[tokio::test]
1756    async fn numbered_block_rejects_response_for_another_height() {
1757        let (client, state) = client_for(
1758            MockRpcState::default().with_response("eth_getBlockByNumber", BLOCK_BY_NUMBER),
1759        )
1760        .await;
1761
1762        let error = client.block_by_number(30_346_561, false).await.unwrap_err();
1763
1764        assert_eq!(
1765            error.to_string(),
1766            "eth_getBlockByNumber returned block 30346560 for requested block 30346561"
1767        );
1768        let requests = state.recorded_requests();
1769        assert_eq!(requests.len(), 1);
1770        assert_eq!(
1771            requests[0]["params"],
1772            serde_json::json!(["0x1cf0d41", false])
1773        );
1774    }
1775
1776    #[tokio::test]
1777    async fn transaction_receipt_maps_null_result_to_none() {
1778        let (client, _) = client_for(
1779            MockRpcState::default().with_response("eth_getTransactionReceipt", RECEIPT_NULL),
1780        )
1781        .await;
1782
1783        let receipt = client
1784            .get_transaction_receipt(&b256!(
1785                "9da4b71be3336357259f56bda5cfbd3803c211ce09b510c43e6fb2af84088c6a"
1786            ))
1787            .await
1788            .unwrap();
1789
1790        assert!(receipt.is_none());
1791    }
1792
1793    #[tokio::test]
1794    async fn transaction_receipt_parses_success_fields() {
1795        let (client, _) = client_for(
1796            MockRpcState::default().with_response("eth_getTransactionReceipt", RECEIPT_SUCCESS),
1797        )
1798        .await;
1799
1800        let receipt = client
1801            .get_transaction_receipt(&b256!(
1802                "9da4b71be3336357259f56bda5cfbd3803c211ce09b510c43e6fb2af84088c6a"
1803            ))
1804            .await
1805            .unwrap()
1806            .unwrap();
1807
1808        assert_eq!(
1809            receipt.transaction_hash,
1810            b256!("9da4b71be3336357259f56bda5cfbd3803c211ce09b510c43e6fb2af84088c6a")
1811        );
1812        assert_eq!(receipt.block_number, 30_346_561);
1813        assert_eq!(
1814            receipt.block_hash,
1815            b256!("2222222222222222222222222222222222222222222222222222222222222222")
1816        );
1817        assert_eq!(receipt.gas_used, 50_112);
1818        assert_eq!(receipt.effective_gas_price, U256::from(100_000_000_u64));
1819        assert_eq!(receipt.transaction_index, 2);
1820        assert!(receipt.status);
1821        assert!(receipt.logs.is_empty());
1822    }
1823
1824    #[tokio::test]
1825    async fn transaction_receipt_parses_reverted_fields() {
1826        let (client, _) = client_for(
1827            MockRpcState::default().with_response("eth_getTransactionReceipt", RECEIPT_REVERTED),
1828        )
1829        .await;
1830
1831        let receipt = client
1832            .get_transaction_receipt(&b256!(
1833                "9da4b71be3336357259f56bda5cfbd3803c211ce09b510c43e6fb2af84088c6a"
1834            ))
1835            .await
1836            .unwrap()
1837            .unwrap();
1838
1839        assert_eq!(
1840            receipt.transaction_hash,
1841            b256!("9da4b71be3336357259f56bda5cfbd3803c211ce09b510c43e6fb2af84088c6a")
1842        );
1843        assert_eq!(receipt.block_number, 30_346_561);
1844        assert_eq!(
1845            receipt.block_hash,
1846            b256!("2222222222222222222222222222222222222222222222222222222222222222")
1847        );
1848        assert_eq!(receipt.gas_used, 50_112);
1849        assert_eq!(receipt.effective_gas_price, U256::from(100_000_000_u64));
1850        assert_eq!(receipt.transaction_index, 2);
1851        assert!(!receipt.status);
1852        assert!(receipt.logs.is_empty());
1853    }
1854
1855    #[tokio::test]
1856    async fn transaction_receipt_rejects_mismatched_hash() {
1857        let (client, _) = client_for(
1858            MockRpcState::default().with_response("eth_getTransactionReceipt", RECEIPT_SUCCESS),
1859        )
1860        .await;
1861
1862        let error = client
1863            .get_transaction_receipt(&B256::ZERO)
1864            .await
1865            .expect_err("a receipt for another transaction should fail closed");
1866
1867        assert_eq!(
1868            error.to_string(),
1869            "eth_getTransactionReceipt returned a receipt with a mismatched transaction hash"
1870        );
1871    }
1872
1873    #[rstest]
1874    #[case::missing(RECEIPT_STATUS_MISSING)]
1875    #[case::malformed(RECEIPT_STATUS_MALFORMED)]
1876    #[case::other(RECEIPT_STATUS_OTHER)]
1877    #[case::noncanonical(RECEIPT_STATUS_NONCANONICAL)]
1878    #[tokio::test]
1879    async fn transaction_receipt_rejects_invalid_status(#[case] response: &str) {
1880        let (client, _) = client_for(
1881            MockRpcState::default().with_response("eth_getTransactionReceipt", response),
1882        )
1883        .await;
1884
1885        let error = client
1886            .get_transaction_receipt(&b256!(
1887                "9da4b71be3336357259f56bda5cfbd3803c211ce09b510c43e6fb2af84088c6a"
1888            ))
1889            .await
1890            .expect_err("an invalid receipt status should fail closed");
1891
1892        assert_eq!(
1893            error.to_string(),
1894            "Failed to parse eth_getTransactionReceipt response"
1895        );
1896    }
1897
1898    #[tokio::test]
1899    async fn send_raw_transaction_returns_node_hash() {
1900        let (client, _) = client_for(
1901            MockRpcState::default().with_response("eth_sendRawTransaction", SEND_RAW_TRANSACTION),
1902        )
1903        .await;
1904
1905        let hash = client
1906            .send_raw_transaction(
1907                &[0x02, 0xf8],
1908                &b256!("9da4b71be3336357259f56bda5cfbd3803c211ce09b510c43e6fb2af84088c6a"),
1909            )
1910            .await
1911            .unwrap();
1912
1913        assert_eq!(
1914            hash,
1915            b256!("9da4b71be3336357259f56bda5cfbd3803c211ce09b510c43e6fb2af84088c6a")
1916        );
1917    }
1918
1919    #[tokio::test]
1920    async fn send_raw_transaction_treats_already_known_as_acceptance() {
1921        let (client, _) = client_for(
1922            MockRpcState::default()
1923                .with_response("eth_sendRawTransaction", SEND_RAW_TRANSACTION_ALREADY_KNOWN),
1924        )
1925        .await;
1926
1927        let expected = b256!("9da4b71be3336357259f56bda5cfbd3803c211ce09b510c43e6fb2af84088c6a");
1928        let hash = client
1929            .send_raw_transaction(&[0x02, 0xf8], &expected)
1930            .await
1931            .unwrap();
1932
1933        assert_eq!(hash, expected);
1934    }
1935
1936    #[tokio::test]
1937    async fn send_raw_transaction_rejection_is_sanitized_to_code() {
1938        let (client, _) = client_for(
1939            MockRpcState::default()
1940                .with_response("eth_sendRawTransaction", SEND_RAW_TRANSACTION_REJECTED),
1941        )
1942        .await;
1943
1944        let error = client
1945            .send_raw_transaction(
1946                &[0x02, 0xf8],
1947                &b256!("9da4b71be3336357259f56bda5cfbd3803c211ce09b510c43e6fb2af84088c6a"),
1948            )
1949            .await
1950            .unwrap_err();
1951
1952        match error {
1953            BroadcastError::Rejected { code } => assert_eq!(code, -32000),
1954            other => panic!("Expected BroadcastError::Rejected, was {other}"),
1955        }
1956        let message = error.to_string();
1957        assert!(!message.contains("insufficient funds"), "was: {message}");
1958    }
1959
1960    #[tokio::test]
1961    async fn send_raw_transaction_treats_nonce_too_low_as_ambiguous() {
1962        let (client, _) = client_for(
1963            MockRpcState::default()
1964                .with_response("eth_sendRawTransaction", SEND_RAW_TRANSACTION_NONCE_TOO_LOW),
1965        )
1966        .await;
1967
1968        let error = client
1969            .send_raw_transaction(
1970                &[0x02, 0xf8],
1971                &b256!("9da4b71be3336357259f56bda5cfbd3803c211ce09b510c43e6fb2af84088c6a"),
1972            )
1973            .await
1974            .unwrap_err();
1975
1976        let BroadcastError::Failed(message) = error else {
1977            panic!("Expected BroadcastError::Failed, was {error}");
1978        };
1979        assert_eq!(message, "node RPC error -32000 reported a consumed nonce");
1980        assert!(!message.contains("0xf39Fd6e51"), "was: {message}");
1981    }
1982
1983    #[rstest]
1984    fn broadcast_error_classification_sanitizes_transport_failure() {
1985        const SECRET: &str = "https://rpc.example.com/private-api-key";
1986        let timeout = HttpClientError::TimeoutError("timed out".to_string());
1987        let transport = HttpClientError::Error(SECRET.to_string());
1988
1989        assert!(matches!(
1990            classify_broadcast_transport_error(&timeout),
1991            BroadcastError::TimeoutAfterSend
1992        ));
1993        let classified = classify_broadcast_transport_error(&transport);
1994        assert_eq!(
1995            classified.to_string(),
1996            "Broadcast failed ambiguously: transport error"
1997        );
1998        assert!(!classified.to_string().contains(SECRET));
1999    }
2000}