Skip to main content

fyuz/
rpc.rs

1//! A JSON-RPC client just large enough to read the bonding curve.
2//!
3//! `eth_call` against a view function is an HTTP POST with a JSON body. That is
4//! the whole requirement, so this rides on the `reqwest` client the crate
5//! already carries rather than pulling in a web3 stack.
6
7use std::sync::atomic::{AtomicI64, Ordering};
8use std::time::Duration;
9
10use serde::Deserialize;
11use serde_json::{json, Value};
12
13use crate::Error;
14
15/// Default per-call timeout for RPC requests.
16pub const DEFAULT_RPC_TIMEOUT: Duration = Duration::from_secs(15);
17
18/// Default block tag every `eth_call` is evaluated against.
19pub const DEFAULT_BLOCK_TAG: &str = "latest";
20
21/// Ceiling on a single response body, shared with the REST client.
22///
23/// A JSON-RPC answer to `getSwapOutput` is a few hundred bytes, so a megabyte is
24/// four orders of magnitude of headroom for either transport.
25use crate::client::MAX_RESPONSE_BYTES;
26
27/// The outcome of an `eth_call`: return data, or revert data.
28///
29/// Reverts are extremely common here — quoting a graduated token reverts by
30/// design — so they are a return value rather than an error. The caller decides
31/// what a revert means and builds the error with the right context.
32#[derive(Debug, Clone)]
33pub enum CallOutcome {
34    /// The call returned data.
35    Ok(String),
36    /// The call reverted, carrying this payload (possibly `"0x"`).
37    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/// Minimal JSON-RPC transport.
54#[derive(Debug)]
55pub struct RpcClient {
56    url: String,
57    http: reqwest::Client,
58    block: String,
59    next_id: AtomicI64,
60}
61
62impl RpcClient {
63    /// Build a client posting to `url`.
64    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    /// Build a client from an already-configured `reqwest::Client`.
70    ///
71    /// This is the way to reach a node that authenticates by header, or to share
72    /// a connection pool: set `default_headers` on the `reqwest::Client` and pass
73    /// it in.
74    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    /// Evaluate every [`RpcClient::call`] against this block instead of
84    /// `"latest"` — a decimal or `0x`-prefixed height, or a tag the node knows.
85    ///
86    /// Pinning a block is what makes two quotes comparable: `"latest"` moves
87    /// under you between calls, so a buy priced at one height and a sell priced
88    /// at the next are not describing the same curve.
89    pub fn block(mut self, block: impl Into<String>) -> Self {
90        self.block = block.into();
91        self
92    }
93
94    /// The endpoint this client posts to.
95    pub fn url(&self) -> &str {
96        &self.url
97    }
98
99    /// The block tag [`RpcClient::call`] uses.
100    pub fn block_tag(&self) -> &str {
101        &self.block
102    }
103
104    /// Invoke a JSON-RPC method and return its `result`.
105    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            // `data` is carried through untouched rather than being formatted
140            // into `message`: it is where the revert payload lives, and once it
141            // is text there is no way to tell it apart from an address the node
142            // happened to mention. `eth_call` digs it back out below.
143            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    /// Run `eth_call`, separating a revert from a transport failure.
155    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    /// [`RpcClient::call`] against one specific block, ignoring the configured
165    /// default.
166    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    /// Run `eth_call` and fail on revert, for calls that should never revert.
189    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
202/// Read a response body, refusing to buffer more than [`MAX_RESPONSE_BYTES`].
203///
204/// `Content-Length` is checked first so an oversized body costs nothing, but it
205/// is only a hint — a chunked response does not carry one, and a lying one is
206/// free to send — so the accumulating read is what actually enforces the bound.
207async 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
241/// Dig the revert payload out of a node error.
242///
243/// Every provider spells this differently: geth puts the hex straight in
244/// `error.data`, others nest it as `error.data.data`, and some drop it into the
245/// message text and nothing else. The two structured forms are read first
246/// because they are unambiguous; the text scan is the last resort and takes the
247/// *first* `0x` run it finds, matching the other three SDKs. Falling back to
248/// `"0x"` says "it reverted, and this node would not tell us why".
249fn 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
272/// A revert payload out of one JSON value: a whole hex string, or the first
273/// `0x` run inside prose such as `"Reverted 0xcfa6d878"`.
274fn 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            // `"0x"` counts: it is a revert with the data stripped, which is a
279            // different answer from "this was not a revert at all".
280            return Some(format!("0x{}", body.to_ascii_lowercase()));
281        }
282    }
283    first_hex_run(text)
284}
285
286/// The first `0x`-prefixed run of at least four bytes of hex in some text.
287fn 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        // The regression this whole field exists for: the launchpad proxy is 42
331        // characters and the selector is 10, so any scan that prefers the longest
332        // run classifies every revert as whatever four bytes the address starts
333        // with.
334        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}