1use 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
39pub const EXECUTION_RPC_TIMEOUT_SECS: u64 = 10;
41
42pub struct BlockchainHttpRpcClient {
47 http_rpc_url: String,
49 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 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 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 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 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 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 #[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 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 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 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 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 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 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 #[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 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 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 #[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 #[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 #[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 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 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 pub async fn latest_block(&self) -> anyhow::Result<RpcBlock> {
656 self.block_by_tag("latest", false).await
657 }
658
659 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 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 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 #[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 #[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 #[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 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
1024fn 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
1036fn 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 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}