diff --git a/docs/TESTING.md b/docs/TESTING.md index 9edbee5..9f68a5b 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -53,6 +53,13 @@ export POLYMARKET_PASSPHRASE="your_passphrase" export POLYMARKET_HOST="https://clob.polymarket.com" export POLYMARKET_CHAIN_ID="137" +# Optional, but required when funds live in a Polymarket proxy/Safe wallet. +# 0 = EOA, 1 = Proxy, 2 = Gnosis Safe/browser wallet, 3 = Poly1271. +export POLYMARKET_SIGNATURE_TYPE="2" + +# Optional: override the derived funder/proxy wallet address. +export POLYMARKET_FUNDER_ADDRESS="0x..." + # Optional: pin clob.polymarket.com to a known A record if local DNS is blocked. # Keep the hostname in POLYMARKET_HOST so HTTPS/SNI still validates. export POLYMARKET_RESOLVE_IP="104.18.34.205" diff --git a/examples/demo.rs b/examples/demo.rs index 9eae8e5..b536820 100644 --- a/examples/demo.rs +++ b/examples/demo.rs @@ -100,6 +100,8 @@ impl PolyfillDemo { private_key: None, api_credentials: None, builder_code: None, + signature_type: None, + funder: None, timeout: Some(Duration::from_secs(30)), max_connections: Some(100), }; diff --git a/src/client.rs b/src/client.rs index 75b2360..0de164a 100644 --- a/src/client.rs +++ b/src/client.rs @@ -11,7 +11,7 @@ use crate::types::{ MarketOrderArgs, OrderArgs, OrderType, PostOrder, PostOrderOptions, PostOrderResponse, Side, SignedOrderRequest, }; -use alloy_primitives::U256; +use alloy_primitives::{Address, U256}; use alloy_signer_local::PrivateKeySigner; use reqwest::header::HeaderName; use reqwest::Client; @@ -99,6 +99,8 @@ impl ClobClient { signer: Option, api_creds: Option, builder_code: Option, + sig_type: Option, + funder: Option
, ) -> Self { let dns_cache = tokio::runtime::Handle::try_current().ok().and_then(|_| { tokio::task::block_in_place(|| { @@ -132,7 +134,7 @@ impl ClobClient { let order_builder = signer .clone() - .map(|signer| crate::orders::OrderBuilder::new(signer, None, None)); + .map(|signer| crate::orders::OrderBuilder::new(signer, sig_type, funder)); Self { http_client, @@ -152,7 +154,7 @@ impl ClobClient { /// Now includes DNS caching, connection management, and buffer pooling pub fn new(host: &str) -> Self { let http_client = build_http_client(host, None, None); - Self::build_client(host, 137, http_client, None, None, None) + Self::build_client(host, 137, http_client, None, None, None, None, None) } /// Create a V2-native client from config. @@ -166,6 +168,26 @@ impl ClobClient { None => None, }; + let sig_type = config + .signature_type + .map(crate::orders::sig_type_from_u8) + .transpose()?; + let explicit_funder = config + .funder + .as_deref() + .map(Address::from_str) + .transpose() + .map_err(|e| PolyfillError::config(format!("Invalid funder address: {e}")))?; + let funder = match (&signer, sig_type) { + (Some(signer), Some(sig_type)) => crate::orders::resolve_funder( + signer.address(), + config.chain, + sig_type, + explicit_funder, + )?, + _ => explicit_funder, + }; + let http_client = build_http_client(&config.base_url, config.timeout, config.max_connections); @@ -176,6 +198,8 @@ impl ClobClient { signer, config.api_credentials, config.builder_code, + sig_type, + funder, )) } @@ -187,7 +211,7 @@ impl ClobClient { .build() .expect("Failed to build reqwest client") }); - Self::build_client(host, 137, http_client, None, None, None) + Self::build_client(host, 137, http_client, None, None, None, None, None) } /// Create a client optimized for internet connections @@ -198,7 +222,7 @@ impl ClobClient { .build() .expect("Failed to build reqwest client") }); - Self::build_client(host, 137, http_client, None, None, None) + Self::build_client(host, 137, http_client, None, None, None, None, None) } /// Create a client with L1 headers (for authentication) diff --git a/src/orders.rs b/src/orders.rs index da7bbe1..1b8d141 100644 --- a/src/orders.rs +++ b/src/orders.rs @@ -8,7 +8,7 @@ use crate::errors::{PolyfillError, Result}; use crate::types::{ CreateOrderOptions, MarketOrderArgs, OrderArgs, OrderType, Side, SignedOrderRequest, }; -use alloy_primitives::{Address, B256, U256}; +use alloy_primitives::{keccak256, Address, B256, U256}; use alloy_signer_local::PrivateKeySigner; use rand::Rng; use rust_decimal::Decimal; @@ -21,7 +21,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; pub const BYTES32_ZERO: &str = "0x0000000000000000000000000000000000000000000000000000000000000000"; /// Signature types for orders -#[derive(Copy, Clone)] +#[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum SigType { /// ECDSA EIP712 signatures signed by EOAs Eoa = 0, @@ -29,6 +29,8 @@ pub enum SigType { PolyProxy = 1, /// EIP712 signatures signed by EOAs that own Polymarket Gnosis safes PolyGnosisSafe = 2, + /// EIP-1271 smart contract wallet signatures (V2 orders only) + Poly1271 = 3, } /// Rounding configuration for different tick sizes @@ -52,6 +54,13 @@ pub struct OrderBuilder { funder: Address, } +const POLYGON_PROXY_FACTORY: &str = "0xaB45c5A4B0c941a2F231C04C3f49182e1A254052"; +const POLYGON_SAFE_FACTORY: &str = "0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b"; +const PROXY_INIT_CODE_HASH: &str = + "0xd21df8dc65880a8606f09fe0ce3df9b8869287ab0b058be05aa9e8af6330a00b"; +const SAFE_INIT_CODE_HASH: &str = + "0x2bce2127ff07fb632d16c8347c4ebf501f4841168bed00d9e6ef715ddb6fcecf"; + /// Rounding configurations for different tick sizes static ROUNDING_CONFIG: LazyLock> = LazyLock::new(|| { HashMap::from([ @@ -107,6 +116,70 @@ pub fn get_contract_config(chain_id: u64, neg_risk: bool) -> Option Result { + match signature_type { + 0 => Ok(SigType::Eoa), + 1 => Ok(SigType::PolyProxy), + 2 => Ok(SigType::PolyGnosisSafe), + 3 => Ok(SigType::Poly1271), + other => Err(PolyfillError::validation(format!( + "Unsupported signature_type {other}" + ))), + } +} + +pub fn derive_proxy_wallet(eoa_address: Address, chain_id: u64) -> Result
{ + if chain_id != 137 { + return Err(PolyfillError::config( + "Proxy wallet auto-derivation is only configured for Polygon mainnet", + )); + } + + let factory = Address::from_str(POLYGON_PROXY_FACTORY) + .map_err(|e| PolyfillError::config(format!("Invalid proxy factory address: {e}")))?; + let init_code_hash = B256::from_str(PROXY_INIT_CODE_HASH) + .map_err(|e| PolyfillError::config(format!("Invalid proxy init code hash: {e}")))?; + let salt = keccak256(eoa_address); + Ok(factory.create2(salt, init_code_hash)) +} + +pub fn derive_safe_wallet(eoa_address: Address, chain_id: u64) -> Result
{ + if chain_id != 137 { + return Err(PolyfillError::config( + "Safe wallet auto-derivation is only configured for Polygon mainnet", + )); + } + + let factory = Address::from_str(POLYGON_SAFE_FACTORY) + .map_err(|e| PolyfillError::config(format!("Invalid safe factory address: {e}")))?; + let init_code_hash = B256::from_str(SAFE_INIT_CODE_HASH) + .map_err(|e| PolyfillError::config(format!("Invalid safe init code hash: {e}")))?; + let mut padded = [0_u8; 32]; + padded[12..].copy_from_slice(eoa_address.as_slice()); + let salt = keccak256(padded); + Ok(factory.create2(salt, init_code_hash)) +} + +pub fn resolve_funder( + signer_address: Address, + chain_id: u64, + sig_type: SigType, + funder: Option
, +) -> Result> { + match (sig_type, funder) { + (SigType::Eoa, Some(_)) => Err(PolyfillError::validation( + "funder cannot be set for EOA signature_type", + )), + (SigType::PolyProxy, None) => derive_proxy_wallet(signer_address, chain_id).map(Some), + (SigType::PolyGnosisSafe, None) => derive_safe_wallet(signer_address, chain_id).map(Some), + (SigType::Poly1271, None) => Err(PolyfillError::validation( + "funder is required for Poly1271 signature_type", + )), + (_, Some(Address::ZERO)) => Err(PolyfillError::validation("funder cannot be zero address")), + (_, explicit) => Ok(explicit), + } +} + /// Generate a random seed for order salt fn generate_seed() -> u64 { let mut rng = rand::thread_rng(); @@ -580,6 +653,28 @@ mod tests { assert!(config_unsupported.is_none()); } + #[test] + fn test_signature_type_from_u8() { + assert_eq!(sig_type_from_u8(0).unwrap(), SigType::Eoa); + assert_eq!(sig_type_from_u8(1).unwrap(), SigType::PolyProxy); + assert_eq!(sig_type_from_u8(2).unwrap(), SigType::PolyGnosisSafe); + assert_eq!(sig_type_from_u8(3).unwrap(), SigType::Poly1271); + assert!(sig_type_from_u8(4).is_err()); + } + + #[test] + fn test_derive_polygon_funder_addresses() { + let eoa = Address::from_str("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266").unwrap(); + assert_eq!( + derive_safe_wallet(eoa, 137).unwrap(), + Address::from_str("0xd93b25Cb943D14d0d34FBAf01fc93a0F8b5f6e47").unwrap() + ); + assert_eq!( + derive_proxy_wallet(eoa, 137).unwrap(), + Address::from_str("0x365f0cA36ae1F641E02Fe3b7743673DA42A13a70").unwrap() + ); + } + #[test] fn test_normalize_optional_bytes32_defaults_to_zero() { assert_eq!( diff --git a/src/types.rs b/src/types.rs index c410f74..9fe146f 100644 --- a/src/types.rs +++ b/src/types.rs @@ -806,6 +806,11 @@ pub struct ClientConfig { pub api_credentials: Option, /// Builder code applied to orders when none is specified on the order itself. pub builder_code: Option, + /// Polymarket signature type: 0 EOA, 1 Proxy, 2 Gnosis Safe, 3 Poly1271. + pub signature_type: Option, + /// Address that holds funds for proxy/Safe/smart-contract wallet flows. + /// If omitted for signature type 1 or 2, the Polygon funder is derived from the signer. + pub funder: Option, /// Request timeout pub timeout: Option, /// Maximum number of connections @@ -820,6 +825,8 @@ impl Default for ClientConfig { private_key: None, api_credentials: None, builder_code: None, + signature_type: None, + funder: None, timeout: Some(std::time::Duration::from_secs(30)), max_connections: Some(100), } diff --git a/tests/common/mod.rs b/tests/common/mod.rs index cc1a483..29c26fc 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -13,6 +13,8 @@ pub struct TestConfig { pub api_key: Option, pub api_secret: Option, pub api_passphrase: Option, + pub signature_type: Option, + pub funder: Option, pub test_timeout: Duration, } @@ -33,6 +35,12 @@ impl Default for TestConfig { api_passphrase: env::var("POLYMARKET_API_PASSPHRASE") .or_else(|_| env::var("POLYMARKET_PASSPHRASE")) .ok(), + signature_type: env::var("POLYMARKET_SIGNATURE_TYPE") + .ok() + .and_then(|v| v.parse::().ok()), + funder: env::var("POLYMARKET_FUNDER") + .or_else(|_| env::var("POLYMARKET_FUNDER_ADDRESS")) + .ok(), test_timeout: Duration::from_secs(30), } } @@ -73,6 +81,8 @@ impl TestConfig { base_url: self.host.clone(), chain: self.chain, private_key: Some(private_key.clone()), + signature_type: self.signature_type, + funder: self.funder.clone(), ..ClientConfig::default() }) } @@ -84,6 +94,8 @@ impl TestConfig { println!(" Chain ID: {}", self.chain); println!(" Has Auth: {}", self.has_auth()); println!(" Has API Creds: {}", self.has_api_creds()); + println!(" Signature Type: {:?}", self.signature_type); + println!(" Has Funder Override: {}", self.funder.is_some()); println!(" Timeout: {:?}", self.test_timeout); } } diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index c98df2b..8235c2b 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -25,11 +25,25 @@ fn load_env_vars() -> (String, Option, Option, Option) { (private_key, api_key, api_secret, api_passphrase) } +fn env_signature_type() -> Option { + env::var("POLYMARKET_SIGNATURE_TYPE") + .ok() + .and_then(|value| value.parse::().ok()) +} + +fn env_funder() -> Option { + env::var("POLYMARKET_FUNDER") + .or_else(|_| env::var("POLYMARKET_FUNDER_ADDRESS")) + .ok() +} + fn bootstrap_client(private_key: &str) -> ClobClient { ClobClient::from_config(ClientConfig { base_url: HOST.to_string(), chain: CHAIN_ID, private_key: Some(private_key.to_string()), + signature_type: env_signature_type(), + funder: env_funder(), ..ClientConfig::default() }) .expect("failed to build bootstrap client") @@ -44,6 +58,8 @@ fn authenticated_client( chain: CHAIN_ID, private_key: Some(private_key), api_credentials: Some(api_credentials), + signature_type: env_signature_type(), + funder: env_funder(), ..ClientConfig::default() }) .expect("failed to build authenticated client") @@ -96,39 +112,48 @@ async fn test_real_api_authenticated_order_flow() { .await .expect("Failed to get markets"); - let active_market = markets - .data - .iter() - .find(|m| m.active && !m.closed) - .expect("No active markets found"); + let mut selected_token = None; + let mut selected_midpoint = None; + for market in markets.data.iter().filter(|m| m.active && !m.closed) { + for token in &market.tokens { + let midpoint = match client.get_midpoint(&token.token_id).await { + Ok(midpoint) => midpoint.mid, + Err(_) => continue, + }; + if midpoint > dec!(0.05) { + selected_token = Some(token.token_id.clone()); + selected_midpoint = Some(midpoint); + break; + } + } + if selected_token.is_some() { + break; + } + } - let token_id = &active_market.tokens[0].token_id; + let token_id = selected_token.expect("No active token with midpoint safely above 0.01 found"); + let midpoint = selected_midpoint.expect("midpoint should be selected with token"); println!("PASS: Found active token: {}", token_id); + println!("PASS: Current midpoint: {}", midpoint); - // Step 3: Get current price to place a reasonable order - println!("Step 3: Getting current market price..."); - let midpoint = client - .get_midpoint(token_id) + // Step 4: Create and post a tiny non-marketable BUY order. + // A BUY at 0.01 validates pUSD balance/allowance without requiring outcome-token inventory. + let side = Side::BUY; + let order_price = dec!(0.01); + let order_book = client + .get_order_book(&token_id) .await - .expect("Failed to get midpoint"); - println!("PASS: Current midpoint: {}", midpoint.mid); - - // Step 4: Create and post a small order well away from market price (so it won't fill immediately). - // IMPORTANT: choose side consistently with the price so we don't accidentally create a marketable order. - let (side, order_price) = if midpoint.mid > dec!(0.5) { - (Side::BUY, dec!(0.01)) // Very low buy price, won't fill - } else { - (Side::SELL, dec!(0.99)) // Very high sell price, won't fill - }; + .expect("Failed to get selected order book"); + let order_size = order_book.min_order_size.max(dec!(5)); println!( - "Step 4: Posting {:?} order at price {}...", - side, order_price + "Step 4: Posting {:?} order at price {} and size {}...", + side, order_price, order_size ); let order_args = OrderArgs { - token_id: token_id.clone(), + token_id, price: order_price, - size: dec!(1.0), // Small size (auth is the thing we're testing here) + size: order_size, side, expiration: None, builder_code: None, @@ -255,6 +280,50 @@ async fn test_real_api_get_balance_allowance() { println!("Testing get_balance_allowance..."); + use polyfill_rs::types::{AssetType, BalanceAllowanceParams}; + let collateral_update_params = BalanceAllowanceParams { + asset_type: Some(AssetType::COLLATERAL), + token_id: None, + signature_type: None, + }; + let collateral_update = client + .update_balance_allowance(Some(collateral_update_params)) + .await; + match collateral_update { + Ok(update) => { + println!("PASS: Successfully requested collateral balance/allowance update"); + println!(" Collateral update: {:?}", update); + }, + Err(e) => { + let err_str = format!("{:?}", e); + if err_str.contains("401") { + panic!("FAIL: 401 Unauthorized - authentication failed!"); + } + println!("WARNING: Collateral balance update failed: {:?}", e); + }, + } + + let collateral_params = BalanceAllowanceParams { + asset_type: Some(AssetType::COLLATERAL), + token_id: None, + signature_type: None, + }; + let collateral_result = client.get_balance_allowance(Some(collateral_params)).await; + + match collateral_result { + Ok(balance) => { + println!("PASS: Successfully fetched collateral balance/allowance"); + println!(" Collateral: {:?}", balance); + }, + Err(e) => { + let err_str = format!("{:?}", e); + if err_str.contains("401") { + panic!("FAIL: 401 Unauthorized - authentication failed!"); + } + println!("WARNING: Collateral balance check failed: {:?}", e); + }, + } + // Get a valid token_id first let markets = client .get_sampling_markets(None) @@ -262,7 +331,6 @@ async fn test_real_api_get_balance_allowance() { .expect("Failed to get markets"); let token_id = &markets.data[0].tokens[0].token_id; - use polyfill_rs::types::{AssetType, BalanceAllowanceParams}; let params = BalanceAllowanceParams { asset_type: Some(AssetType::CONDITIONAL), token_id: Some(token_id.clone()),