From e4badca6b65374b4c4f7b1e37c67032c13cef724 Mon Sep 17 00:00:00 2001 From: floor-licker Date: Mon, 22 Jun 2026 18:29:12 -0400 Subject: [PATCH] feat: add prepared order path --- benches/comparison_benchmarks.rs | 25 +++ src/auth.rs | 40 ++++- src/client.rs | 85 +++++++++- src/orders.rs | 278 ++++++++++++++++++++++++++++--- 4 files changed, 390 insertions(+), 38 deletions(-) diff --git a/benches/comparison_benchmarks.rs b/benches/comparison_benchmarks.rs index 2cbb91d..e53cbc2 100644 --- a/benches/comparison_benchmarks.rs +++ b/benches/comparison_benchmarks.rs @@ -89,6 +89,31 @@ fn benchmark_create_order_eip712(c: &mut Criterion) { black_box(signed_order) }) }); + + let prepared = builder + .prepare_order_path( + CHAIN_ID, + order_args.token_id.clone(), + options.tick_size.unwrap(), + options.neg_risk.unwrap(), + order_args.builder_code.as_deref(), + order_args.metadata.as_deref(), + ) + .unwrap(); + + c.bench_function("prepared_create_order_eip712_signature", |b| { + b.iter(|| { + let signed_order = prepared + .create_limit_order( + black_box(order_args.side), + black_box(order_args.price), + black_box(order_args.size), + black_box(order_args.expiration), + ) + .unwrap(); + black_box(signed_order) + }) + }); } // Benchmark: Serialize a signed order body and build L2 auth headers for POST /order. diff --git a/src/auth.rs b/src/auth.rs index 7720814..6917ebb 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -8,7 +8,7 @@ use crate::types::ApiCredentials; use alloy_primitives::{hex::encode_prefixed, Address, B256, U256}; use alloy_signer::SignerSync; use alloy_signer_local::PrivateKeySigner; -use alloy_sol_types::{eip712_domain, sol}; +use alloy_sol_types::{eip712_domain, sol, Eip712Domain}; use base64::engine::Engine; use hmac::{Hmac, Mac}; use serde::Serialize; @@ -142,6 +142,25 @@ pub struct SignedOrderMessage { pub builder: B256, } +/// Prepared EIP-712 domain for signing orders against one exchange contract. +#[derive(Clone)] +pub struct PreparedOrderDomain { + domain: Eip712Domain, +} + +impl PreparedOrderDomain { + pub fn new(chain_id: u64, verifying_contract: Address) -> Self { + let domain = eip712_domain!( + name: "Polymarket CTF Exchange", + version: "2", + chain_id: chain_id, + verifying_contract: verifying_contract, + ); + + Self { domain } + } +} + /// Get current Unix timestamp in seconds pub fn get_current_unix_time_secs() -> u64 { SystemTime::now() @@ -185,6 +204,16 @@ pub fn sign_order_message( order: SignedOrderMessage, chain_id: u64, verifying_contract: Address, +) -> Result { + let domain = PreparedOrderDomain::new(chain_id, verifying_contract); + sign_order_message_with_domain(signer, order, &domain) +} + +/// Sign order message using a prepared EIP-712 domain. +pub fn sign_order_message_with_domain( + signer: &PrivateKeySigner, + order: SignedOrderMessage, + domain: &PreparedOrderDomain, ) -> Result { let order = Order { salt: order.salt, @@ -200,15 +229,8 @@ pub fn sign_order_message( builder: order.builder, }; - let domain = eip712_domain!( - name: "Polymarket CTF Exchange", - version: "2", - chain_id: chain_id, - verifying_contract: verifying_contract, - ); - let signature = signer - .sign_typed_data_sync(&order, &domain) + .sign_typed_data_sync(&order, &domain.domain) .map_err(|e| PolyfillError::crypto(format!("Order signature failed: {}", e)))?; Ok(encode_prefixed(signature.as_bytes())) diff --git a/src/client.rs b/src/client.rs index 77d77ac..1d5ccfb 100644 --- a/src/client.rs +++ b/src/client.rs @@ -974,6 +974,35 @@ impl ClobClient { self.get_clob_market_info(&market.condition_id).await } + /// Prepare reusable order-path state for repeated orders on one token. + /// + /// This resolves tick-size/neg-risk once, then caches fixed order-path inputs such as the + /// parsed token ID, exchange address, normalized bytes32 values, and EIP-712 domain. + pub async fn prepare_order_path( + &self, + token_id: &str, + options: Option<&CreateOrderOptions>, + builder_code: Option<&str>, + metadata: Option<&str>, + ) -> Result { + let order_builder = self + .order_builder + .as_ref() + .ok_or_else(|| PolyfillError::auth("Order builder not initialized"))?; + + let create_order_options = self.get_filled_order_options(token_id, options).await?; + let builder_code = builder_code.or(self.builder_code.as_deref()); + + order_builder.prepare_order_path( + self.chain_id, + token_id, + create_order_options.tick_size.expect("Should be filled"), + create_order_options.neg_risk.expect("Should be filled"), + builder_code, + metadata, + ) + } + /// Create an order pub async fn create_order( &self, @@ -2516,8 +2545,9 @@ pub type PolyfillClient = ClobClient; mod tests { use super::{ClobClient, OrderArgs as ClientOrderArgs}; use crate::types::{ - OrderType, PostOrderOptions, PricesHistoryInterval, RfqCreateQuote, RfqCreateRequest, - RfqOrderExecutionRequest, RfqQuotesParams, RfqRequestsParams, Side, SignedOrderRequest, + CreateOrderOptions, OrderType, PostOrderOptions, PricesHistoryInterval, RfqCreateQuote, + RfqCreateRequest, RfqOrderExecutionRequest, RfqQuotesParams, RfqRequestsParams, Side, + SignedOrderRequest, }; use crate::{ApiCredentials, ClientConfig, PolyfillError}; use mockito::{Matcher, Server}; @@ -3288,6 +3318,57 @@ mod tests { assert_eq!(default_args.side, Side::BUY); } + #[tokio::test(flavor = "multi_thread")] + async fn test_prepare_order_path_uses_client_builder_code() { + let mut server = Server::new_async().await; + let tick_size_mock = server + .mock("GET", "/tick-size") + .match_query(Matcher::UrlEncoded("token_id".into(), "123456".into())) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"minimum_tick_size":"0.01"}"#) + .create_async() + .await; + + let client = ClobClient::from_config(ClientConfig { + base_url: server.url(), + chain: 137, + private_key: Some( + "0x1234567890123456789012345678901234567890123456789012345678901234".to_string(), + ), + builder_code: Some( + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + ), + ..ClientConfig::default() + }) + .expect("test auth client with builder code"); + let options = CreateOrderOptions { + tick_size: Some(Decimal::from_str("0.01").unwrap()), + neg_risk: Some(false), + }; + + let prepared = client + .prepare_order_path("123456", Some(&options), None, None) + .await + .unwrap(); + let order = prepared + .create_limit_order( + Side::BUY, + Decimal::from_str("0.45").unwrap(), + Decimal::from_str("12.34").unwrap(), + Some(1_900_000_000), + ) + .unwrap(); + + tick_size_mock.assert_async().await; + assert_eq!(order.token_id, "123456"); + assert_eq!( + order.builder, + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ); + assert_eq!(order.metadata, crate::orders::BYTES32_ZERO); + } + #[tokio::test(flavor = "multi_thread")] async fn test_get_clob_market_info_success() { let mut server = Server::new_async().await; diff --git a/src/orders.rs b/src/orders.rs index 1a8fb54..7e637e9 100644 --- a/src/orders.rs +++ b/src/orders.rs @@ -3,7 +3,9 @@ //! This module handles the complex process of creating and signing orders //! for the Polymarket CLOB, including EIP-712 signature generation. -use crate::auth::{sign_order_message, SignedOrderMessage}; +use crate::auth::{ + sign_order_message, sign_order_message_with_domain, PreparedOrderDomain, SignedOrderMessage, +}; use crate::errors::{PolyfillError, Result}; use crate::types::{ CreateOrderOptions, MarketOrderArgs, OrderArgs, OrderType, Side, SignedOrderRequest, @@ -33,6 +35,7 @@ pub enum SigType { } /// Rounding configuration for different tick sizes +#[derive(Clone, Copy)] pub struct RoundConfig { price: u32, size: u32, @@ -47,10 +50,28 @@ pub struct ContractConfig { } /// Order builder for creating and signing orders +#[derive(Clone)] pub struct OrderBuilder { signer: PrivateKeySigner, + signer_address: Address, + signer_checksum: String, sig_type: SigType, funder: Address, + funder_checksum: String, +} + +/// Prepared low-latency order path for a single market/token configuration. +#[derive(Clone)] +pub struct PreparedOrderPath { + builder: OrderBuilder, + token_id: String, + token_id_u256: U256, + round_config: RoundConfig, + domain: PreparedOrderDomain, + builder_bytes: B256, + builder_code: String, + metadata_bytes: B256, + metadata: String, } const POLYGON_PROXY_FACTORY: &str = "0xaB45c5A4B0c941a2F231C04C3f49182e1A254052"; @@ -99,6 +120,28 @@ pub fn get_contract_config(chain_id: u64, neg_risk: bool) -> Option Result
{ + let contract_config = get_contract_config(chain_id, neg_risk).ok_or_else(|| { + PolyfillError::config("No contract found with given chain_id and neg_risk") + })?; + + Address::from_str(&contract_config.exchange) + .map_err(|e| PolyfillError::config(format!("Invalid exchange address: {}", e))) +} + +fn parse_token_id(token_id: &str) -> Result { + U256::from_str_radix(token_id, 10) + .map_err(|e| PolyfillError::validation(format!("Incorrect tokenId format: {}", e))) +} + +fn parse_optional_bytes32(name: &str, value: Option<&str>) -> Result<(B256, String)> { + let normalized = normalize_optional_bytes32(name, value)?; + let parsed = B256::from_str(&normalized) + .map_err(|e| PolyfillError::validation(format!("Invalid {name} bytes32 value: {e}")))?; + + Ok((parsed, normalized)) +} + pub fn sig_type_from_u8(signature_type: u8) -> Result { match signature_type { 0 => Ok(SigType::Eoa), @@ -303,12 +346,18 @@ impl OrderBuilder { funder: Option
, ) -> Self { let sig_type = sig_type.unwrap_or(SigType::Eoa); - let funder = funder.unwrap_or(signer.address()); + let signer_address = signer.address(); + let signer_checksum = signer_address.to_checksum(None); + let funder = funder.unwrap_or(signer_address); + let funder_checksum = funder.to_checksum(None); OrderBuilder { signer, + signer_address, + signer_checksum, sig_type, funder, + funder_checksum, } } @@ -317,6 +366,41 @@ impl OrderBuilder { self.sig_type as u8 } + /// Prepare reusable order-path state for one market/token. + /// + /// This caches tick-size rounding, exchange address parsing, token ID parsing, normalized + /// bytes32 fields, and the EIP-712 domain. Use this for latency-sensitive repeated order + /// creation on the same token. + pub fn prepare_order_path( + &self, + chain_id: u64, + token_id: impl Into, + tick_size: Decimal, + neg_risk: bool, + builder_code: Option<&str>, + metadata: Option<&str>, + ) -> Result { + let token_id = token_id.into(); + let token_id_u256 = parse_token_id(&token_id)?; + let round_config = *parse_round_config(tick_size)?; + let exchange = exchange_address_for(chain_id, neg_risk)?; + let domain = PreparedOrderDomain::new(chain_id, exchange); + let (builder_bytes, builder_code) = parse_optional_bytes32("builder_code", builder_code)?; + let (metadata_bytes, metadata) = parse_optional_bytes32("metadata", metadata)?; + + Ok(PreparedOrderPath { + builder: self.clone(), + token_id, + token_id_u256, + round_config, + domain, + builder_bytes, + builder_code, + metadata_bytes, + metadata, + }) + } + /// Fix amount rounding according to configuration fn fix_amount_rounding(&self, mut amt: Decimal, round_config: &RoundConfig) -> Decimal { if amt.scale() > round_config.amount { @@ -459,12 +543,7 @@ impl OrderBuilder { .neg_risk .ok_or_else(|| PolyfillError::validation("Cannot create order without neg_risk"))?; - let contract_config = get_contract_config(chain_id, neg_risk).ok_or_else(|| { - PolyfillError::config("No contract found with given chain_id and neg_risk") - })?; - - let exchange_address = Address::from_str(&contract_config.exchange) - .map_err(|e| PolyfillError::config(format!("Invalid exchange address: {}", e)))?; + let exchange_address = exchange_address_for(chain_id, neg_risk)?; self.build_signed_order( order_args.token_id.clone(), @@ -502,12 +581,7 @@ impl OrderBuilder { .neg_risk .ok_or_else(|| PolyfillError::validation("Cannot create order without neg_risk"))?; - let contract_config = get_contract_config(chain_id, neg_risk).ok_or_else(|| { - PolyfillError::config("No contract found with given chain_id and neg_risk") - })?; - - let exchange_address = Address::from_str(&contract_config.exchange) - .map_err(|e| PolyfillError::config(format!("Invalid exchange address: {}", e)))?; + let exchange_address = exchange_address_for(chain_id, neg_risk)?; self.build_signed_order( order_args.token_id.clone(), @@ -542,35 +616,30 @@ impl OrderBuilder { .expect("Time went backwards") .as_millis(); - let u256_token_id = U256::from_str_radix(&token_id, 10) - .map_err(|e| PolyfillError::validation(format!("Incorrect tokenId format: {}", e)))?; - let builder = normalize_optional_bytes32("builder_code", builder_code)?; - let metadata = normalize_optional_bytes32("metadata", metadata)?; + let u256_token_id = parse_token_id(&token_id)?; + let (builder_bytes, builder) = parse_optional_bytes32("builder_code", builder_code)?; + let (metadata_bytes, metadata) = parse_optional_bytes32("metadata", metadata)?; let order = SignedOrderMessage { salt: U256::from(seed), maker: self.funder, - signer: self.signer.address(), + signer: self.signer_address, token_id: u256_token_id, maker_amount, taker_amount, side: side as u8, signature_type: self.sig_type as u8, timestamp: U256::from(timestamp), - metadata: B256::from_str(&metadata).map_err(|e| { - PolyfillError::validation(format!("Invalid metadata bytes32 value: {e}")) - })?, - builder: B256::from_str(&builder).map_err(|e| { - PolyfillError::validation(format!("Invalid builder_code bytes32 value: {e}")) - })?, + metadata: metadata_bytes, + builder: builder_bytes, }; let signature = sign_order_message(&self.signer, order, chain_id, exchange)?; Ok(SignedOrderRequest { salt: seed, - maker: self.funder.to_checksum(None), - signer: self.signer.address().to_checksum(None), + maker: self.funder_checksum.clone(), + signer: self.signer_checksum.clone(), token_id, maker_amount: maker_amount.to_string(), taker_amount: taker_amount.to_string(), @@ -585,6 +654,90 @@ impl OrderBuilder { } } +impl PreparedOrderPath { + /// Create and sign a limit order using the cached market/token context. + pub fn create_limit_order( + &self, + side: Side, + price: Decimal, + size: Decimal, + expiration: Option, + ) -> Result { + let (maker_amount, taker_amount) = + self.builder + .get_order_amounts(side, size, price, &self.round_config)?; + + self.build_signed_order(side, maker_amount, taker_amount, expiration.unwrap_or(0)) + } + + /// Create and sign a market order using the cached market/token context. + pub fn create_market_order( + &self, + side: Side, + amount: Decimal, + price: Decimal, + order_type: OrderType, + ) -> Result { + if !matches!(order_type, OrderType::FOK | OrderType::FAK) { + return Err(PolyfillError::validation( + "Market orders only support FOK and FAK order types", + )); + } + + let (maker_amount, taker_amount) = + self.builder + .get_market_order_amounts(side, amount, price, &self.round_config)?; + + self.build_signed_order(side, maker_amount, taker_amount, 0) + } + + fn build_signed_order( + &self, + side: Side, + maker_amount: U256, + taker_amount: U256, + expiration: u64, + ) -> Result { + let seed = generate_seed(); + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_millis(); + + let order = SignedOrderMessage { + salt: U256::from(seed), + maker: self.builder.funder, + signer: self.builder.signer_address, + token_id: self.token_id_u256, + maker_amount, + taker_amount, + side: side as u8, + signature_type: self.builder.sig_type as u8, + timestamp: U256::from(timestamp), + metadata: self.metadata_bytes, + builder: self.builder_bytes, + }; + + let signature = sign_order_message_with_domain(&self.builder.signer, order, &self.domain)?; + + Ok(SignedOrderRequest { + salt: seed, + maker: self.builder.funder_checksum.clone(), + signer: self.builder.signer_checksum.clone(), + token_id: self.token_id.clone(), + maker_amount: maker_amount.to_string(), + taker_amount: taker_amount.to_string(), + expiration: expiration.to_string(), + side: side.as_str().to_string(), + signature_type: self.builder.sig_type as u8, + timestamp: timestamp.to_string(), + metadata: self.metadata.clone(), + builder: self.builder_code.clone(), + signature, + }) + } +} + #[cfg(test)] mod tests { use super::*; @@ -766,6 +919,77 @@ mod tests { assert_eq!(order.metadata, BYTES32_ZERO); } + #[test] + fn test_prepared_order_path_creates_equivalent_limit_order_fields() { + let builder = test_builder(); + let args = OrderArgs { + token_id: "123456".to_string(), + price: Decimal::from_str("0.45").unwrap(), + size: Decimal::from_str("12.34").unwrap(), + side: Side::BUY, + expiration: Some(1_900_000_000), + builder_code: Some(BYTES32_ZERO.to_string()), + metadata: None, + }; + let options = CreateOrderOptions { + tick_size: Some(Decimal::from_str("0.01").unwrap()), + neg_risk: Some(false), + }; + + let order = builder.create_order(137, &args, &options).unwrap(); + let prepared = builder + .prepare_order_path( + 137, + args.token_id.clone(), + options.tick_size.unwrap(), + options.neg_risk.unwrap(), + args.builder_code.as_deref(), + args.metadata.as_deref(), + ) + .unwrap(); + let prepared_order = prepared + .create_limit_order(args.side, args.price, args.size, args.expiration) + .unwrap(); + + assert_eq!(prepared_order.maker, order.maker); + assert_eq!(prepared_order.signer, order.signer); + assert_eq!(prepared_order.token_id, order.token_id); + assert_eq!(prepared_order.maker_amount, order.maker_amount); + assert_eq!(prepared_order.taker_amount, order.taker_amount); + assert_eq!(prepared_order.expiration, order.expiration); + assert_eq!(prepared_order.side, order.side); + assert_eq!(prepared_order.signature_type, order.signature_type); + assert_eq!(prepared_order.metadata, order.metadata); + assert_eq!(prepared_order.builder, order.builder); + assert!(prepared_order.signature.starts_with("0x")); + } + + #[test] + fn test_prepared_market_order_rejects_unsupported_order_type() { + let builder = test_builder(); + let prepared = builder + .prepare_order_path( + 137, + "123456", + Decimal::from_str("0.01").unwrap(), + false, + None, + None, + ) + .unwrap(); + + let err = prepared + .create_market_order( + Side::BUY, + Decimal::from_str("10").unwrap(), + Decimal::from_str("0.45").unwrap(), + OrderType::GTC, + ) + .unwrap_err(); + + assert!(matches!(err, PolyfillError::Validation { .. })); + } + #[test] fn test_create_market_order_supports_fak() { let builder = test_builder();