polymarket 0.1.0

Rust SDK for Polymarket prediction market - CLOB trading, on-chain operations, and WebSocket streaming
Documentation
use base64::{engine::general_purpose::URL_SAFE, Engine};
use hmac::{Hmac, Mac};
use sha2::Sha256;

use crate::clob::{ClobError, Result};

type HmacSha256 = Hmac<Sha256>;

/// Builds an HMAC signature for API authentication
///
/// Creates an HMAC-SHA256 signature by signing a payload with the secret.
/// The message format is: timestamp + method + requestPath + body (if present)
///
/// # Arguments
/// * `secret` - Base64 URL-safe encoded secret key
/// * `timestamp` - Unix timestamp as string
/// * `method` - HTTP method (GET, POST, DELETE, etc.)
/// * `request_path` - API endpoint path
/// * `body` - Optional request body (JSON string)
///
/// # Returns
/// Base64 URL-safe encoded HMAC signature
pub fn build_hmac_signature(
    secret: &str,
    timestamp: &str,
    method: &str,
    request_path: &str,
    body: Option<&str>,
) -> Result<String> {
    // Validate secret is not empty
    if secret.is_empty() {
        return Err(ClobError::SigningError(
            "API secret is empty. Set CLOB_API_SECRET or derive credentials first.".to_string()
        ));
    }

    let secret_bytes = URL_SAFE.decode(secret)
        .map_err(|e| {
            ClobError::SigningError(format!(
                "Invalid API secret format (must be base64): {}. Secret length: {}",
                e,
                secret.len()
            ))
        })?;

    // Build the message to sign
    let mut message = format!("{}{}{}", timestamp, method, request_path);
    if let Some(body_str) = body {
        // Note: Single quotes must be replaced with double quotes
        // to generate the same HMAC as Go and TypeScript implementations
        message.push_str(&body_str.replace('\'', "\""));
    }

    // Create HMAC
    let mut mac = HmacSha256::new_from_slice(&secret_bytes)
        .map_err(|e| ClobError::SigningError(e.to_string()))?;

    mac.update(message.as_bytes());

    let result = mac.finalize();
    let signature_bytes = result.into_bytes();

    // Base64 encode the signature
    // IMPORTANT: Python uses urlsafe_b64encode which INCLUDES padding
    // So we must use URL_SAFE (with padding), not URL_SAFE_NO_PAD
    Ok(URL_SAFE.encode(signature_bytes))
}

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

    #[test]
    fn test_hmac_signature() {
        // Test basic HMAC generation
        let secret = "dGVzdHNlY3JldA=="; // "testsecret" base64 encoded with proper padding
        let timestamp = "1234567890";
        let method = "GET";
        let path = "/test";

        let sig = build_hmac_signature(secret, timestamp, method, path, None);
        assert!(sig.is_ok());
    }

    #[test]
    fn test_hmac_with_body() {
        let secret = "dGVzdHNlY3JldA=="; // "testsecret" base64 encoded with proper padding
        let timestamp = "1234567890";
        let method = "POST";
        let path = "/test";
        let body = r#"{"key":"value"}"#;

        let sig = build_hmac_signature(secret, timestamp, method, path, Some(body));
        assert!(sig.is_ok());
    }

    #[test]
    fn test_hmac_matches_python() {
        // Test case from Python client - MUST match exactly
        // Python uses urlsafe_b64encode which INCLUDES padding (=)
        let secret = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
        let timestamp = "1000000";
        let method = "test-sign";
        let path = "/orders";
        let body = r#"{"hash": "0x123"}"#;

        let signature = build_hmac_signature(secret, timestamp, method, path, Some(body)).unwrap();

        // Expected signature from Python test - with padding
        let expected = "ZwAdJKvoYRlEKDkNMwd5BuwNNtg93kNaR_oU2HrfVvc=";

        assert_eq!(
            signature, expected,
            "HMAC signature should match Python implementation"
        );
    }
}