1use std::sync::atomic::{AtomicI64, Ordering};
8use std::time::Duration;
9
10use serde::Deserialize;
11use serde_json::{json, Value};
12
13use crate::Error;
14
15pub const DEFAULT_RPC_TIMEOUT: Duration = Duration::from_secs(15);
17
18pub const DEFAULT_BLOCK_TAG: &str = "latest";
20
21use crate::client::MAX_RESPONSE_BYTES;
26
27#[derive(Debug, Clone)]
33pub enum CallOutcome {
34 Ok(String),
36 Reverted(String),
38}
39
40#[derive(Debug, Deserialize)]
41struct RpcResponse {
42 result: Option<Value>,
43 error: Option<RpcErrorBody>,
44}
45
46#[derive(Debug, Deserialize)]
47struct RpcErrorBody {
48 code: Option<i64>,
49 message: Option<String>,
50 data: Option<Value>,
51}
52
53#[derive(Debug)]
55pub struct RpcClient {
56 url: String,
57 http: reqwest::Client,
58 block: String,
59 next_id: AtomicI64,
60}
61
62impl RpcClient {
63 pub fn new(url: impl Into<String>, timeout: Duration) -> Result<Self, Error> {
65 let http = reqwest::Client::builder().timeout(timeout).build()?;
66 Ok(Self::with_http(url, http))
67 }
68
69 pub fn with_http(url: impl Into<String>, http: reqwest::Client) -> Self {
75 Self {
76 url: url.into(),
77 http,
78 block: DEFAULT_BLOCK_TAG.to_string(),
79 next_id: AtomicI64::new(1),
80 }
81 }
82
83 pub fn block(mut self, block: impl Into<String>) -> Self {
90 self.block = block.into();
91 self
92 }
93
94 pub fn url(&self) -> &str {
96 &self.url
97 }
98
99 pub fn block_tag(&self) -> &str {
101 &self.block
102 }
103
104 pub async fn request(&self, method: &str, params: Value) -> Result<Value, Error> {
106 let id = self.next_id.fetch_add(1, Ordering::Relaxed);
107 let body = json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params });
108
109 let response = self
110 .http
111 .post(&self.url)
112 .json(&body)
113 .send()
114 .await
115 .map_err(|source| Error::RpcTransport {
116 method: method.to_string(),
117 source,
118 })?;
119 let status = response.status();
120 let text = read_capped(response, method).await?;
121
122 if !status.is_success() {
123 return Err(Error::Rpc {
124 method: method.to_string(),
125 message: format!("HTTP {}: {}", status.as_u16(), truncate(&text, 256)),
126 code: None,
127 data: None,
128 });
129 }
130
131 let decoded: RpcResponse = serde_json::from_str(&text).map_err(|_| Error::Rpc {
132 method: method.to_string(),
133 message: format!("body is not a JSON-RPC response: {}", truncate(&text, 256)),
134 code: None,
135 data: None,
136 })?;
137
138 if let Some(error) = decoded.error {
139 return Err(Error::Rpc {
144 method: method.to_string(),
145 message: error.message.unwrap_or_else(|| "unknown error".to_string()),
146 code: error.code,
147 data: error.data,
148 });
149 }
150
151 Ok(decoded.result.unwrap_or(Value::Null))
152 }
153
154 pub async fn call(
156 &self,
157 to: &str,
158 data: &str,
159 from: Option<&str>,
160 ) -> Result<CallOutcome, Error> {
161 self.call_at(to, data, from, &self.block).await
162 }
163
164 pub async fn call_at(
167 &self,
168 to: &str,
169 data: &str,
170 from: Option<&str>,
171 block: &str,
172 ) -> Result<CallOutcome, Error> {
173 let mut message = json!({ "to": to, "data": data });
174 if let Some(from) = from {
175 message["from"] = Value::String(from.to_string());
176 }
177
178 match self.request("eth_call", json!([message, block])).await {
179 Ok(Value::String(result)) => Ok(CallOutcome::Ok(result)),
180 Ok(_) => Ok(CallOutcome::Ok("0x".to_string())),
181 Err(error) => match extract_revert_data(&error) {
182 Some(revert) => Ok(CallOutcome::Reverted(revert)),
183 None => Err(error),
184 },
185 }
186 }
187
188 pub async fn call_or_error(
190 &self,
191 to: &str,
192 data: &str,
193 context: &str,
194 ) -> Result<String, Error> {
195 match self.call(to, data, None).await? {
196 CallOutcome::Ok(result) => Ok(result),
197 CallOutcome::Reverted(revert) => Err(crate::revert::revert_error(&revert, context)),
198 }
199 }
200}
201
202async fn read_capped(mut response: reqwest::Response, method: &str) -> Result<String, Error> {
208 let too_big = |seen: u64| Error::Rpc {
209 method: method.to_string(),
210 message: format!(
211 "response body is at least {seen} bytes, past the {MAX_RESPONSE_BYTES}-byte ceiling"
212 ),
213 code: None,
214 data: None,
215 };
216
217 if let Some(declared) = response.content_length() {
218 if declared > MAX_RESPONSE_BYTES as u64 {
219 return Err(too_big(declared));
220 }
221 }
222
223 let mut body: Vec<u8> = Vec::new();
224 while let Some(chunk) = response
225 .chunk()
226 .await
227 .map_err(|source| Error::RpcTransport {
228 method: method.to_string(),
229 source,
230 })?
231 {
232 if body.len() + chunk.len() > MAX_RESPONSE_BYTES {
233 return Err(too_big((body.len() + chunk.len()) as u64));
234 }
235 body.extend_from_slice(&chunk);
236 }
237
238 Ok(String::from_utf8_lossy(&body).into_owned())
239}
240
241fn extract_revert_data(error: &Error) -> Option<String> {
250 let Error::Rpc { message, data, .. } = error else {
251 return None;
252 };
253
254 if let Some(data) = data {
255 if let Some(found) = payload_from(data) {
256 return Some(found);
257 }
258 if let Some(found) = payload_from(&data["data"]) {
259 return Some(found);
260 }
261 }
262
263 if let Some(found) = first_hex_run(message) {
264 return Some(found);
265 }
266 message
267 .to_ascii_lowercase()
268 .contains("revert")
269 .then(|| "0x".to_string())
270}
271
272fn payload_from(value: &Value) -> Option<String> {
275 let text = value.as_str()?;
276 if let Some(body) = text.strip_prefix("0x").or_else(|| text.strip_prefix("0X")) {
277 if body.len() % 2 == 0 && body.chars().all(|c| c.is_ascii_hexdigit()) {
278 return Some(format!("0x{}", body.to_ascii_lowercase()));
281 }
282 }
283 first_hex_run(text)
284}
285
286fn first_hex_run(text: &str) -> Option<String> {
288 let bytes = text.as_bytes();
289 let mut index = 0;
290 while index + 2 <= bytes.len() {
291 if bytes[index] == b'0' && (bytes[index + 1] == b'x' || bytes[index + 1] == b'X') {
292 let start = index;
293 let mut end = index + 2;
294 while end < bytes.len() && (bytes[end] as char).is_ascii_hexdigit() {
295 end += 1;
296 }
297 if end - start >= 10 {
298 return Some(text[start..end].to_ascii_lowercase());
299 }
300 index = end;
301 } else {
302 index += 1;
303 }
304 }
305 None
306}
307
308fn truncate(text: &str, limit: usize) -> &str {
309 match text.char_indices().nth(limit) {
310 Some((index, _)) => &text[..index],
311 None => text,
312 }
313}
314
315#[cfg(test)]
316mod tests {
317 use super::*;
318
319 fn rpc(message: &str, data: Option<Value>) -> Error {
320 Error::Rpc {
321 method: "eth_call".to_string(),
322 message: message.to_string(),
323 code: Some(3),
324 data,
325 }
326 }
327
328 #[test]
329 fn structured_data_beats_an_address_in_the_message() {
330 let error = rpc(
335 "execution reverted: revert at 0x33a98bef6496684a8dac83734d9ceb0cefc7019c",
336 Some(json!("0xcfa6d878")),
337 );
338 assert_eq!(extract_revert_data(&error).as_deref(), Some("0xcfa6d878"));
339 }
340
341 #[test]
342 fn reads_the_nested_data_form() {
343 let error = rpc(
344 "execution reverted",
345 Some(json!({ "message": "execution reverted", "data": "0x8199F5F3" })),
346 );
347 assert_eq!(extract_revert_data(&error).as_deref(), Some("0x8199f5f3"));
348 }
349
350 #[test]
351 fn falls_back_to_the_first_run_in_the_message() {
352 let error = rpc("execution reverted: 0xcfa6d878 at 0xdeadbeefcafe", None);
353 assert_eq!(extract_revert_data(&error).as_deref(), Some("0xcfa6d878"));
354 }
355
356 #[test]
357 fn a_stripped_payload_is_still_a_revert() {
358 assert_eq!(
359 extract_revert_data(&rpc("execution reverted", Some(json!("0x")))).as_deref(),
360 Some("0x")
361 );
362 assert_eq!(
363 extract_revert_data(&rpc("execution reverted", None)).as_deref(),
364 Some("0x")
365 );
366 }
367
368 #[test]
369 fn a_plain_node_failure_is_not_a_revert() {
370 assert_eq!(extract_revert_data(&rpc("header not found", None)), None);
371 assert_eq!(
372 extract_revert_data(&Error::InvalidArgument {
373 message: "nope".to_string()
374 }),
375 None
376 );
377 }
378
379 #[test]
380 fn digs_a_payload_out_of_prose_in_the_data_field() {
381 let error = rpc(
382 "execution reverted",
383 Some(json!("Reverted 0xcfa6d878, gas 21000")),
384 );
385 assert_eq!(extract_revert_data(&error).as_deref(), Some("0xcfa6d878"));
386 }
387}