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