polymarket 0.1.0

Rust SDK for Polymarket prediction market - CLOB trading, on-chain operations, and WebSocket streaming
Documentation
use crate::clob::{
    constants::{L0, L1, L2},
    error::{ClobError, Result},
    headers::create_level_1_headers,
    headers::create_level_2_headers,
    types::{ApiCreds, RequestArgs},
    Signer,
};
use reqwest::{Client, RequestBuilder, Response};
use serde::de::DeserializeOwned;
use serde::Serialize;
use std::collections::HashMap;

/// HTTP client for making requests to the CLOB API
pub struct HttpClient {
    client: Client,
    base_url: String,
}

impl HttpClient {
    /// Create a new HTTP client
    ///
    /// # Arguments
    /// * `base_url` - The base URL for the CLOB API
    pub fn new(base_url: impl Into<String>) -> Self {
        Self {
            client: Client::new(),
            base_url: base_url.into(),
        }
    }

    /// Make a GET request
    ///
    /// # Arguments
    /// * `endpoint` - The API endpoint path
    /// * `params` - Optional query parameters
    /// * `auth_level` - Authentication level (L0, L1, or L2)
    /// * `signer` - Optional signer for L1/L2 authentication
    /// * `creds` - Optional API credentials for L2 authentication
    /// * `nonce` - Optional nonce for L1 authentication
    pub async fn get<T: DeserializeOwned>(
        &self,
        endpoint: &str,
        params: Option<&HashMap<String, String>>,
        auth_level: u8,
        signer: Option<&Signer>,
        creds: Option<&ApiCreds>,
        nonce: Option<u64>
    ) -> Result<T> {
        let url = format!("{}{}", self.base_url, endpoint);
        let mut request = self.client.get(&url);

        // Add query parameters
        if let Some(params) = params {
            request = request.query(params);
        }

        // Add authentication headers
        request = self.add_auth_headers(
                request,
                auth_level,
                signer,
                creds,
                "GET",
                endpoint,
                None,
                nonce
            )
            .await?;

        let response = request.send().await?;
        self.handle_response(response).await
    }

    /// Make a POST request
    ///
    /// # Arguments
    /// * `endpoint` - The API endpoint path
    /// * `body` - Request body (will be serialized to JSON)
    /// * `auth_level` - Authentication level (L0, L1, or L2)
    /// * `signer` - Optional signer for L1/L2 authentication
    /// * `creds` - Optional API credentials for L2 authentication
    /// * `nonce` - Optional nonce for L1 authentication
    pub async fn post<T: DeserializeOwned, B: Serialize>(
        &self,
        endpoint: &str,
        body: Option<&B>,
        auth_level: u8,
        signer: Option<&Signer>,
        creds: Option<&ApiCreds>,
        nonce: Option<u64>,
    ) -> Result<T> {
        let url = format!("{}{}", self.base_url, endpoint);
        let mut request = self.client.post(&url);

        // CRITICAL: Serialize body ONCE with Python-compatible format
        // This same string is used for both HMAC and HTTP body to ensure they match exactly
        let json_str_opt = if let Some(body) = body {
            Some(Self::serialize_python_compatible_json(body)?)
        } else {
            None
        };

        // Add authentication headers (will use json_str directly for HMAC)
        request = self.add_auth_headers(
            request,
            auth_level,
            signer,
            creds,
            "POST",
            endpoint,
            json_str_opt.as_deref(),
            nonce,
        )
        .await?;

        // Add the exact same body string to HTTP request
        if let Some(json_str) = json_str_opt {
            request = request.body(json_str);
        }

        let response = request.send().await?;
        self.handle_response(response).await
    }

    /// Make a DELETE request
    ///
    /// # Arguments
    /// * `endpoint` - The API endpoint path
    /// * `body` - Optional request body (will be serialized to JSON)
    /// * `auth_level` - Authentication level (L0, L1, or L2)
    /// * `signer` - Optional signer for L1/L2 authentication
    /// * `creds` - Optional API credentials for L2 authentication
    /// * `nonce` - Optional nonce for L1 authentication
    pub async fn delete<T: DeserializeOwned, B: Serialize>(
        &self,
        endpoint: &str,
        body: Option<&B>,
        auth_level: u8,
        signer: Option<&Signer>,
        creds: Option<&ApiCreds>,
        nonce: Option<u64>,
    ) -> Result<T> {
        let url = format!("{}{}", self.base_url, endpoint);
        let mut request = self.client.delete(&url);

        // CRITICAL: Serialize body ONCE with Python-compatible format
        // This same string is used for both HMAC and HTTP body to ensure they match exactly
        let json_str_opt = if let Some(body) = body {
            Some(Self::serialize_python_compatible_json(body)?)
        } else {
            None
        };

        // Add authentication headers (will use json_str directly for HMAC)
        request = self
            .add_auth_headers(
                request,
                auth_level,
                signer,
                creds,
                "DELETE",
                endpoint,
                json_str_opt.as_deref(),
                nonce,
            )
            .await?;

        // Add the exact same body string to HTTP request
        if let Some(json_str) = json_str_opt {
            request = request.body(json_str);
        }

        let response = request.send().await?;
        self.handle_response(response).await
    }

    /// Serialize data to JSON with Python-compatible format (spaces after : and ,)
    ///
    /// Python's json.dumps() default format: {"key": "value", "key2": 123}
    /// Rust's serde_json compact format: {"key":"value","key2":123}
    ///
    /// This function produces Python-compatible format for HMAC signature matching
    /// Note: Does NOT sort keys - preserves struct field order from serde serialization
    fn serialize_python_compatible_json<S: Serialize>(data: &S) -> Result<String> {
        // First serialize with compact format (preserves struct field order)
        let compact = serde_json::to_string(data)
            .map_err(|e| ClobError::InvalidOrder(format!("JSON serialization failed: {}", e)))?;

        // Add space after : and , to match Python's format
        let mut result = String::with_capacity(compact.len() + 100);
        let mut chars = compact.chars();

        while let Some(c) = chars.next() {
            result.push(c);
            if c == ':' || c == ',' {
                // Add space after colon or comma
                result.push(' ');
            }
        }

        Ok(result)
    }

    /// Add authentication headers based on level
    async fn add_auth_headers(
        &self,
        mut request: RequestBuilder,
        auth_level: u8,
        signer: Option<&Signer>,
        creds: Option<&ApiCreds>,
        method: &str,
        endpoint: &str,
        body: Option<&str>,
        nonce: Option<u64>,
    ) -> Result<RequestBuilder> {
        // Add standard headers (matching Python client)
        // Note: Don't manually set Accept-Encoding - reqwest handles gzip/deflate automatically
        request = request
            .header("Content-Type", "application/json")
            .header("Accept", "*/*")
            .header("Connection", "keep-alive")
            .header("User-Agent", "polymarket-rs-sdk");

        match auth_level {
            L0 => {
                // Public endpoint, no auth needed
                Ok(request)
            }
            L1 => {
                // Wallet signature required
                let signer = signer.ok_or_else(|| {
                    ClobError::AuthError("Signer required for L1 auth".to_string())
                })?;
                let headers = create_level_1_headers(signer, nonce).await?;
                for (key, value) in headers {
                    request = request.header(key, value);
                }
                Ok(request)
            }
            L2 => {
                // API key required
                let signer = signer.ok_or_else(|| {
                    ClobError::AuthError("Signer required for L2 auth".to_string())
                })?;
                let creds = creds.ok_or_else(|| {
                    ClobError::AuthError(
                        "API credentials required for L2 auth".to_string(),
                    )
                })?;

                let request_args = RequestArgs {
                    method: method.to_string(),
                    request_path: endpoint.to_string(),
                    body: body.map(|s| s.to_owned()),
                };

                let headers = create_level_2_headers(signer, creds, &request_args).await?;

                for (key, value) in headers {
                    request = request.header(key, value);
                }
                Ok(request)
            }
            _ => Err(ClobError::InvalidOrder(format!(
                "Invalid auth level: {}",
                auth_level
            ))),
        }
    }

    /// Handle the HTTP response
    async fn handle_response<T: DeserializeOwned>(&self, response: Response) -> Result<T> {
        let status = response.status();

        if status.is_success() {
            let text = response.text().await?;
            serde_json::from_str(&text).map_err(|e| {
                ClobError::InvalidOrder(format!("Failed to parse response: {}. Body: {}", e, text))
            })
        } else {
            let error_text = response.text().await?;
            eprintln!("❌ API Error ({}): {}", status, &error_text);
            Err(ClobError::ApiError {
                status: status.as_u16(),
                message: error_text,
            })
        }
    }
}

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

    #[test]
    fn test_http_client_creation() {
        let client = HttpClient::new("https://clob.polymarket.com");
        assert_eq!(client.base_url, "https://clob.polymarket.com");
    }

    #[test]
    fn test_url_construction() {
        let client = HttpClient::new("https://clob.polymarket.com");
        let url = format!("{}{}", client.base_url, "/sampling-markets");
        assert_eq!(url, "https://clob.polymarket.com/sampling-markets");
    }
}