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