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