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