nautilus_blockchain/rpc/
http.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16use std::{collections::HashMap, num::NonZeroU32, str::FromStr};
17
18use alloy::primitives::{Address, U256};
19use bytes::Bytes;
20use nautilus_model::defi::rpc::RpcNodeHttpResponse;
21use nautilus_network::{http::HttpClient, ratelimiter::quota::Quota};
22use reqwest::Method;
23use serde::de::DeserializeOwned;
24
25use crate::rpc::error::BlockchainRpcClientError;
26
27/// Client for making HTTP-based RPC requests to blockchain nodes.
28///
29/// This client is designed to interact with Ethereum-compatible blockchain networks, providing
30/// methods to execute RPC calls and handle responses in a type-safe manner.
31#[derive(Debug)]
32pub struct BlockchainHttpRpcClient {
33    /// The HTTP URL for the blockchain node's RPC endpoint.
34    http_rpc_url: String,
35    /// The HTTP client for making RPC http-based requests.
36    http_client: HttpClient,
37}
38
39impl BlockchainHttpRpcClient {
40    /// Creates a new HTTP RPC client with the given endpoint URL and optional rate limit.
41    ///
42    /// # Panics
43    ///
44    /// Panics if `rpc_request_per_second` is `Some(0)`, since a zero rate limit is invalid.
45    #[must_use]
46    pub fn new(http_rpc_url: String, rpc_request_per_second: Option<u32>) -> Self {
47        let default_quota = rpc_request_per_second.map(|rpc_request_per_second| {
48            Quota::per_second(NonZeroU32::new(rpc_request_per_second).unwrap())
49        });
50        let http_client = HttpClient::new(
51            HashMap::new(),
52            vec![],
53            Vec::new(),
54            default_quota,
55            None, // timeout_secs
56            None, // proxy_url
57        )
58        .expect("Failed to create HTTP client");
59        Self {
60            http_rpc_url,
61            http_client,
62        }
63    }
64
65    /// Generic method that sends a JSON-RPC request and returns the raw response in bytes.
66    async fn send_rpc_request(
67        &self,
68        rpc_request: serde_json::Value,
69    ) -> Result<Bytes, BlockchainRpcClientError> {
70        let body_bytes = serde_json::to_vec(&rpc_request).map_err(|e| {
71            BlockchainRpcClientError::ClientError(format!("Failed to serialize request: {e}"))
72        })?;
73
74        let mut headers = HashMap::new();
75        headers.insert("Content-Type".to_string(), "application/json".to_string());
76
77        match self
78            .http_client
79            .request(
80                Method::POST,
81                self.http_rpc_url.clone(),
82                None,
83                Some(headers),
84                Some(body_bytes),
85                None,
86                None,
87            )
88            .await
89        {
90            Ok(response) => Ok(response.body),
91            Err(e) => Err(BlockchainRpcClientError::ClientError(e.to_string())),
92        }
93    }
94
95    /// Executes an Ethereum JSON-RPC call and deserializes the response into the specified type T.
96    ///
97    /// # Errors
98    ///
99    /// Returns an error if the HTTP RPC request fails or the response cannot be parsed.
100    pub async fn execute_rpc_call<T: DeserializeOwned>(
101        &self,
102        rpc_request: serde_json::Value,
103    ) -> anyhow::Result<T> {
104        match self.send_rpc_request(rpc_request).await {
105            Ok(bytes) => match serde_json::from_slice::<RpcNodeHttpResponse<T>>(bytes.as_ref()) {
106                Ok(parsed) => {
107                    if let Some(error) = parsed.error {
108                        Err(anyhow::anyhow!(
109                            "RPC error {}: {}",
110                            error.code,
111                            error.message
112                        ))
113                    } else if let Some(result) = parsed.result {
114                        Ok(result)
115                    } else {
116                        Err(anyhow::anyhow!(
117                            "Response missing both result and error fields"
118                        ))
119                    }
120                }
121                Err(e) => {
122                    // Try to convert bytes to string for better error reporting
123                    let raw_response = String::from_utf8_lossy(bytes.as_ref());
124                    let preview = if raw_response.len() > 500 {
125                        format!(
126                            "{}... (truncated, {} bytes total)",
127                            &raw_response[..500],
128                            raw_response.len()
129                        )
130                    } else {
131                        raw_response.to_string()
132                    };
133
134                    Err(anyhow::anyhow!(
135                        "Failed to parse eth call response: {}\nRaw response: {}",
136                        e,
137                        preview
138                    ))
139                }
140            },
141            Err(e) => Err(anyhow::anyhow!(
142                "Failed to execute eth call RPC request: {}",
143                e
144            )),
145        }
146    }
147
148    /// Creates a properly formatted `eth_call` JSON-RPC request object targeting a specific contract address with encoded function data.
149    #[must_use]
150    pub fn construct_eth_call(
151        &self,
152        to: &str,
153        call_data: &[u8],
154        block: Option<u64>,
155    ) -> serde_json::Value {
156        let encoded_data = format!("0x{}", hex::encode(call_data));
157        let call = serde_json::json!({
158            "to": to,
159            "data": encoded_data
160        });
161
162        let block_param = if let Some(block_number) = block {
163            serde_json::json!(format!("0x{:x}", block_number))
164        } else {
165            serde_json::json!("latest")
166        };
167
168        serde_json::json!({
169            "jsonrpc": "2.0",
170            "id": 1,
171            "method": "eth_call",
172            "params": [call, block_param]
173        })
174    }
175
176    /// Retrieves the balance of the specified Ethereum address at the given block.
177    ///
178    /// # Errors
179    ///
180    /// Returns an error if the RPC call fails or if the returned balance string cannot be parsed as a valid U256.
181    pub async fn get_balance(&self, address: &Address, block: Option<u64>) -> anyhow::Result<U256> {
182        let block_param = if let Some(block_number) = block {
183            serde_json::json!(format!("0x{:x}", block_number))
184        } else {
185            serde_json::json!("latest")
186        };
187
188        let request = serde_json::json!({
189            "jsonrpc": "2.0",
190            "id": 1,
191            "method": "eth_getBalance",
192            "params": [address, block_param]
193        });
194        let hex_string: String = self.execute_rpc_call(request).await?;
195
196        U256::from_str(&hex_string).map_err(|e| {
197            anyhow::anyhow!("Failed to parse balance hex string '{}': {}", hex_string, e)
198        })
199    }
200}