polymarket 0.1.0

Rust SDK for Polymarket prediction market - CLOB trading, on-chain operations, and WebSocket streaming
Documentation
use crate::clob::{
    error::{ClobError, Result},
    order_builder::salt::generate_salt,
    to_raw_amount_decimal,
    types::{OrderSide, PartialOrderArgs, SignatureType, SignedOrderArgs},
    Signer,
};
use rust_decimal::Decimal;

/// Builder for creating limit orders
///
/// Unlike market orders, limit orders specify an exact price and remain on the order book
/// until filled or expired.
pub struct LimitOrderBuilder {
    token_id: String,
    amount: Decimal,
    price: Decimal,
    side: OrderSide,
    fee_rate_bps: Option<u16>,
    nonce: Option<u64>,
    expiration: Option<u64>,
    signature_type: SignatureType,
    funder: Option<String>,
    neg_risk: Option<bool>,
}

impl LimitOrderBuilder {
    /// Create a new limit order builder
    ///
    /// # Arguments
    /// * `token_id` - The token ID to trade
    /// * `amount` - The amount to trade (in token units)
    /// * `price` - The limit price (0.0 to 1.0)
    /// * `side` - BUY or SELL
    pub fn new(token_id: impl Into<String>, amount: Decimal, price: Decimal, side: OrderSide) -> Self {
        Self {
            token_id: token_id.into(),
            amount,
            price,
            side,
            fee_rate_bps: None,
            nonce: None,
            expiration: None,
            signature_type: SignatureType::Eoa,
            funder: None,
            neg_risk: None,
        }
    }

    /// Set the fee rate in basis points (e.g., 100 = 1%)
    pub fn fee_rate_bps(mut self, fee_rate_bps: u16) -> Self {
        self.fee_rate_bps = Some(fee_rate_bps);
        self
    }

    /// Set the nonce for the order
    pub fn nonce(mut self, nonce: u64) -> Self {
        self.nonce = Some(nonce);
        self
    }

    /// Set the expiration timestamp (seconds since epoch)
    pub fn expiration(mut self, expiration: u64) -> Self {
        self.expiration = Some(expiration);
        self
    }

    /// Set the signature type (EOA, POLY_PROXY, or POLY_GNOSIS_SAFE)
    pub fn signature_type(mut self, signature_type: SignatureType) -> Self {
        self.signature_type = signature_type;
        self
    }

    /// Set the funder address for proxy wallets
    pub fn funder(mut self, funder: impl Into<String>) -> Self {
        self.funder = Some(funder.into());
        self
    }

    /// Set whether this is a neg risk order
    pub fn neg_risk(mut self, neg_risk: bool) -> Self {
        self.neg_risk = Some(neg_risk);
        self
    }

    /// Build the order into PartialOrderArgs (without signature)
    pub fn build_partial(self) -> Result<PartialOrderArgs> {
        if self.amount <= Decimal::ZERO {
            return Err(ClobError::InvalidOrder(
                "Amount must be positive".to_string(),
            ));
        }

        if self.price <= Decimal::ZERO || self.price > Decimal::ONE {
            return Err(ClobError::InvalidOrder(
                "Price must be between 0 and 1".to_string(),
            ));
        }

        // Calculate amounts following Python client's approach:
        // 1. Calculate in high precision (Decimal)
        // 2. Convert to raw (multiply by 10^6) FIRST
        // 3. Round to integer (done inside to_raw_amount_decimal)
        //
        // This matches py_clob_client/order_builder/helpers.py:to_token_decimals()
        // which does: int(round((10**6) * x))
        // Polymarket API constraints:
        //   BUY:  maker_amount (USDC) max 2 decimals, taker_amount (tokens) max 5 decimals
        //   SELL: maker_amount (tokens) max 5 decimals, taker_amount (USDC) max 2 decimals
        let (maker_amount, taker_amount) = match self.side {
            OrderSide::SELL => {
                // SELL: we give tokens, we get USDC
                let tokens = self.amount.round_dp(5);
                let usdc = (self.amount * self.price).round_dp(2);
                (to_raw_amount_decimal(tokens), to_raw_amount_decimal(usdc))
            }
            OrderSide::BUY => {
                // BUY: we give USDC, we get tokens
                let usdc = (self.amount * self.price).round_dp(2);
                let tokens = self.amount.round_dp(5);
                (to_raw_amount_decimal(usdc), to_raw_amount_decimal(tokens))
            }
        };

        Ok(PartialOrderArgs {
            salt: self.nonce.unwrap_or_else(generate_salt),
            maker: String::new(),  // Will be filled by signer
            signer: String::new(), // Will be filled by signer
            taker: crate::clob::constants::ZERO_ADDRESS.to_string(),
            token_id: self.token_id,
            maker_amount,
            taker_amount,
            side: self.side,
            fee_rate_bps: self.fee_rate_bps.unwrap_or(0),
            nonce: 0, // Nonce is separate from salt
            signature_type: self.signature_type,
            expiration: self.expiration.unwrap_or(0),
        })
    }

    /// Build and sign the order
    ///
    /// # Arguments
    /// * `signer` - The signer to use for signing the order
    pub async fn build_and_sign(self, signer: &Signer) -> Result<SignedOrderArgs> {
        let funder = self.funder.clone();
        let neg_risk = self.neg_risk.unwrap_or(false);
        let mut partial = self.build_partial()?;

        // Fill in maker and signer addresses
        // For proxy wallets, maker is the funder (proxy wallet address)
        // For EOA wallets, maker is the signer address
        // Note: signer.address() already returns checksummed format
        partial.maker = funder.unwrap_or_else(|| signer.address());
        partial.signer = signer.address();

        // Use the shared order signing logic with neg_risk flag
        super::sign_order(partial, signer, neg_risk).await
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::clob::OrderSide;
    use std::str::FromStr;

    #[test]
    fn test_limit_order_builder() {
        let builder = LimitOrderBuilder::new("456", Decimal::from(50), Decimal::from_str("0.75").unwrap(), OrderSide::SELL)
            .fee_rate_bps(20)
            .nonce(789012);

        let partial = builder.build_partial().unwrap();
        assert_eq!(partial.token_id, "456");
        assert_eq!(partial.maker_amount, "50000000"); // 50 * 1,000,000
        assert_eq!(partial.side, OrderSide::SELL);
        assert_eq!(partial.fee_rate_bps, 20);
    }

    #[test]
    fn test_limit_order_taker_amount() {
        // BUY limit order: size=100 tokens at price=0.5
        // We GIVE: 50 USDC (maker_amount)
        // We GET: 100 tokens (taker_amount)
        let builder = LimitOrderBuilder::new("456", Decimal::from(100), Decimal::from_str("0.5").unwrap(), OrderSide::BUY);
        let partial = builder.build_partial().unwrap();
        assert_eq!(partial.maker_amount, "50000000");  // 50 USDC * 1,000,000
        assert_eq!(partial.taker_amount, "100000000"); // 100 tokens * 1,000,000
    }

    #[test]
    fn test_invalid_amount() {
        let builder = LimitOrderBuilder::new("456", Decimal::from(-50), Decimal::from_str("0.75").unwrap(), OrderSide::SELL);
        assert!(builder.build_partial().is_err());
    }

    #[test]
    fn test_invalid_price_too_high() {
        let builder = LimitOrderBuilder::new("456", Decimal::from(50), Decimal::from_str("1.5").unwrap(), OrderSide::SELL);
        assert!(builder.build_partial().is_err());
    }

    #[test]
    fn test_invalid_price_zero() {
        let builder = LimitOrderBuilder::new("456", Decimal::from(50), Decimal::ZERO, OrderSide::SELL);
        assert!(builder.build_partial().is_err());
    }
}