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