fyuz-sdk 0.1.1

Async Rust client for the Fyuz public REST API — bonding-curve token market data on BNB Smart Chain.
Documentation
//! A JSON-RPC client just large enough to read the bonding curve.
//!
//! `eth_call` against a view function is an HTTP POST with a JSON body. That is
//! the whole requirement, so this rides on the `reqwest` client the crate
//! already carries rather than pulling in a web3 stack.

use std::sync::atomic::{AtomicI64, Ordering};
use std::time::Duration;

use serde::Deserialize;
use serde_json::{json, Value};

use crate::Error;

/// Default per-call timeout for RPC requests.
pub const DEFAULT_RPC_TIMEOUT: Duration = Duration::from_secs(15);

/// Default block tag every `eth_call` is evaluated against.
pub const DEFAULT_BLOCK_TAG: &str = "latest";

/// Ceiling on a single response body, shared with the REST client.
///
/// A JSON-RPC answer to `getSwapOutput` is a few hundred bytes, so a megabyte is
/// four orders of magnitude of headroom for either transport.
use crate::client::MAX_RESPONSE_BYTES;

/// The outcome of an `eth_call`: return data, or revert data.
///
/// Reverts are extremely common here — quoting a graduated token reverts by
/// design — so they are a return value rather than an error. The caller decides
/// what a revert means and builds the error with the right context.
#[derive(Debug, Clone)]
pub enum CallOutcome {
    /// The call returned data.
    Ok(String),
    /// The call reverted, carrying this payload (possibly `"0x"`).
    Reverted(String),
}

#[derive(Debug, Deserialize)]
struct RpcResponse {
    result: Option<Value>,
    error: Option<RpcErrorBody>,
}

#[derive(Debug, Deserialize)]
struct RpcErrorBody {
    code: Option<i64>,
    message: Option<String>,
    data: Option<Value>,
}

/// Minimal JSON-RPC transport.
#[derive(Debug)]
pub struct RpcClient {
    url: String,
    http: reqwest::Client,
    block: String,
    next_id: AtomicI64,
}

impl RpcClient {
    /// Build a client posting to `url`.
    pub fn new(url: impl Into<String>, timeout: Duration) -> Result<Self, Error> {
        let http = reqwest::Client::builder().timeout(timeout).build()?;
        Ok(Self::with_http(url, http))
    }

    /// Build a client from an already-configured `reqwest::Client`.
    ///
    /// This is the way to reach a node that authenticates by header, or to share
    /// a connection pool: set `default_headers` on the `reqwest::Client` and pass
    /// it in.
    pub fn with_http(url: impl Into<String>, http: reqwest::Client) -> Self {
        Self {
            url: url.into(),
            http,
            block: DEFAULT_BLOCK_TAG.to_string(),
            next_id: AtomicI64::new(1),
        }
    }

    /// Evaluate every [`RpcClient::call`] against this block instead of
    /// `"latest"` — a decimal or `0x`-prefixed height, or a tag the node knows.
    ///
    /// Pinning a block is what makes two quotes comparable: `"latest"` moves
    /// under you between calls, so a buy priced at one height and a sell priced
    /// at the next are not describing the same curve.
    pub fn block(mut self, block: impl Into<String>) -> Self {
        self.block = block.into();
        self
    }

    /// The endpoint this client posts to.
    pub fn url(&self) -> &str {
        &self.url
    }

    /// The block tag [`RpcClient::call`] uses.
    pub fn block_tag(&self) -> &str {
        &self.block
    }

    /// Invoke a JSON-RPC method and return its `result`.
    pub async fn request(&self, method: &str, params: Value) -> Result<Value, Error> {
        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
        let body = json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params });

        let response = self
            .http
            .post(&self.url)
            .json(&body)
            .send()
            .await
            .map_err(|source| Error::RpcTransport {
                method: method.to_string(),
                source,
            })?;
        let status = response.status();
        let text = read_capped(response, method).await?;

        if !status.is_success() {
            return Err(Error::Rpc {
                method: method.to_string(),
                message: format!("HTTP {}: {}", status.as_u16(), truncate(&text, 256)),
                code: None,
                data: None,
            });
        }

        let decoded: RpcResponse = serde_json::from_str(&text).map_err(|_| Error::Rpc {
            method: method.to_string(),
            message: format!("body is not a JSON-RPC response: {}", truncate(&text, 256)),
            code: None,
            data: None,
        })?;

        if let Some(error) = decoded.error {
            // `data` is carried through untouched rather than being formatted
            // into `message`: it is where the revert payload lives, and once it
            // is text there is no way to tell it apart from an address the node
            // happened to mention. `eth_call` digs it back out below.
            return Err(Error::Rpc {
                method: method.to_string(),
                message: error.message.unwrap_or_else(|| "unknown error".to_string()),
                code: error.code,
                data: error.data,
            });
        }

        Ok(decoded.result.unwrap_or(Value::Null))
    }

    /// Run `eth_call`, separating a revert from a transport failure.
    pub async fn call(
        &self,
        to: &str,
        data: &str,
        from: Option<&str>,
    ) -> Result<CallOutcome, Error> {
        self.call_at(to, data, from, &self.block).await
    }

    /// [`RpcClient::call`] against one specific block, ignoring the configured
    /// default.
    pub async fn call_at(
        &self,
        to: &str,
        data: &str,
        from: Option<&str>,
        block: &str,
    ) -> Result<CallOutcome, Error> {
        let mut message = json!({ "to": to, "data": data });
        if let Some(from) = from {
            message["from"] = Value::String(from.to_string());
        }

        match self.request("eth_call", json!([message, block])).await {
            Ok(Value::String(result)) => Ok(CallOutcome::Ok(result)),
            Ok(_) => Ok(CallOutcome::Ok("0x".to_string())),
            Err(error) => match extract_revert_data(&error) {
                Some(revert) => Ok(CallOutcome::Reverted(revert)),
                None => Err(error),
            },
        }
    }

    /// Run `eth_call` and fail on revert, for calls that should never revert.
    pub async fn call_or_error(
        &self,
        to: &str,
        data: &str,
        context: &str,
    ) -> Result<String, Error> {
        match self.call(to, data, None).await? {
            CallOutcome::Ok(result) => Ok(result),
            CallOutcome::Reverted(revert) => Err(crate::revert::revert_error(&revert, context)),
        }
    }
}

/// Read a response body, refusing to buffer more than [`MAX_RESPONSE_BYTES`].
///
/// `Content-Length` is checked first so an oversized body costs nothing, but it
/// is only a hint — a chunked response does not carry one, and a lying one is
/// free to send — so the accumulating read is what actually enforces the bound.
async fn read_capped(mut response: reqwest::Response, method: &str) -> Result<String, Error> {
    let too_big = |seen: u64| Error::Rpc {
        method: method.to_string(),
        message: format!(
            "response body is at least {seen} bytes, past the {MAX_RESPONSE_BYTES}-byte ceiling"
        ),
        code: None,
        data: None,
    };

    if let Some(declared) = response.content_length() {
        if declared > MAX_RESPONSE_BYTES as u64 {
            return Err(too_big(declared));
        }
    }

    let mut body: Vec<u8> = Vec::new();
    while let Some(chunk) = response
        .chunk()
        .await
        .map_err(|source| Error::RpcTransport {
            method: method.to_string(),
            source,
        })?
    {
        if body.len() + chunk.len() > MAX_RESPONSE_BYTES {
            return Err(too_big((body.len() + chunk.len()) as u64));
        }
        body.extend_from_slice(&chunk);
    }

    Ok(String::from_utf8_lossy(&body).into_owned())
}

/// Dig the revert payload out of a node error.
///
/// Every provider spells this differently: geth puts the hex straight in
/// `error.data`, others nest it as `error.data.data`, and some drop it into the
/// message text and nothing else. The two structured forms are read first
/// because they are unambiguous; the text scan is the last resort and takes the
/// *first* `0x` run it finds, matching the other three SDKs. Falling back to
/// `"0x"` says "it reverted, and this node would not tell us why".
fn extract_revert_data(error: &Error) -> Option<String> {
    let Error::Rpc { message, data, .. } = error else {
        return None;
    };

    if let Some(data) = data {
        if let Some(found) = payload_from(data) {
            return Some(found);
        }
        if let Some(found) = payload_from(&data["data"]) {
            return Some(found);
        }
    }

    if let Some(found) = first_hex_run(message) {
        return Some(found);
    }
    message
        .to_ascii_lowercase()
        .contains("revert")
        .then(|| "0x".to_string())
}

/// A revert payload out of one JSON value: a whole hex string, or the first
/// `0x` run inside prose such as `"Reverted 0xcfa6d878"`.
fn payload_from(value: &Value) -> Option<String> {
    let text = value.as_str()?;
    if let Some(body) = text.strip_prefix("0x").or_else(|| text.strip_prefix("0X")) {
        if body.len() % 2 == 0 && body.chars().all(|c| c.is_ascii_hexdigit()) {
            // `"0x"` counts: it is a revert with the data stripped, which is a
            // different answer from "this was not a revert at all".
            return Some(format!("0x{}", body.to_ascii_lowercase()));
        }
    }
    first_hex_run(text)
}

/// The first `0x`-prefixed run of at least four bytes of hex in some text.
fn first_hex_run(text: &str) -> Option<String> {
    let bytes = text.as_bytes();
    let mut index = 0;
    while index + 2 <= bytes.len() {
        if bytes[index] == b'0' && (bytes[index + 1] == b'x' || bytes[index + 1] == b'X') {
            let start = index;
            let mut end = index + 2;
            while end < bytes.len() && (bytes[end] as char).is_ascii_hexdigit() {
                end += 1;
            }
            if end - start >= 10 {
                return Some(text[start..end].to_ascii_lowercase());
            }
            index = end;
        } else {
            index += 1;
        }
    }
    None
}

fn truncate(text: &str, limit: usize) -> &str {
    match text.char_indices().nth(limit) {
        Some((index, _)) => &text[..index],
        None => text,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn rpc(message: &str, data: Option<Value>) -> Error {
        Error::Rpc {
            method: "eth_call".to_string(),
            message: message.to_string(),
            code: Some(3),
            data,
        }
    }

    #[test]
    fn structured_data_beats_an_address_in_the_message() {
        // The regression this whole field exists for: the launchpad proxy is 42
        // characters and the selector is 10, so any scan that prefers the longest
        // run classifies every revert as whatever four bytes the address starts
        // with.
        let error = rpc(
            "execution reverted: revert at 0x33a98bef6496684a8dac83734d9ceb0cefc7019c",
            Some(json!("0xcfa6d878")),
        );
        assert_eq!(extract_revert_data(&error).as_deref(), Some("0xcfa6d878"));
    }

    #[test]
    fn reads_the_nested_data_form() {
        let error = rpc(
            "execution reverted",
            Some(json!({ "message": "execution reverted", "data": "0x8199F5F3" })),
        );
        assert_eq!(extract_revert_data(&error).as_deref(), Some("0x8199f5f3"));
    }

    #[test]
    fn falls_back_to_the_first_run_in_the_message() {
        let error = rpc("execution reverted: 0xcfa6d878 at 0xdeadbeefcafe", None);
        assert_eq!(extract_revert_data(&error).as_deref(), Some("0xcfa6d878"));
    }

    #[test]
    fn a_stripped_payload_is_still_a_revert() {
        assert_eq!(
            extract_revert_data(&rpc("execution reverted", Some(json!("0x")))).as_deref(),
            Some("0x")
        );
        assert_eq!(
            extract_revert_data(&rpc("execution reverted", None)).as_deref(),
            Some("0x")
        );
    }

    #[test]
    fn a_plain_node_failure_is_not_a_revert() {
        assert_eq!(extract_revert_data(&rpc("header not found", None)), None);
        assert_eq!(
            extract_revert_data(&Error::InvalidArgument {
                message: "nope".to_string()
            }),
            None
        );
    }

    #[test]
    fn digs_a_payload_out_of_prose_in_the_data_field() {
        let error = rpc(
            "execution reverted",
            Some(json!("Reverted 0xcfa6d878, gas 21000")),
        );
        assert_eq!(extract_revert_data(&error).as_deref(), Some("0xcfa6d878"));
    }
}