predict-fun-sdk 0.4.0

Rust SDK for the Predict.fun prediction market API — EIP-712 order signing, REST client, WebSocket feeds, and execution pipeline
Documentation
//! Predict.fun REST API client.
//!
//! Thin wrapper around the [OpenAPI surface](https://api.predict.fun/docs)
//! with typed methods for all 30 endpoints.

use anyhow::{anyhow, Context, Result};
use reqwest::{Method, StatusCode};
use serde_json::{json, Value};

pub const PREDICT_MAINNET_BASE: &str = "https://api.predict.fun/v1";
pub const PREDICT_TESTNET_BASE: &str = "https://api-testnet.predict.fun/v1";

/// Raw API response for diagnostics. Only returned by `raw_get`/`raw_post`.
#[derive(Debug, Clone)]
pub struct RawApiResponse {
    pub status: StatusCode,
    pub json: Option<Value>,
}

/// Thin REST wrapper around Predict's OpenAPI surface (BNB Chain).
///
/// Most methods return `serde_json::Value` intentionally to keep it resilient
/// to API/schema drift while we stabilize integration.
///
/// Connection pooling: HTTP/2 with keep-alive, 16 idle conns per host.
#[derive(Clone)]
pub struct PredictApiClient {
    base: String,
    api_key: Option<String>,
    jwt: Option<String>,
    http: reqwest::Client,
}

impl PredictApiClient {
    pub fn new_mainnet(api_key: impl Into<String>) -> Result<Self> {
        Self::new(PREDICT_MAINNET_BASE, Some(api_key.into()), None)
    }

    pub fn new_testnet() -> Result<Self> {
        Self::new(PREDICT_TESTNET_BASE, None, None)
    }

    pub fn new(
        base: impl Into<String>,
        api_key: Option<String>,
        jwt: Option<String>,
    ) -> Result<Self> {
        let http = reqwest::Client::builder()
            .timeout(std::time::Duration::from_secs(10))
            .connect_timeout(std::time::Duration::from_secs(5))
            .pool_max_idle_per_host(16)
            .pool_idle_timeout(std::time::Duration::from_secs(90))
            .tcp_keepalive(std::time::Duration::from_secs(30))
            .build()
            .context("failed to build predict api client")?;

        Ok(Self {
            base: base.into().trim_end_matches('/').to_string(),
            api_key,
            jwt,
            http,
        })
    }

    pub fn with_jwt(mut self, jwt: impl Into<String>) -> Self {
        self.jwt = Some(jwt.into());
        self
    }

    pub fn set_jwt(&mut self, jwt: impl Into<String>) {
        self.jwt = Some(jwt.into());
    }

    pub fn clear_jwt(&mut self) {
        self.jwt = None;
    }

    pub fn has_jwt(&self) -> bool {
        self.jwt.is_some()
    }

    // === Auth ===

    pub async fn auth_message(&self) -> Result<Value> {
        self.get_ok("/auth/message", &[], false).await
    }

    pub async fn auth(&self, signer: &str, message: &str, signature: &str) -> Result<Value> {
        let body = json!({
            "signer": signer,
            "message": message,
            "signature": signature,
        });
        self.post_ok("/auth", &[], body, false).await
    }

    // === Orders ===

    pub async fn create_order(&self, body: Value) -> Result<Value> {
        self.post_ok("/orders", &[], body, true).await
    }

    pub async fn list_orders(&self, query: &[(&str, String)]) -> Result<Value> {
        self.get_ok("/orders", query, true).await
    }

    pub async fn remove_orders(&self, body: Value) -> Result<Value> {
        self.post_ok("/orders/remove", &[], body, true).await
    }

    pub async fn get_order(&self, hash: &str) -> Result<Value> {
        self.get_ok(&format!("/orders/{}", hash), &[], true).await
    }

    pub async fn get_order_matches(&self, query: &[(&str, String)]) -> Result<Value> {
        self.get_ok("/orders/matches", query, false).await
    }

    // === Markets ===

    pub async fn list_markets(&self, query: &[(&str, String)]) -> Result<Value> {
        self.get_ok("/markets", query, false).await
    }

    pub async fn get_market(&self, id: i64) -> Result<Value> {
        self.get_ok(&format!("/markets/{}", id), &[], false).await
    }

    pub async fn get_market_stats(&self, id: i64) -> Result<Value> {
        self.get_ok(&format!("/markets/{}/stats", id), &[], false)
            .await
    }

    pub async fn get_market_last_sale(&self, id: i64) -> Result<Value> {
        self.get_ok(&format!("/markets/{}/last-sale", id), &[], false)
            .await
    }

    pub async fn get_market_orderbook(&self, id: i64) -> Result<Value> {
        self.get_ok(&format!("/markets/{}/orderbook", id), &[], false)
            .await
    }

    pub async fn get_market_timeseries(&self, id: i64, query: &[(&str, String)]) -> Result<Value> {
        self.get_ok(&format!("/markets/{}/timeseries", id), query, false)
            .await
    }

    pub async fn get_market_timeseries_latest(&self, id: i64) -> Result<Value> {
        self.get_ok(&format!("/markets/{}/timeseries/latest", id), &[], false)
            .await
    }

    // === Categories / tags ===

    pub async fn list_categories(&self, query: &[(&str, String)]) -> Result<Value> {
        self.get_ok("/categories", query, false).await
    }

    pub async fn get_category(&self, slug: &str) -> Result<Value> {
        self.get_ok(&format!("/categories/{}", slug), &[], false)
            .await
    }

    pub async fn get_category_stats(&self, id: i64) -> Result<Value> {
        self.get_ok(&format!("/categories/{}/stats", id), &[], false)
            .await
    }

    pub async fn list_tags(&self) -> Result<Value> {
        self.get_ok("/tags", &[], false).await
    }

    // === Positions ===

    pub async fn list_positions(&self, query: &[(&str, String)]) -> Result<Value> {
        self.get_ok("/positions", query, true).await
    }

    pub async fn list_positions_for_address(
        &self,
        address: &str,
        query: &[(&str, String)],
    ) -> Result<Value> {
        self.get_ok(&format!("/positions/{}", address), query, false)
            .await
    }

    // === Account ===

    pub async fn account(&self) -> Result<Value> {
        self.get_ok("/account", &[], true).await
    }

    pub async fn set_referral(&self, code: &str) -> Result<Value> {
        let body = json!({ "code": code });
        self.post_ok("/account/referral", &[], body, true).await
    }

    pub async fn account_activity(&self, query: &[(&str, String)]) -> Result<Value> {
        self.get_ok("/account/activity", query, true).await
    }

    // === OAuth ===

    pub async fn oauth_finalize(&self, body: Value) -> Result<Value> {
        self.post_ok("/oauth/finalize", &[], body, false).await
    }

    pub async fn oauth_orders(&self, body: Value) -> Result<Value> {
        self.post_ok("/oauth/orders", &[], body, false).await
    }

    pub async fn oauth_create_order(&self, body: Value) -> Result<Value> {
        self.post_ok("/oauth/orders/create", &[], body, false).await
    }

    pub async fn oauth_cancel_order(&self, body: Value) -> Result<Value> {
        self.post_ok("/oauth/orders/cancel", &[], body, false).await
    }

    pub async fn oauth_positions(&self, body: Value) -> Result<Value> {
        self.post_ok("/oauth/positions", &[], body, false).await
    }

    // === Search / Yield ===

    pub async fn search(&self, query: &[(&str, String)]) -> Result<Value> {
        self.get_ok("/search", query, false).await
    }

    pub async fn yield_pending(&self, query: &[(&str, String)]) -> Result<Value> {
        self.get_ok("/yield/pending", query, true).await
    }

    // === Raw helpers (for probes / diagnostics) ===

    pub async fn raw_get(
        &self,
        path: &str,
        query: &[(&str, String)],
        require_jwt: bool,
    ) -> Result<RawApiResponse> {
        self.raw_request(Method::GET, path, query, None, require_jwt)
            .await
    }

    pub async fn raw_post(
        &self,
        path: &str,
        query: &[(&str, String)],
        body: Value,
        require_jwt: bool,
    ) -> Result<RawApiResponse> {
        self.raw_request(Method::POST, path, query, Some(body), require_jwt)
            .await
    }

    // === Internal ===

    async fn get_ok(
        &self,
        path: &str,
        query: &[(&str, String)],
        require_jwt: bool,
    ) -> Result<Value> {
        let resp = self
            .raw_request(Method::GET, path, query, None, require_jwt)
            .await?;
        self.expect_success(resp, "GET", path)
    }

    async fn post_ok(
        &self,
        path: &str,
        query: &[(&str, String)],
        body: Value,
        require_jwt: bool,
    ) -> Result<Value> {
        let resp = self
            .raw_request(Method::POST, path, query, Some(body), require_jwt)
            .await?;
        self.expect_success(resp, "POST", path)
    }

    fn expect_success(&self, resp: RawApiResponse, method: &str, path: &str) -> Result<Value> {
        if !resp.status.is_success() {
            let body_str = resp
                .json
                .as_ref()
                .map(|j| j.to_string())
                .unwrap_or_default();
            return Err(anyhow!(
                "Predict API {} {} failed: status={} body={}",
                method,
                path,
                resp.status,
                &body_str[..body_str.len().min(500)]
            ));
        }

        resp.json
            .ok_or_else(|| anyhow!("Predict API {} {} returned non-JSON body", method, path))
    }

    async fn raw_request(
        &self,
        method: Method,
        path: &str,
        query: &[(&str, String)],
        body: Option<Value>,
        require_jwt: bool,
    ) -> Result<RawApiResponse> {
        if require_jwt && self.jwt.is_none() {
            return Err(anyhow!(
                "JWT required for {} {} but not set — call authenticate first",
                method,
                path
            ));
        }

        let url = format!("{}{}", self.base, path);
        let mut req = self
            .http
            .request(method.clone(), &url)
            .header("Accept", "application/json")
            .header("Content-Type", "application/json");

        if !query.is_empty() {
            req = req.query(query);
        }

        if let Some(api_key) = &self.api_key {
            req = req.header("x-api-key", api_key);
        }

        if let Some(jwt) = &self.jwt {
            req = req.header("Authorization", format!("Bearer {}", jwt));
        }

        if let Some(v) = body {
            req = req.json(&v);
        }

        let resp = req
            .send()
            .await
            .with_context(|| format!("predict api {} {} failed", method, path))?;

        let status = resp.status();
        let json = resp.json::<Value>().await.ok();

        Ok(RawApiResponse { status, json })
    }
}

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

    #[test]
    fn client_construction() {
        let client = PredictApiClient::new_mainnet("test-key").unwrap();
        assert!(!client.has_jwt());

        let client = client.with_jwt("token123");
        assert!(client.has_jwt());
    }
}