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