diff --git a/src/auth.rs b/src/auth.rs index 67c71d6..09129e4 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, Eip712Domain}; +use alloy_sol_types::{eip712_domain, sol, Eip712Domain, SolStruct}; use base64::engine::Engine; use hmac::{Hmac, Mac}; use serde::Serialize; @@ -120,8 +120,22 @@ sol! { bytes32 metadata; bytes32 builder; } + + struct TypedDataSign { + Order contents; + string name; + string version; + uint256 chainId; + address verifyingContract; + bytes32 salt; + } } +const DEPOSIT_WALLET_NAME: &str = "DepositWallet"; +const DEPOSIT_WALLET_VERSION: &str = "1"; +const ERC7739_TYPED_DATA_SIGN_SALT: B256 = B256::ZERO; +const ORDER_TYPE_STRING: &str = "Order(uint256 salt,address maker,address signer,uint256 tokenId,uint256 makerAmount,uint256 takerAmount,uint8 side,uint8 signatureType,uint256 timestamp,bytes32 metadata,bytes32 builder)"; + /// V2 order signing payload. The REST body still carries `expiration`, but the EIP-712 payload /// follows the V2 exchange struct. #[derive(Clone)] @@ -212,7 +226,69 @@ pub fn sign_order_message_with_domain( order: SignedOrderMessage, domain: &PreparedOrderDomain, ) -> Result { - let order = Order { + let order = order_sol(order); + + let signature = signer + .sign_typed_data_sync(&order, &domain.domain) + .map_err(|e| PolyfillError::crypto(format!("Order signature failed: {}", e)))?; + + Ok(encode_prefixed(signature.as_bytes())) +} + +/// Sign a POLY_1271 deposit-wallet order using the ERC-7739 wrapper expected by +/// Polymarket's V2 deposit wallet verifier. +/// +/// The order itself must have `maker == signer == deposit_wallet` and +/// `signatureType == 3`. The EOA key signs a Solady `TypedDataSign(...)` +/// envelope under the CTF exchange order domain; the wire signature appends the +/// app-domain separator, order contents hash, order type string, and a uint16 +/// big-endian type-string length. +pub fn sign_poly1271_order_message_with_domain( + signer: &PrivateKeySigner, + order: SignedOrderMessage, + domain: &PreparedOrderDomain, + deposit_wallet: Address, + chain_id: u64, +) -> Result { + if order.maker != deposit_wallet || order.signer != deposit_wallet { + return Err(PolyfillError::validation( + "POLY_1271 orders require maker and signer to equal the deposit wallet", + )); + } + + let order = order_sol(order); + let app_domain_separator = domain.domain.hash_struct(); + let contents_hash = order.eip712_hash_struct(); + let envelope = TypedDataSign { + contents: order, + name: DEPOSIT_WALLET_NAME.to_string(), + version: DEPOSIT_WALLET_VERSION.to_string(), + chainId: U256::from(chain_id), + verifyingContract: deposit_wallet, + salt: ERC7739_TYPED_DATA_SIGN_SALT, + }; + + let inner = signer + .sign_typed_data_sync(&envelope, &domain.domain) + .map_err(|e| PolyfillError::crypto(format!("POLY_1271 order signature failed: {e}")))?; + + let type_bytes = ORDER_TYPE_STRING.as_bytes(); + let type_len: u16 = type_bytes + .len() + .try_into() + .map_err(|_| PolyfillError::validation("POLY_1271 order type string too long"))?; + let mut wrapped = Vec::with_capacity(65 + 32 + 32 + type_bytes.len() + 2); + wrapped.extend_from_slice(&inner.as_bytes()); + wrapped.extend_from_slice(app_domain_separator.as_slice()); + wrapped.extend_from_slice(contents_hash.as_slice()); + wrapped.extend_from_slice(type_bytes); + wrapped.extend_from_slice(&type_len.to_be_bytes()); + + Ok(encode_prefixed(wrapped)) +} + +fn order_sol(order: SignedOrderMessage) -> Order { + Order { salt: order.salt, maker: order.maker, signer: order.signer, @@ -224,13 +300,7 @@ pub fn sign_order_message_with_domain( timestamp: order.timestamp, metadata: order.metadata, builder: order.builder, - }; - - let signature = signer - .sign_typed_data_sync(&order, &domain.domain) - .map_err(|e| PolyfillError::crypto(format!("Order signature failed: {}", e)))?; - - Ok(encode_prefixed(signature.as_bytes())) + } } /// Build HMAC signature for L2 authentication @@ -393,6 +463,7 @@ pub fn create_l2_headers_with_body_bytes( #[cfg(test)] mod tests { use super::*; + use std::str::FromStr; #[test] fn test_unix_timestamp() { @@ -565,6 +636,47 @@ mod tests { assert_eq!(signature.len(), 132); // 0x + 130 hex chars = 132 total } + #[test] + fn test_poly1271_wrapped_order_signature_golden() { + let signer: PrivateKeySigner = + "0x2222222222222222222222222222222222222222222222222222222222222222" + .parse() + .expect("valid private key"); + let deposit_wallet = + Address::from_str("0x000000000000000000000000000000000000d077").unwrap(); + let exchange = Address::from_str("0xE111180000d2663C0091e4f400237545B87B996B").unwrap(); + let order = SignedOrderMessage { + salt: U256::from(12345), + maker: deposit_wallet, + signer: deposit_wallet, + token_id: U256::from_str_radix( + "100000000000000000000000000000000000000000000000000000000000000000000000000000", + 10, + ) + .unwrap(), + maker_amount: U256::from(1_000_000), + taker_amount: U256::from(5_000_000), + side: 0, + signature_type: 3, + timestamp: U256::from(1_716_000_000_000_u64), + metadata: B256::ZERO, + builder: B256::ZERO, + }; + + let got = sign_poly1271_order_message_with_domain( + &signer, + order, + &PreparedOrderDomain::new(137, exchange), + deposit_wallet, + 137, + ) + .unwrap(); + + const WANT: &str = "0xc1c199d3822d7f465b1f213793ebb7bbc3a77810ceefa89b58ecfeb284e708953c5ec946a41c6fa2a6fac373c78b167dd6834fae6f3e37581111ffb06200f1d21b3264e159346253e26a64e00b69032db0e7d32f94628de3e6eecb50304d7af3d26814859c22020105275eba8b46528be2edd6078dea415bec6d90f2076b0c8ac64f726465722875696e743235362073616c742c61646472657373206d616b65722c61646472657373207369676e65722c75696e7432353620746f6b656e49642c75696e74323536206d616b6572416d6f756e742c75696e743235362074616b6572416d6f756e742c75696e743820736964652c75696e7438207369676e6174757265547970652c75696e743235362074696d657374616d702c62797465733332206d657461646174612c62797465733332206275696c6465722900ba"; + assert_eq!(got, WANT); + assert!(got.len() > 600 && got.len() < 700); + } + #[test] fn test_timestamp_generation() { let ts1 = get_current_unix_time_secs(); diff --git a/src/orders.rs b/src/orders.rs index 7e637e9..1852713 100644 --- a/src/orders.rs +++ b/src/orders.rs @@ -4,7 +4,8 @@ //! for the Polymarket CLOB, including EIP-712 signature generation. use crate::auth::{ - sign_order_message, sign_order_message_with_domain, PreparedOrderDomain, SignedOrderMessage, + sign_order_message, sign_order_message_with_domain, sign_poly1271_order_message_with_domain, + PreparedOrderDomain, SignedOrderMessage, }; use crate::errors::{PolyfillError, Result}; use crate::types::{ @@ -64,6 +65,7 @@ pub struct OrderBuilder { #[derive(Clone)] pub struct PreparedOrderPath { builder: OrderBuilder, + chain_id: u64, token_id: String, token_id_u256: U256, round_config: RoundConfig, @@ -390,6 +392,7 @@ impl OrderBuilder { Ok(PreparedOrderPath { builder: self.clone(), + chain_id, token_id, token_id_u256, round_config, @@ -620,10 +623,12 @@ impl OrderBuilder { let (builder_bytes, builder) = parse_optional_bytes32("builder_code", builder_code)?; let (metadata_bytes, metadata) = parse_optional_bytes32("metadata", metadata)?; + let signer_address = self.order_signer_address(); + let signer_checksum = self.order_signer_checksum(); let order = SignedOrderMessage { salt: U256::from(seed), maker: self.funder, - signer: self.signer_address, + signer: signer_address, token_id: u256_token_id, maker_amount, taker_amount, @@ -634,12 +639,21 @@ impl OrderBuilder { builder: builder_bytes, }; - let signature = sign_order_message(&self.signer, order, chain_id, exchange)?; + let signature = match self.sig_type { + SigType::Poly1271 => sign_poly1271_order_message_with_domain( + &self.signer, + order, + &PreparedOrderDomain::new(chain_id, exchange), + self.funder, + chain_id, + )?, + _ => sign_order_message(&self.signer, order, chain_id, exchange)?, + }; Ok(SignedOrderRequest { salt: seed, maker: self.funder_checksum.clone(), - signer: self.signer_checksum.clone(), + signer: signer_checksum, token_id, maker_amount: maker_amount.to_string(), taker_amount: taker_amount.to_string(), @@ -654,6 +668,22 @@ impl OrderBuilder { } } +impl OrderBuilder { + fn order_signer_address(&self) -> Address { + match self.sig_type { + SigType::Poly1271 => self.funder, + _ => self.signer_address, + } + } + + fn order_signer_checksum(&self) -> String { + match self.sig_type { + SigType::Poly1271 => self.funder_checksum.clone(), + _ => self.signer_checksum.clone(), + } + } +} + impl PreparedOrderPath { /// Create and sign a limit order using the cached market/token context. pub fn create_limit_order( @@ -704,10 +734,12 @@ impl PreparedOrderPath { .expect("Time went backwards") .as_millis(); + let signer_address = self.builder.order_signer_address(); + let signer_checksum = self.builder.order_signer_checksum(); let order = SignedOrderMessage { salt: U256::from(seed), maker: self.builder.funder, - signer: self.builder.signer_address, + signer: signer_address, token_id: self.token_id_u256, maker_amount, taker_amount, @@ -718,12 +750,21 @@ impl PreparedOrderPath { builder: self.builder_bytes, }; - let signature = sign_order_message_with_domain(&self.builder.signer, order, &self.domain)?; + let signature = match self.builder.sig_type { + SigType::Poly1271 => sign_poly1271_order_message_with_domain( + &self.builder.signer, + order, + &self.domain, + self.builder.funder, + self.chain_id, + )?, + _ => 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(), + signer: signer_checksum, token_id: self.token_id.clone(), maker_amount: maker_amount.to_string(), taker_amount: taker_amount.to_string(), @@ -919,6 +960,45 @@ mod tests { assert_eq!(order.metadata, BYTES32_ZERO); } + #[test] + fn test_poly1271_order_uses_deposit_wallet_as_signer_and_wrapped_signature() { + let signer: PrivateKeySigner = + "0x2222222222222222222222222222222222222222222222222222222222222222" + .parse() + .expect("valid private key"); + let funder = Address::from_str("0x000000000000000000000000000000000000d077").unwrap(); + let builder = OrderBuilder::new(signer, Some(SigType::Poly1271), Some(funder)); + + let order = builder + .create_order( + 137, + &OrderArgs { + token_id: "123456".to_string(), + price: Decimal::from_str("0.50").unwrap(), + size: Decimal::from_str("10").unwrap(), + side: Side::BUY, + expiration: None, + builder_code: Some(BYTES32_ZERO.to_string()), + metadata: Some(BYTES32_ZERO.to_string()), + }, + &CreateOrderOptions { + tick_size: Some(Decimal::from_str("0.01").unwrap()), + neg_risk: Some(false), + }, + ) + .unwrap(); + + let funder_checksum = funder.to_checksum(None); + assert_eq!(order.maker, funder_checksum); + assert_eq!(order.signer, funder_checksum); + assert_eq!(order.signature_type, SigType::Poly1271 as u8); + assert!(order.signature.starts_with("0x")); + assert!( + order.signature.len() > 600, + "POLY_1271 signature should be ERC-7739 wrapped" + ); + } + #[test] fn test_prepared_order_path_creates_equivalent_limit_order_fields() { let builder = test_builder();