diff --git a/Cargo.lock b/Cargo.lock index e223ddc..582a836 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3589,9 +3589,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" dependencies = [ "aws-lc-rs", "bytes", diff --git a/src/auth.rs b/src/auth.rs index 5200222..c2326b9 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -14,6 +14,7 @@ use hmac::{Hmac, Mac}; use serde::Serialize; use sha2::Sha256; use std::collections::HashMap; +use std::sync::{Arc, LazyLock, RwLock}; use std::time::{SystemTime, UNIX_EPOCH}; // Header constants @@ -25,6 +26,8 @@ const POLY_API_KEY_HEADER: &str = "poly_api_key"; const POLY_PASS_HEADER: &str = "poly_passphrase"; type Headers = HashMap<&'static str, String>; +static DECODED_SECRET_CACHE: LazyLock>>> = + LazyLock::new(|| RwLock::new(HashMap::new())); // EIP-712 struct for CLOB authentication sol! { @@ -156,41 +159,78 @@ pub fn build_hmac_signature( where T: ?Sized + Serialize, { - // Apply inverse transformation to key material for digest initialization - // This ensures compatibility with the expected cryptographic envelope format - let decoded_secret = base64::engine::general_purpose::URL_SAFE - .decode(secret) - .map_err(|e| PolyfillError::crypto(format!("Failed to decode base64 secret: {}", e)))?; + let decoded_secret = decoded_secret_bytes(secret)?; + let body_bytes = + match body { + Some(b) => Some(serde_json::to_vec(b).map_err(|e| { + PolyfillError::parse(format!("Failed to serialize body: {}", e), None) + })?), + None => None, + }; - // Initialize MAC with transformed key material to maintain protocol coherence - let mut mac = Hmac::::new_from_slice(&decoded_secret) + build_hmac_signature_bytes( + &decoded_secret, + timestamp, + method, + request_path, + body_bytes.as_deref(), + ) +} + +pub fn build_hmac_signature_bytes( + decoded_secret: &[u8], + timestamp: u64, + method: &str, + request_path: &str, + body_bytes: Option<&[u8]>, +) -> Result { + let mut mac = Hmac::::new_from_slice(decoded_secret) .map_err(|e| PolyfillError::crypto(format!("Invalid HMAC key: {}", e)))?; - // Construct canonical message representation for signature verification - // Message components are concatenated in strict order to preserve cryptographic binding - let message = format!( - "{}{}{}{}", - timestamp, - method.to_uppercase(), - request_path, - match body { - Some(b) => serde_json::to_string(b).map_err(|e| PolyfillError::parse( - format!("Failed to serialize body: {}", e), - None - ))?, - None => String::new(), - } - ); + let timestamp = timestamp.to_string(); + mac.update(timestamp.as_bytes()); + let method_upper; + let method_bytes = if method.bytes().all(|b| !b.is_ascii_lowercase()) { + method.as_bytes() + } else { + method_upper = method.to_ascii_uppercase(); + method_upper.as_bytes() + }; + mac.update(method_bytes); + mac.update(request_path.as_bytes()); + if let Some(body_bytes) = body_bytes { + mac.update(body_bytes); + } - // Compute authentication tag over canonical message form - mac.update(message.as_bytes()); let result = mac.finalize(); - // Apply URL-safe encoding transformation for transport layer compatibility - // This encoding scheme ensures proper signature validation across network boundaries Ok(base64::engine::general_purpose::URL_SAFE.encode(result.into_bytes())) } +fn decoded_secret_bytes(secret: &str) -> Result> { + if let Some(decoded) = DECODED_SECRET_CACHE + .read() + .map_err(|_| PolyfillError::internal_simple("Decoded secret cache lock poisoned"))? + .get(secret) + .cloned() + { + return Ok(decoded); + } + + let decoded: Arc<[u8]> = base64::engine::general_purpose::URL_SAFE + .decode(secret) + .map_err(|e| PolyfillError::crypto(format!("Failed to decode base64 secret: {}", e)))? + .into(); + + let mut cache = DECODED_SECRET_CACHE + .write() + .map_err(|_| PolyfillError::internal_simple("Decoded secret cache lock poisoned"))?; + Ok(cache + .entry(secret.to_string()) + .or_insert_with(|| decoded.clone()) + .clone()) +} + /// Create L1 headers for authentication (using private key signature) /// /// Generates initial authentication envelope using elliptic curve cryptography @@ -245,6 +285,28 @@ where ])) } +pub fn create_l2_headers_with_body_bytes( + signer: &PrivateKeySigner, + api_creds: &ApiCredentials, + method: &str, + req_path: &str, + body_bytes: Option<&[u8]>, +) -> Result { + let address = encode_prefixed(signer.address().as_slice()); + let timestamp = get_current_unix_time_secs(); + let decoded_secret = decoded_secret_bytes(&api_creds.secret)?; + let hmac_signature = + build_hmac_signature_bytes(&decoded_secret, timestamp, method, req_path, body_bytes)?; + + Ok(HashMap::from([ + (POLY_ADDR_HEADER, address), + (POLY_SIG_HEADER, hmac_signature), + (POLY_TS_HEADER, timestamp.to_string()), + (POLY_API_KEY_HEADER, api_creds.api_key.clone()), + (POLY_PASS_HEADER, api_creds.passphrase.clone()), + ])) +} + #[cfg(test)] mod tests { use super::*; @@ -296,6 +358,28 @@ mod tests { assert_eq!(sig1, sig2); } + #[test] + fn test_hmac_signature_bytes_matches_serialized_body() { + let secret = "dGVzdF9zZWNyZXRfa2V5XzEyMzQ1"; + let timestamp = 1234567890; + let body = serde_json::json!({"orderID": "abc123"}); + let body_bytes = serde_json::to_vec(&body).unwrap(); + let decoded_secret = decoded_secret_bytes(secret).unwrap(); + + let object_signature = + build_hmac_signature(secret, timestamp, "delete", "/order", Some(&body)).unwrap(); + let bytes_signature = build_hmac_signature_bytes( + &decoded_secret, + timestamp, + "DELETE", + "/order", + Some(&body_bytes), + ) + .unwrap(); + + assert_eq!(object_signature, bytes_signature); + } + #[test] fn test_hmac_signature_different_inputs() { let secret = "dGVzdF9zZWNyZXRfa2V5XzEyMzQ1"; diff --git a/src/client.rs b/src/client.rs index 483607b..1050c67 100644 --- a/src/client.rs +++ b/src/client.rs @@ -3,7 +3,7 @@ //! This module provides a production-ready client for interacting with //! Polymarket, optimized for high-frequency trading environments. -use crate::auth::{create_l1_headers, create_l2_headers}; +use crate::auth::{create_l1_headers, create_l2_headers, create_l2_headers_with_body_bytes}; use crate::errors::{PolyfillError, Result}; use crate::http_config::{create_colocated_client, create_internet_client, prewarm_connections}; use crate::types::{ @@ -13,11 +13,12 @@ use crate::types::{ }; use alloy_primitives::{Address, U256}; use alloy_signer_local::PrivateKeySigner; -use reqwest::header::HeaderName; +use reqwest::header::{HeaderName, CONTENT_TYPE}; use reqwest::Client; use reqwest::{Method, RequestBuilder}; use rust_decimal::prelude::FromPrimitive; use rust_decimal::Decimal; +use serde::Serialize; use serde_json::Value; use std::net::{IpAddr, SocketAddr}; use std::str::FromStr; @@ -863,6 +864,23 @@ impl ClobClient { headers.fold(req, |r, (k, v)| r.header(HeaderName::from_static(k), v)) } + fn serialize_json_body(body: &T) -> Result> { + serde_json::to_vec(body) + .map_err(|e| PolyfillError::parse(format!("Failed to serialize body: {e}"), None)) + } + + fn create_request_with_json_bytes( + &self, + method: Method, + endpoint: &str, + headers: impl Iterator, + body_bytes: Vec, + ) -> RequestBuilder { + self.create_request_with_headers(method, endpoint, headers) + .header(CONTENT_TYPE, "application/json") + .body(body_bytes) + } + /// Get neg risk for a token pub async fn get_neg_risk(&self, token_id: &str) -> Result { let response = self @@ -1140,11 +1158,23 @@ impl ClobClient { // Owner field must reference the credential principal identifier // to maintain consistency with the authentication context layer let body = PostOrder::new(order, api_creds.api_key.clone(), options); + let body_bytes = Self::serialize_json_body(&body)?; - let headers = create_l2_headers(signer, api_creds, "POST", "/order", Some(&body))?; - let req = self.create_request_with_headers(Method::POST, "/order", headers.into_iter()); + let headers = create_l2_headers_with_body_bytes( + signer, + api_creds, + "POST", + "/order", + Some(&body_bytes), + )?; + let req = self.create_request_with_json_bytes( + Method::POST, + "/order", + headers.into_iter(), + body_bytes, + ); - let response = req.json(&body).send().await?; + let response = req.send().await?; if !response.status().is_success() { let status = response.status().as_u16(); let body = response.text().await.unwrap_or_default(); @@ -1201,11 +1231,23 @@ impl ClobClient { .ok_or_else(|| PolyfillError::auth("API credentials not set"))?; let body = std::collections::HashMap::from([("orderID", order_id)]); + let body_bytes = Self::serialize_json_body(&body)?; - let headers = create_l2_headers(signer, api_creds, "DELETE", "/order", Some(&body))?; - let req = self.create_request_with_headers(Method::DELETE, "/order", headers.into_iter()); + let headers = create_l2_headers_with_body_bytes( + signer, + api_creds, + "DELETE", + "/order", + Some(&body_bytes), + )?; + let req = self.create_request_with_json_bytes( + Method::DELETE, + "/order", + headers.into_iter(), + body_bytes, + ); - let response = req.json(&body).send().await?; + let response = req.send().await?; if !response.status().is_success() { return Err(PolyfillError::api( response.status().as_u16(), @@ -1230,10 +1272,22 @@ impl ClobClient { .as_ref() .ok_or_else(|| PolyfillError::auth("API credentials not set"))?; - let headers = create_l2_headers(signer, api_creds, "DELETE", "/orders", Some(order_ids))?; - let req = self.create_request_with_headers(Method::DELETE, "/orders", headers.into_iter()); + let body_bytes = Self::serialize_json_body(order_ids)?; + let headers = create_l2_headers_with_body_bytes( + signer, + api_creds, + "DELETE", + "/orders", + Some(&body_bytes), + )?; + let req = self.create_request_with_json_bytes( + Method::DELETE, + "/orders", + headers.into_iter(), + body_bytes, + ); - let response = req.json(order_ids).send().await?; + let response = req.send().await?; if !response.status().is_success() { return Err(PolyfillError::api( response.status().as_u16(), @@ -1741,19 +1795,18 @@ impl ClobClient { ("market", market.unwrap_or("")), ("asset_id", asset_id.unwrap_or("")), ]); + let body_bytes = Self::serialize_json_body(&body)?; - let headers = create_l2_headers(signer, api_creds, method.as_str(), endpoint, Some(&body))?; + let headers = create_l2_headers_with_body_bytes( + signer, + api_creds, + method.as_str(), + endpoint, + Some(&body_bytes), + )?; let response = self - .http_client - .request(method, format!("{}{}", self.base_url, endpoint)) - .headers( - headers - .into_iter() - .map(|(k, v)| (HeaderName::from_static(k), v.parse().unwrap())) - .collect(), - ) - .json(&body) + .create_request_with_json_bytes(method, endpoint, headers.into_iter(), body_bytes) .send() .await .map_err(|e| PolyfillError::network(format!("Request failed: {}", e), e))?; @@ -1905,24 +1958,17 @@ impl ClobClient { let method = Method::POST; let endpoint = "/orders-scoring"; - let headers = create_l2_headers( + let body_bytes = Self::serialize_json_body(order_ids)?; + let headers = create_l2_headers_with_body_bytes( signer, api_creds, method.as_str(), endpoint, - Some(order_ids), + Some(&body_bytes), )?; let response = self - .http_client - .request(method, format!("{}{}", self.base_url, endpoint)) - .headers( - headers - .into_iter() - .map(|(k, v)| (HeaderName::from_static(k), v.parse().unwrap())) - .collect(), - ) - .json(order_ids) + .create_request_with_json_bytes(method, endpoint, headers.into_iter(), body_bytes) .send() .await .map_err(|e| PolyfillError::network(format!("Request failed: {}", e), e))?; @@ -1953,12 +1999,17 @@ impl ClobClient { let method = Method::POST; let endpoint = "/rfq/request"; - let headers = - create_l2_headers(signer, api_creds, method.as_str(), endpoint, Some(request))?; + let body_bytes = Self::serialize_json_body(request)?; + let headers = create_l2_headers_with_body_bytes( + signer, + api_creds, + method.as_str(), + endpoint, + Some(&body_bytes), + )?; let response = self - .create_request_with_headers(method, endpoint, headers.into_iter()) - .json(request) + .create_request_with_json_bytes(method, endpoint, headers.into_iter(), body_bytes) .send() .await .map_err(|e| PolyfillError::network(format!("Request failed: {}", e), e))?; @@ -1992,11 +2043,17 @@ impl ClobClient { let body = crate::types::RfqCancelRequest { request_id: request_id.to_string(), }; - let headers = create_l2_headers(signer, api_creds, method.as_str(), endpoint, Some(&body))?; + let body_bytes = Self::serialize_json_body(&body)?; + let headers = create_l2_headers_with_body_bytes( + signer, + api_creds, + method.as_str(), + endpoint, + Some(&body_bytes), + )?; let response = self - .create_request_with_headers(method, endpoint, headers.into_iter()) - .json(&body) + .create_request_with_json_bytes(method, endpoint, headers.into_iter(), body_bytes) .send() .await .map_err(|e| PolyfillError::network(format!("Request failed: {}", e), e))?; @@ -2068,11 +2125,17 @@ impl ClobClient { let method = Method::POST; let endpoint = "/rfq/quote"; - let headers = create_l2_headers(signer, api_creds, method.as_str(), endpoint, Some(quote))?; + let body_bytes = Self::serialize_json_body(quote)?; + let headers = create_l2_headers_with_body_bytes( + signer, + api_creds, + method.as_str(), + endpoint, + Some(&body_bytes), + )?; let response = self - .create_request_with_headers(method, endpoint, headers.into_iter()) - .json(quote) + .create_request_with_json_bytes(method, endpoint, headers.into_iter(), body_bytes) .send() .await .map_err(|e| PolyfillError::network(format!("Request failed: {}", e), e))?; @@ -2106,11 +2169,17 @@ impl ClobClient { let body = crate::types::RfqCancelQuote { quote_id: quote_id.to_string(), }; - let headers = create_l2_headers(signer, api_creds, method.as_str(), endpoint, Some(&body))?; + let body_bytes = Self::serialize_json_body(&body)?; + let headers = create_l2_headers_with_body_bytes( + signer, + api_creds, + method.as_str(), + endpoint, + Some(&body_bytes), + )?; let response = self - .create_request_with_headers(method, endpoint, headers.into_iter()) - .json(&body) + .create_request_with_json_bytes(method, endpoint, headers.into_iter(), body_bytes) .send() .await .map_err(|e| PolyfillError::network(format!("Request failed: {}", e), e))?; @@ -2259,11 +2328,17 @@ impl ClobClient { let method = Method::POST; let endpoint = "/rfq/request/accept"; - let headers = create_l2_headers(signer, api_creds, method.as_str(), endpoint, Some(body))?; + let body_bytes = Self::serialize_json_body(body)?; + let headers = create_l2_headers_with_body_bytes( + signer, + api_creds, + method.as_str(), + endpoint, + Some(&body_bytes), + )?; let response = self - .create_request_with_headers(method, endpoint, headers.into_iter()) - .json(body) + .create_request_with_json_bytes(method, endpoint, headers.into_iter(), body_bytes) .send() .await .map_err(|e| PolyfillError::network(format!("Request failed: {}", e), e))?; @@ -2294,11 +2369,17 @@ impl ClobClient { let method = Method::POST; let endpoint = "/rfq/quote/approve"; - let headers = create_l2_headers(signer, api_creds, method.as_str(), endpoint, Some(body))?; + let body_bytes = Self::serialize_json_body(body)?; + let headers = create_l2_headers_with_body_bytes( + signer, + api_creds, + method.as_str(), + endpoint, + Some(&body_bytes), + )?; let response = self - .create_request_with_headers(method, endpoint, headers.into_iter()) - .json(body) + .create_request_with_json_bytes(method, endpoint, headers.into_iter(), body_bytes) .send() .await .map_err(|e| PolyfillError::network(format!("Request failed: {}", e), e))?; diff --git a/src/orders.rs b/src/orders.rs index 1b8d141..1cde795 100644 --- a/src/orders.rs +++ b/src/orders.rs @@ -6,16 +6,15 @@ use crate::auth::{sign_order_message, SignedOrderMessage}; use crate::errors::{PolyfillError, Result}; use crate::types::{ - CreateOrderOptions, MarketOrderArgs, OrderArgs, OrderType, Side, SignedOrderRequest, + decimal_to_price, CreateOrderOptions, MarketOrderArgs, OrderArgs, OrderType, Side, + SignedOrderRequest, }; use alloy_primitives::{keccak256, Address, B256, U256}; use alloy_signer_local::PrivateKeySigner; use rand::Rng; use rust_decimal::Decimal; use rust_decimal::RoundingStrategy::{AwayFromZero, MidpointTowardZero, ToZero}; -use std::collections::HashMap; use std::str::FromStr; -use std::sync::LazyLock; use std::time::{SystemTime, UNIX_EPOCH}; pub const BYTES32_ZERO: &str = "0x0000000000000000000000000000000000000000000000000000000000000000"; @@ -61,43 +60,27 @@ const PROXY_INIT_CODE_HASH: &str = const SAFE_INIT_CODE_HASH: &str = "0x2bce2127ff07fb632d16c8347c4ebf501f4841168bed00d9e6ef715ddb6fcecf"; -/// Rounding configurations for different tick sizes -static ROUNDING_CONFIG: LazyLock> = LazyLock::new(|| { - HashMap::from([ - ( - Decimal::from_str("0.1").unwrap(), - RoundConfig { - price: 1, - size: 2, - amount: 3, - }, - ), - ( - Decimal::from_str("0.01").unwrap(), - RoundConfig { - price: 2, - size: 2, - amount: 4, - }, - ), - ( - Decimal::from_str("0.001").unwrap(), - RoundConfig { - price: 3, - size: 2, - amount: 5, - }, - ), - ( - Decimal::from_str("0.0001").unwrap(), - RoundConfig { - price: 4, - size: 2, - amount: 6, - }, - ), - ]) -}); +const ROUND_CONFIG_0_1: RoundConfig = RoundConfig { + price: 1, + size: 2, + amount: 3, +}; +const ROUND_CONFIG_0_01: RoundConfig = RoundConfig { + price: 2, + size: 2, + amount: 4, +}; +const ROUND_CONFIG_0_001: RoundConfig = RoundConfig { + price: 3, + size: 2, + amount: 5, +}; +const ROUND_CONFIG_0_0001: RoundConfig = RoundConfig { + price: 4, + size: 2, + amount: 6, +}; +const TOKEN_UNIT_SCALE: Decimal = Decimal::from_parts(1_000_000, 0, 0, false, 0); /// Get contract configuration for chain pub fn get_contract_config(chain_id: u64, neg_risk: bool) -> Option { @@ -193,7 +176,7 @@ fn generate_seed() -> u64 { /// Convert decimal to token units (multiply by 1e6) fn decimal_to_token_u32(amt: Decimal) -> u32 { - let mut amt = Decimal::from_scientific("1e6").expect("1e6 is not scientific") * amt; + let mut amt = TOKEN_UNIT_SCALE * amt; if amt.scale() > 0 { amt = amt.round_dp_with_strategy(0, MidpointTowardZero); } @@ -201,9 +184,18 @@ fn decimal_to_token_u32(amt: Decimal) -> u32 { } fn parse_round_config(tick_size: Decimal) -> Result<&'static RoundConfig> { - ROUNDING_CONFIG - .get(&tick_size) - .ok_or_else(|| PolyfillError::validation(format!("Unsupported tick size {tick_size}"))) + let tick_size_ticks = decimal_to_price(tick_size) + .map_err(|_| PolyfillError::validation(format!("Unsupported tick size {tick_size}")))?; + + match tick_size_ticks { + 1000 => Ok(&ROUND_CONFIG_0_1), + 100 => Ok(&ROUND_CONFIG_0_01), + 10 => Ok(&ROUND_CONFIG_0_001), + 1 => Ok(&ROUND_CONFIG_0_0001), + _ => Err(PolyfillError::validation(format!( + "Unsupported tick size {tick_size}" + ))), + } } pub(crate) fn validate_bytes32_hex(field: &str, value: &str) -> Result<()> {