polynode 0.6.0

Rust SDK for the PolyNode API — real-time Polymarket data
Documentation
//! Co-signer client — routes orders through the polynode builder attribution proxy.

use hmac::{Hmac, Mac};
use sha2::Sha256;
use std::collections::HashMap;

use crate::error::{Error, Result};
use super::constants::CLOB_HOST;
use super::types::{BuilderCredentials, CosignerRequest};

type HmacSha256 = Hmac<Sha256>;

#[derive(Debug, Clone)]
pub struct CosignerConfig {
    pub cosigner_url: String,
    pub polynode_key: String,
    pub fallback_direct: bool,
    pub builder_credentials: Option<BuilderCredentials>,
}

/// Build L2 HMAC headers for Polymarket CLOB authentication.
pub fn build_l2_headers(
    api_key: &str,
    api_secret: &str,
    api_passphrase: &str,
    wallet_address: &str,
    method: &str,
    path: &str,
    body: Option<&str>,
) -> HashMap<String, String> {
    let timestamp = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap()
        .as_secs()
        .to_string();

    let mut message = format!("{}{}{}", timestamp, method, path);
    if let Some(b) = body {
        message.push_str(b);
    }

    // Secret may be URL-safe base64 (with - and _) or standard (with + and /)
    // Try URL-safe first, fall back to standard
    let secret_bytes = base64::Engine::decode(
        &base64::engine::general_purpose::URL_SAFE_NO_PAD,
        api_secret.trim_end_matches('='),
    ).or_else(|_| base64::Engine::decode(
        &base64::engine::general_purpose::URL_SAFE,
        api_secret,
    )).or_else(|_| base64::Engine::decode(
        &base64::engine::general_purpose::STANDARD,
        api_secret,
    )).unwrap_or_default();

    let mut mac = HmacSha256::new_from_slice(&secret_bytes)
        .expect("HMAC can take key of any size");
    mac.update(message.as_bytes());
    let sig = base64::Engine::encode(
        &base64::engine::general_purpose::STANDARD,
        mac.finalize().into_bytes(),
    )
    .replace('+', "-")
    .replace('/', "_");

    let mut headers = HashMap::new();
    headers.insert("POLY_ADDRESS".into(), wallet_address.into());
    headers.insert("POLY_SIGNATURE".into(), sig);
    headers.insert("POLY_TIMESTAMP".into(), timestamp);
    headers.insert("POLY_API_KEY".into(), api_key.into());
    headers.insert("POLY_PASSPHRASE".into(), api_passphrase.into());
    headers
}

/// Send a request through the co-signer (adds builder headers, forwards to CLOB).
/// Falls back to direct CLOB submission if configured and co-signer fails.
pub async fn send_via_cosigner(
    config: &CosignerConfig,
    request: &CosignerRequest,
) -> Result<serde_json::Value> {
    let client = reqwest::Client::new();

    if !config.cosigner_url.is_empty() {
        match try_cosigner(&client, config, request).await {
            Ok(data) => return Ok(data),
            Err(e) => {
                if config.fallback_direct {
                    tracing::warn!("Co-signer failed, falling back to direct: {}", e);
                    return send_direct(&client, request).await;
                }
                return Err(e);
            }
        }
    }

    send_direct(&client, request).await
}

async fn try_cosigner(
    client: &reqwest::Client,
    config: &CosignerConfig,
    request: &CosignerRequest,
) -> Result<serde_json::Value> {
    let resp = client
        .post(format!("{}/submit", config.cosigner_url))
        .header("Content-Type", "application/json")
        .header("X-PolyNode-Key", &config.polynode_key)
        .json(request)
        .send()
        .await?;

    let status = resp.status().as_u16();
    if status >= 500 {
        return Err(Error::Trading(format!("Co-signer error: {}", status)));
    }

    let data: serde_json::Value = resp.json().await?;

    // Detect Cloudflare blocks forwarded through the cosigner (error code 1010/1020)
    if status == 403 {
        let body_str = data.to_string();
        if body_str.contains("1010") || body_str.contains("1020") || body_str.contains("cloudflare") {
            return Err(Error::Trading("Cloudflare blocked CLOB request via cosigner".into()));
        }
    }

    Ok(data)
}

/// Build an HTTP client that mimics the official Polymarket CLOB TS client.
/// Required to pass Cloudflare WAF on clob.polymarket.com.
fn clob_client() -> reqwest::Client {
    reqwest::Client::builder()
        .user_agent("@polymarket/clob-client")
        .http1_only()
        .build()
        .expect("failed to build HTTP client")
}

/// Send directly to Polymarket CLOB (no builder attribution).
async fn send_direct(
    _client: &reqwest::Client,
    request: &CosignerRequest,
) -> Result<serde_json::Value> {
    let client = clob_client();
    let host = request.clob_host.as_deref().unwrap_or(CLOB_HOST);
    let url = format!("{}{}", host, request.path);

    let mut builder = match request.method.as_str() {
        "GET" => client.get(&url),
        "POST" => client.post(&url),
        "DELETE" => client.delete(&url),
        "PUT" => client.put(&url),
        _ => client.post(&url),
    };

    // Add L2 headers
    for (k, v) in &request.headers {
        builder = builder.header(k, v);
    }
    builder = builder
        .header("Accept", "*/*")
        .header("Connection", "keep-alive")
        .header("Content-Type", "application/json");

    if let Some(body) = &request.body {
        builder = builder.body(body.clone());
    }

    let resp = builder.send().await?;
    let data: serde_json::Value = resp.json().await?;
    Ok(data)
}