polymarket 0.1.0

Rust SDK for Polymarket prediction market - CLOB trading, on-chain operations, and WebSocket streaming
Documentation
use alloy::primitives::Address;
use std::str::FromStr;

use crate::onchain::error::{OnchainError, Result};

/// Network configuration for Polygon networks
#[derive(Debug, Clone)]
pub struct NetworkConfig {
    /// Chain ID (137 for mainnet, 80002 for Amoy testnet)
    pub chain_id: u64,

    /// RPC URL for the network
    pub rpc_url: String,

    /// Contract addresses for this network
    pub contracts: ContractAddresses,
}

/// Contract addresses for Polymarket on a specific network
#[derive(Debug, Clone)]
pub struct ContractAddresses {
    /// Proxy Wallet Factory - used for email/Magic accounts
    pub proxy_factory: Address,

    /// Safe (Gnosis Safe) Factory
    pub safe_factory: Address,

    /// USDC token address
    pub usdc: Address,

    /// Conditional Tokens Framework (CTF) - main contract
    pub ctf: Address,

    /// Negative Risk Adapter - for certain market types
    pub neg_risk_adapter: Address,

    /// CTF Exchange - for trading
    pub exchange: Address,

    /// Safe Singleton contract
    pub safe_singleton: Address,
}

impl NetworkConfig {
    /// Create configuration for Polygon mainnet
    pub fn polygon_mainnet() -> Self {
        Self {
            chain_id: 137,
            rpc_url: "https://polygon-rpc.com".to_string(),
            contracts: ContractAddresses::polygon_mainnet(),
        }
    }

    /// Create configuration for Polygon Amoy testnet
    pub fn polygon_amoy() -> Self {
        Self {
            chain_id: 80002,
            rpc_url: "https://rpc-amoy.polygon.technology".to_string(),
            contracts: ContractAddresses::polygon_amoy(),
        }
    }

    /// Create a custom network configuration
    ///
    /// # Arguments
    /// * `chain_id` - The chain ID
    /// * `rpc_url` - The RPC endpoint URL
    /// * `contracts` - Contract addresses for this network
    pub fn custom(chain_id: u64, rpc_url: String, contracts: ContractAddresses) -> Self {
        Self {
            chain_id,
            rpc_url,
            contracts,
        }
    }

    /// Set a custom RPC URL
    pub fn with_rpc_url(mut self, rpc_url: String) -> Self {
        self.rpc_url = rpc_url;
        self
    }
}

impl Default for NetworkConfig {
    fn default() -> Self {
        Self::polygon_mainnet()
    }
}

impl ContractAddresses {
    /// Polygon mainnet contract addresses
    pub fn polygon_mainnet() -> Self {
        Self {
            proxy_factory: parse_address("0xaB45c5A4B0c941a2F231C04C3f49182e1A254052"),
            safe_factory: parse_address("0xa6B71E26C5e0845f74c812102Ca7114b6a896AB2"),
            usdc: parse_address("0x2791bca1f2de4661ed88a30c99a7a9449aa84174"),
            ctf: parse_address("0x4D97DCd97eC945f40cF65F87097ACe5EA0476045"),
            neg_risk_adapter: parse_address("0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296"),
            exchange: parse_address("0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E"),
            safe_singleton: parse_address("0xd9Db270c1B5E3Bd161E8c8503c55cEABeE709552"),
        }
    }

    /// Polygon Amoy testnet contract addresses
    pub fn polygon_amoy() -> Self {
        Self {
            proxy_factory: parse_address("0x0000000000000000000000000000000000000000"), // TODO: Update with actual testnet address
            safe_factory: parse_address("0xa6B71E26C5e0845f74c812102Ca7114b6a896AB2"), // Same as mainnet
            usdc: parse_address("0x9c4e1703476e875070ee25b56a58b008cfb8fa78"), // Test USDC
            ctf: parse_address("0x69308FB512518e39F9b16112fA8d994F4e2Bf8bB"),
            neg_risk_adapter: parse_address("0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296"),
            exchange: parse_address("0xdFE02Eb6733538f8Ea35D585af8DE5958AD99E40"),
            safe_singleton: parse_address("0xd9Db270c1B5E3Bd161E8c8503c55cEABeE709552"),
        }
    }
}

/// Helper to parse address (panics on invalid - these are hardcoded constants)
fn parse_address(addr: &str) -> Address {
    Address::from_str(addr).expect("Invalid hardcoded address")
}

/// USDC has 6 decimals on Polygon
pub const USDC_DECIMALS: u8 = 6;

/// Helper to parse USDC amount from human-readable string
///
/// # Example
/// ```
/// use polymarket_sdk::onchain::parse_usdc;
/// let amount = parse_usdc("10.5").unwrap(); // 10_500_000 (6 decimals)
/// ```
pub fn parse_usdc(amount: &str) -> Result<alloy::primitives::U256> {
    let parts: Vec<&str> = amount.split('.').collect();

    match parts.len() {
        1 => {
            // No decimal point - just whole dollars
            let dollars: u64 = parts[0].parse().map_err(|e| {
                OnchainError::InvalidAmount(format!("Invalid USDC amount '{}': {}", amount, e))
            })?;
            Ok(alloy::primitives::U256::from(dollars) * alloy::primitives::U256::from(1_000_000))
        }
        2 => {
            // Has decimal point
            let dollars: u64 = if parts[0].is_empty() {
                0
            } else {
                parts[0].parse().map_err(|e| {
                    OnchainError::InvalidAmount(format!("Invalid USDC amount '{}': {}", amount, e))
                })?
            };

            // Pad or truncate cents to 6 digits
            let cents_str = parts[1];
            if cents_str.len() > 6 {
                return Err(OnchainError::InvalidAmount(
                    format!("Too many decimal places in '{}' (max 6)", amount)
                ));
            }

            let cents_padded = format!("{:0<6}", cents_str); // Pad with zeros
            let cents: u64 = cents_padded.parse().map_err(|e| {
                OnchainError::InvalidAmount(format!("Invalid USDC amount '{}': {}", amount, e))
            })?;

            Ok(alloy::primitives::U256::from(dollars) * alloy::primitives::U256::from(1_000_000)
                + alloy::primitives::U256::from(cents))
        }
        _ => Err(OnchainError::InvalidAmount(format!(
            "Invalid USDC amount format: '{}'",
            amount
        ))),
    }
}

/// Helper to format USDC amount to human-readable string
///
/// # Example
/// ```
/// use polymarket_sdk::onchain::format_usdc;
/// use alloy::primitives::U256;
/// let formatted = format_usdc(U256::from(10_500_000)); // "10.500000"
/// ```
pub fn format_usdc(amount: alloy::primitives::U256) -> String {
    let raw = amount.to_string();
    let len = raw.len();

    if len <= 6 {
        // Less than 1 USDC
        format!("0.{:0>6}", raw)
    } else {
        // Split into dollars and cents
        let dollars = &raw[..len - 6];
        let cents = &raw[len - 6..];
        format!("{}.{}", dollars, cents)
    }
}

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

    #[test]
    fn test_polygon_mainnet_config() {
        let config = NetworkConfig::polygon_mainnet();
        assert_eq!(config.chain_id, 137);
        assert!(config.contracts.usdc.to_string().starts_with("0x"));
    }

    #[test]
    fn test_polygon_amoy_config() {
        let config = NetworkConfig::polygon_amoy();
        assert_eq!(config.chain_id, 80002);
    }

    #[test]
    fn test_custom_config() {
        let contracts = ContractAddresses::polygon_mainnet();
        let config = NetworkConfig::custom(999, "http://localhost:8545".to_string(), contracts);
        assert_eq!(config.chain_id, 999);
        assert_eq!(config.rpc_url, "http://localhost:8545");
    }

    #[test]
    fn test_parse_usdc_whole() {
        let amount = parse_usdc("10").unwrap();
        assert_eq!(amount, alloy::primitives::U256::from(10_000_000));
    }

    #[test]
    fn test_parse_usdc_decimal() {
        let amount = parse_usdc("10.5").unwrap();
        assert_eq!(amount, alloy::primitives::U256::from(10_500_000));
    }

    #[test]
    fn test_parse_usdc_full_precision() {
        let amount = parse_usdc("10.123456").unwrap();
        assert_eq!(amount, alloy::primitives::U256::from(10_123_456));
    }

    #[test]
    fn test_parse_usdc_leading_zero() {
        let amount = parse_usdc("0.5").unwrap();
        assert_eq!(amount, alloy::primitives::U256::from(500_000));
    }

    #[test]
    fn test_parse_usdc_no_leading() {
        let amount = parse_usdc(".5").unwrap();
        assert_eq!(amount, alloy::primitives::U256::from(500_000));
    }

    #[test]
    fn test_parse_usdc_too_many_decimals() {
        let result = parse_usdc("10.1234567");
        assert!(result.is_err());
    }

    #[test]
    fn test_format_usdc_whole() {
        let formatted = format_usdc(alloy::primitives::U256::from(10_000_000));
        assert_eq!(formatted, "10.000000");
    }

    #[test]
    fn test_format_usdc_decimal() {
        let formatted = format_usdc(alloy::primitives::U256::from(10_500_000));
        assert_eq!(formatted, "10.500000");
    }

    #[test]
    fn test_format_usdc_small() {
        let formatted = format_usdc(alloy::primitives::U256::from(123));
        assert_eq!(formatted, "0.000123");
    }

    #[test]
    fn test_parse_format_roundtrip() {
        let original = "10.123456";
        let parsed = parse_usdc(original).unwrap();
        let formatted = format_usdc(parsed);
        assert_eq!(formatted, "10.123456");
    }
}