Skip to main content

nautilus_polymarket/signing/
eip712.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! EIP-712 order signing for the Polymarket CTF Exchange.
17//!
18//! Orders on Polymarket are signed typed structured data (EIP-712) against the
19//! CTF Exchange contract on Polygon. Two exchange contracts exist:
20//! - [`CTF_EXCHANGE`]: Standard binary markets.
21//! - [`NEG_RISK_CTF_EXCHANGE`]: Negative-risk (multi-outcome) markets.
22//!
23//! Both share the same EIP-712 domain name and version; only the
24//! `verifyingContract` differs.
25//!
26//! This module also owns the CLOB V2 contract identities and ordered on-chain
27//! approval plan ([`approval_plan`]) used by the set-allowances binary.
28
29use std::str::FromStr;
30
31use alloy::{
32    signers::{SignerSync, local::PrivateKeySigner},
33    sol_types::{SolStruct, SolValue, eip712_domain},
34};
35use alloy_primitives::{Address, B256, FixedBytes, U256, address, keccak256};
36use rust_decimal::Decimal;
37
38use crate::{
39    common::{
40        credential::EvmPrivateKey,
41        enums::{PolymarketOrderSide, SignatureType},
42    },
43    http::{
44        error::{Error, Result},
45        models::PolymarketOrder,
46    },
47};
48
49// L1 ClobAuth constants
50const CLOB_AUTH_DOMAIN_NAME: &str = "ClobAuthDomain";
51const CLOB_AUTH_DOMAIN_VERSION: &str = "1";
52const CLOB_AUTH_MESSAGE: &str = "This message attests that I control the given wallet";
53
54/// CTF Exchange contract address on Polygon mainnet (CLOB V2).
55pub const CTF_EXCHANGE: Address = address!("0xE111180000d2663C0091e4f400237545B87B996B");
56
57/// Neg Risk CTF Exchange contract address on Polygon mainnet (CLOB V2).
58pub const NEG_RISK_CTF_EXCHANGE: Address = address!("0xe2222d279d744050d28e00520010520000310F59");
59
60/// Neg Risk CTF collateral adapter address on Polygon mainnet.
61pub const NEG_RISK_CTF_COLLATERAL_ADAPTER: Address =
62    address!("0xadA2005600Dec949baf300f4C6120000bDB6eAab");
63
64/// Polymarket pUSD collateral token contract address on Polygon mainnet.
65pub const POLYMARKET_COLLATERAL_TOKEN: Address =
66    address!("0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB");
67
68/// Conditional Tokens Framework contract address on Polygon mainnet.
69pub const CONDITIONAL_TOKENS: Address = address!("0x4D97DCd97eC945f40cF65F87097ACe5EA0476045");
70
71/// Complete spender set requiring collateral approval for Polymarket CLOB V2 orders.
72pub const COLLATERAL_APPROVAL_TARGETS: &[Address] = &[
73    CTF_EXCHANGE,
74    NEG_RISK_CTF_EXCHANGE,
75    NEG_RISK_CTF_COLLATERAL_ADAPTER,
76];
77
78/// One transaction in the Polymarket approval plan.
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum PolymarketApproval {
81    /// Approves a contract to spend pUSD collateral.
82    Collateral {
83        /// Collateral token contract receiving the approval call.
84        contract: Address,
85        /// Contract receiving the collateral allowance.
86        spender: Address,
87        /// Collateral allowance amount.
88        amount: U256,
89    },
90    /// Enables a contract as an operator for conditional tokens.
91    ConditionalTokens {
92        /// Conditional Tokens contract receiving the operator call.
93        contract: Address,
94        /// Contract receiving conditional-token operator authority.
95        operator: Address,
96        /// Whether operator authority is enabled.
97        approved: bool,
98    },
99}
100
101/// Returns the ordered approval plan for Polymarket CLOB V2.
102pub fn approval_plan() -> impl Iterator<Item = PolymarketApproval> {
103    COLLATERAL_APPROVAL_TARGETS
104        .iter()
105        .copied()
106        .flat_map(|target| {
107            [
108                PolymarketApproval::Collateral {
109                    contract: POLYMARKET_COLLATERAL_TOKEN,
110                    spender: target,
111                    amount: U256::MAX,
112                },
113                PolymarketApproval::ConditionalTokens {
114                    contract: CONDITIONAL_TOKENS,
115                    operator: target,
116                    approved: true,
117                },
118            ]
119        })
120}
121
122const DOMAIN_NAME: &str = "Polymarket CTF Exchange";
123const DOMAIN_VERSION: &str = "2";
124const POLYGON_CHAIN_ID: u64 = 137;
125const ORDER_TYPE_STRING: &str = concat!(
126    "Order(uint256 salt,address maker,address signer,uint256 tokenId,",
127    "uint256 makerAmount,uint256 takerAmount,uint8 side,uint8 signatureType,",
128    "uint256 timestamp,bytes32 metadata,bytes32 builder)",
129);
130const SOLADY_TYPE_STRING: &str = concat!(
131    "TypedDataSign(Order contents,string name,string version,uint256 chainId,",
132    "address verifyingContract,bytes32 salt)",
133    "Order(uint256 salt,address maker,address signer,uint256 tokenId,",
134    "uint256 makerAmount,uint256 takerAmount,uint8 side,uint8 signatureType,",
135    "uint256 timestamp,bytes32 metadata,bytes32 builder)",
136);
137const DOMAIN_TYPE_STRING: &str =
138    "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)";
139const DEPOSIT_WALLET_DOMAIN_NAME: &str = "DepositWallet";
140const DEPOSIT_WALLET_DOMAIN_VERSION: &str = "1";
141
142// EIP-712 ClobAuth struct for L1 API authentication.
143//
144// Reference: <https://docs.polymarket.com/api-reference/authentication#l1-authentication>
145alloy::sol! {
146    struct ClobAuth {
147        address address;
148        string timestamp;
149        uint256 nonce;
150        string message;
151    }
152}
153
154// EIP-712 Order struct for CLOB V2 CTFExchange.
155//
156// Fees are set by the protocol at match time (not signed) and per-address
157// uniqueness comes from `timestamp` (milliseconds) rather than `nonce`.
158alloy::sol! {
159    struct Order {
160        uint256 salt;
161        address maker;
162        address signer;
163        uint256 tokenId;
164        uint256 makerAmount;
165        uint256 takerAmount;
166        uint8 side;
167        uint8 signatureType;
168        uint256 timestamp;
169        bytes32 metadata;
170        bytes32 builder;
171    }
172}
173
174/// EIP-712 order signer for the Polymarket CTF Exchange.
175#[derive(Debug)]
176pub struct OrderSigner {
177    signer: PrivateKeySigner,
178}
179
180impl OrderSigner {
181    /// Creates a new [`OrderSigner`] from an EVM private key.
182    pub fn new(private_key: &EvmPrivateKey) -> Result<Self> {
183        let key_hex = private_key
184            .as_hex()
185            .strip_prefix("0x")
186            .unwrap_or(private_key.as_hex());
187        let signer = PrivateKeySigner::from_str(key_hex)
188            .map_err(|e| Error::bad_request(format!("Failed to create signer: {e}")))?;
189        Ok(Self { signer })
190    }
191
192    /// Returns the signer's Ethereum address.
193    #[must_use]
194    pub fn address(&self) -> Address {
195        self.signer.address()
196    }
197
198    /// Signs a [`PolymarketOrder`] and returns the hex-encoded ECDSA signature.
199    ///
200    /// The `neg_risk` flag selects which exchange contract to use as the
201    /// EIP-712 `verifyingContract`.
202    ///
203    /// # Errors
204    ///
205    /// Returns an error if a non-`POLY_1271` order signer does not match this
206    /// signer's address, or if a `POLY_1271` order does not use the deposit
207    /// wallet for both `maker` and `signer`.
208    pub fn sign_order(&self, order: &PolymarketOrder, neg_risk: bool) -> Result<String> {
209        let order_signer = parse_address(&order.signer, "signer")?;
210        let order_maker = parse_address(&order.maker, "maker")?;
211        if order.signature_type == SignatureType::Poly1271 {
212            if order_signer != order_maker {
213                return Err(Error::bad_request(format!(
214                    "POLY_1271 orders require maker and signer to both be the deposit wallet, maker was {order_maker}, signer was {order_signer}",
215                )));
216            }
217        } else if order_signer != self.signer.address() {
218            return Err(Error::bad_request(format!(
219                "Order signer {order_signer} does not match local signer {}",
220                self.signer.address(),
221            )));
222        }
223
224        let eip712_order = build_eip712_order(order)?;
225        let contract = exchange_contract(neg_risk);
226
227        if order.signature_type == SignatureType::Poly1271 {
228            return self.sign_poly_1271_order(&eip712_order, contract);
229        }
230
231        let domain = eip712_domain! {
232            name: DOMAIN_NAME,
233            version: DOMAIN_VERSION,
234            chain_id: POLYGON_CHAIN_ID,
235            verifying_contract: contract,
236        };
237
238        let signing_hash = eip712_order.eip712_signing_hash(&domain);
239        self.sign_hash(&signing_hash.0)
240    }
241
242    fn sign_poly_1271_order(&self, order: &Order, contract: Address) -> Result<String> {
243        let contents_hash = poly_1271_contents_hash(order);
244        let wallet_struct_hash = poly_1271_wallet_struct_hash(contents_hash, order.signer);
245        let app_domain_separator = ctf_exchange_domain_separator(contract);
246        let signing_hash = typed_data_hash(app_domain_separator, wallet_struct_hash);
247        let signature = self.sign_hash_b256(&signing_hash)?;
248
249        let mut encoded =
250            Vec::with_capacity(65 + 32 + 32 + ORDER_TYPE_STRING.len() + std::mem::size_of::<u16>());
251        encoded.extend_from_slice(&signature);
252        encoded.extend_from_slice(app_domain_separator.as_slice());
253        encoded.extend_from_slice(contents_hash.as_slice());
254        encoded.extend_from_slice(ORDER_TYPE_STRING.as_bytes());
255        encoded.extend_from_slice(&(ORDER_TYPE_STRING.len() as u16).to_be_bytes());
256
257        Ok(format!(
258            "0x{}",
259            alloy_primitives::hex::encode(encoded.as_slice())
260        ))
261    }
262
263    fn sign_hash_b256(&self, hash: &B256) -> Result<[u8; 65]> {
264        let signature = self
265            .signer
266            .sign_hash_sync(hash)
267            .map_err(|e| Error::bad_request(format!("Failed to sign order: {e}")))?;
268        Ok(signature.as_bytes())
269    }
270
271    fn sign_hash(&self, hash: &[u8; 32]) -> Result<String> {
272        let hash_b256 = B256::from(*hash);
273        let signature = self.sign_hash_b256(&hash_b256)?;
274        Ok(format!(
275            "0x{}",
276            alloy_primitives::hex::encode(signature.as_slice())
277        ))
278    }
279}
280
281/// Computes the EIP-712 signing hash used by Polymarket as the order ID.
282///
283/// The `neg_risk` flag selects which exchange contract to use as the
284/// EIP-712 `verifyingContract`.
285pub fn order_hash(order: &PolymarketOrder, neg_risk: bool) -> Result<B256> {
286    let eip712_order = build_eip712_order(order)?;
287    let contract = exchange_contract(neg_risk);
288
289    let domain = eip712_domain! {
290        name: DOMAIN_NAME,
291        version: DOMAIN_VERSION,
292        chain_id: POLYGON_CHAIN_ID,
293        verifying_contract: contract,
294    };
295
296    Ok(eip712_order.eip712_signing_hash(&domain))
297}
298
299const fn exchange_contract(neg_risk: bool) -> Address {
300    if neg_risk {
301        NEG_RISK_CTF_EXCHANGE
302    } else {
303        CTF_EXCHANGE
304    }
305}
306
307fn order_type_hash() -> B256 {
308    keccak256(ORDER_TYPE_STRING.as_bytes())
309}
310
311fn solady_type_hash() -> B256 {
312    keccak256(SOLADY_TYPE_STRING.as_bytes())
313}
314
315fn domain_type_hash() -> B256 {
316    keccak256(DOMAIN_TYPE_STRING.as_bytes())
317}
318
319fn domain_name_hash() -> B256 {
320    keccak256(DOMAIN_NAME.as_bytes())
321}
322
323fn domain_version_hash() -> B256 {
324    keccak256(DOMAIN_VERSION.as_bytes())
325}
326
327fn deposit_wallet_name_hash() -> B256 {
328    keccak256(DEPOSIT_WALLET_DOMAIN_NAME.as_bytes())
329}
330
331fn deposit_wallet_version_hash() -> B256 {
332    keccak256(DEPOSIT_WALLET_DOMAIN_VERSION.as_bytes())
333}
334
335fn poly_1271_contents_hash(order: &Order) -> B256 {
336    let tuple = (
337        order_type_hash(),
338        order.salt,
339        order.maker,
340        order.signer,
341        order.tokenId,
342        order.makerAmount,
343        order.takerAmount,
344        U256::from(order.side),
345        U256::from(order.signatureType),
346        order.timestamp,
347        order.metadata,
348        order.builder,
349    );
350    keccak256(tuple.abi_encode())
351}
352
353fn poly_1271_wallet_struct_hash(contents_hash: B256, deposit_wallet: Address) -> B256 {
354    let tuple = (
355        solady_type_hash(),
356        contents_hash,
357        deposit_wallet_name_hash(),
358        deposit_wallet_version_hash(),
359        U256::from(POLYGON_CHAIN_ID),
360        deposit_wallet,
361        FixedBytes::<32>::ZERO,
362    );
363    keccak256(tuple.abi_encode())
364}
365
366fn ctf_exchange_domain_separator(contract: Address) -> B256 {
367    let tuple = (
368        domain_type_hash(),
369        domain_name_hash(),
370        domain_version_hash(),
371        U256::from(POLYGON_CHAIN_ID),
372        contract,
373    );
374    keccak256(tuple.abi_encode())
375}
376
377fn typed_data_hash(domain_separator: B256, struct_hash: B256) -> B256 {
378    let mut bytes = Vec::with_capacity(2 + 32 + 32);
379    bytes.push(0x19);
380    bytes.push(0x01);
381    bytes.extend_from_slice(domain_separator.as_slice());
382    bytes.extend_from_slice(struct_hash.as_slice());
383    keccak256(bytes)
384}
385
386/// Signs a ClobAuth EIP-712 message for L1 API authentication.
387///
388/// Used to create or derive API credentials via the CLOB `/auth/api-key`
389/// and `/auth/derive-api-key` endpoints.
390///
391/// Returns `(signer_address_hex, signature_hex)`.
392pub fn sign_clob_auth(
393    private_key: &EvmPrivateKey,
394    timestamp: &str,
395    nonce: u64,
396) -> Result<(String, String)> {
397    let key_hex = private_key
398        .as_hex()
399        .strip_prefix("0x")
400        .unwrap_or(private_key.as_hex());
401    let signer = PrivateKeySigner::from_str(key_hex)
402        .map_err(|e| Error::bad_request(format!("Failed to create signer: {e}")))?;
403
404    let address = signer.address();
405
406    let auth = ClobAuth {
407        address,
408        timestamp: timestamp.to_string(),
409        nonce: U256::from(nonce),
410        message: CLOB_AUTH_MESSAGE.to_string(),
411    };
412
413    let domain = eip712_domain! {
414        name: CLOB_AUTH_DOMAIN_NAME,
415        version: CLOB_AUTH_DOMAIN_VERSION,
416        chain_id: POLYGON_CHAIN_ID,
417    };
418
419    let signing_hash = auth.eip712_signing_hash(&domain);
420    let signature = signer
421        .sign_hash_sync(&signing_hash)
422        .map_err(|e| Error::bad_request(format!("Failed to sign ClobAuth: {e}")))?;
423
424    let r = signature.r();
425    let s = signature.s();
426    let v = if signature.v() { 28u8 } else { 27u8 };
427
428    Ok((
429        format!("{address:#x}"),
430        format!("0x{r:064x}{s:064x}{v:02x}"),
431    ))
432}
433
434// Converts a PolymarketOrder to the EIP-712 Order struct
435fn build_eip712_order(order: &PolymarketOrder) -> Result<Order> {
436    Ok(Order {
437        salt: U256::from(order.salt),
438        maker: parse_address(&order.maker, "maker")?,
439        signer: parse_address(&order.signer, "signer")?,
440        tokenId: U256::from_str(order.token_id.as_str())
441            .map_err(|e| Error::bad_request(format!("Invalid token ID: {e}")))?,
442        makerAmount: decimal_to_u256(order.maker_amount, "maker_amount")?,
443        takerAmount: decimal_to_u256(order.taker_amount, "taker_amount")?,
444        side: order_side_to_u8(order.side),
445        signatureType: order.signature_type as u8,
446        timestamp: U256::from_str(&order.timestamp)
447            .map_err(|e| Error::bad_request(format!("Invalid timestamp: {e}")))?,
448        metadata: parse_bytes32(&order.metadata, "metadata")?,
449        builder: parse_bytes32(&order.builder, "builder")?,
450    })
451}
452
453fn parse_address(addr: &str, field: &str) -> Result<Address> {
454    Address::from_str(addr).map_err(|e| Error::bad_request(format!("Invalid {field} address: {e}")))
455}
456
457fn parse_bytes32(value: &str, field: &str) -> Result<FixedBytes<32>> {
458    FixedBytes::<32>::from_str(value)
459        .map_err(|e| Error::bad_request(format!("Invalid {field} bytes32: {e}")))
460}
461
462fn decimal_to_u256(d: Decimal, field: &str) -> Result<U256> {
463    let normalized = d.normalize();
464    if normalized.scale() != 0 {
465        return Err(Error::bad_request(format!("{field} must be an integer")));
466    }
467    let mantissa = normalized.mantissa();
468    if mantissa < 0 {
469        return Err(Error::bad_request(format!("{field} must be non-negative")));
470    }
471    Ok(U256::from(mantissa as u128))
472}
473
474fn order_side_to_u8(side: PolymarketOrderSide) -> u8 {
475    match side {
476        PolymarketOrderSide::Buy => 0,
477        PolymarketOrderSide::Sell => 1,
478    }
479}
480
481#[cfg(test)]
482mod tests {
483    use alloy_primitives::{Signature, keccak256};
484    use nautilus_core::hex;
485    use rstest::rstest;
486    use rust_decimal_macros::dec;
487    use ustr::Ustr;
488
489    use super::*;
490    use crate::common::enums::SignatureType;
491
492    const TEST_PRIVATE_KEY: &str =
493        "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
494
495    fn test_signer() -> OrderSigner {
496        let pk = EvmPrivateKey::new(TEST_PRIVATE_KEY).unwrap();
497        OrderSigner::new(&pk).unwrap()
498    }
499
500    const ZERO_BYTES32: &str = "0x0000000000000000000000000000000000000000000000000000000000000000";
501
502    fn test_order() -> PolymarketOrder {
503        PolymarketOrder {
504            salt: 123456789,
505            maker: "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266".to_string(),
506            signer: "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266".to_string(),
507            token_id: Ustr::from(
508                "71321045679252212594626385532706912750332728571942532289631379312455583992563",
509            ),
510            maker_amount: dec!(100000000),
511            taker_amount: dec!(50000000),
512            side: PolymarketOrderSide::Buy,
513            signature_type: SignatureType::Eoa,
514            expiration: "0".to_string(),
515            timestamp: "1713398400000".to_string(),
516            metadata: ZERO_BYTES32.to_string(),
517            builder: ZERO_BYTES32.to_string(),
518            signature: String::new(),
519        }
520    }
521
522    #[rstest]
523    fn test_order_typehash_matches_contract() {
524        // ORDER_TYPEHASH from the CLOB V2 CTFExchange contract
525        let expected = keccak256(
526            "Order(uint256 salt,address maker,address signer,uint256 tokenId,uint256 makerAmount,uint256 takerAmount,uint8 side,uint8 signatureType,uint256 timestamp,bytes32 metadata,bytes32 builder)",
527        );
528        let order = test_order();
529        let eip712_order = build_eip712_order(&order).unwrap();
530        assert_eq!(eip712_order.eip712_type_hash(), expected);
531    }
532
533    #[rstest]
534    fn test_signer_address_derivation() {
535        let signer = test_signer();
536        // Hardhat account #0
537        let expected = Address::from_str("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266").unwrap();
538        assert_eq!(signer.address(), expected);
539    }
540
541    #[rstest]
542    fn test_sign_order_format() {
543        let signer = test_signer();
544        let order = test_order();
545
546        let sig = signer.sign_order(&order, false).unwrap();
547
548        assert!(sig.starts_with("0x"));
549        assert_eq!(sig.len(), 132); // 0x + r(64) + s(64) + v(2)
550    }
551
552    #[rstest]
553    fn test_sign_poly_1271_order_format() {
554        let signer = test_signer();
555        let mut order = test_order();
556        order.maker = "0x1111111111111111111111111111111111111111".to_string();
557        order.signer = order.maker.clone();
558        order.signature_type = SignatureType::Poly1271;
559
560        let sig = signer.sign_order(&order, false).unwrap();
561
562        assert!(sig.starts_with("0x"));
563        assert_eq!(sig.len(), 636);
564    }
565
566    #[rstest]
567    fn test_sign_poly_1271_order_requires_deposit_wallet_signer() {
568        let signer = test_signer();
569        let mut order = test_order();
570        order.maker = "0x1111111111111111111111111111111111111111".to_string();
571        order.signature_type = SignatureType::Poly1271;
572
573        let err = signer.sign_order(&order, false).unwrap_err();
574
575        assert!(
576            err.to_string()
577                .contains("maker and signer to both be the deposit wallet")
578        );
579    }
580
581    #[rstest]
582    fn test_sign_order_deterministic() {
583        let signer = test_signer();
584        let order = test_order();
585
586        let sig1 = signer.sign_order(&order, false).unwrap();
587        let sig2 = signer.sign_order(&order, false).unwrap();
588        assert_eq!(sig1, sig2);
589    }
590
591    #[rstest]
592    fn test_sign_order_neg_risk_differs() {
593        let signer = test_signer();
594        let order = test_order();
595
596        let sig_normal = signer.sign_order(&order, false).unwrap();
597        let sig_neg_risk = signer.sign_order(&order, true).unwrap();
598        assert_ne!(sig_normal, sig_neg_risk);
599    }
600
601    #[rstest]
602    fn test_sign_order_sell_side() {
603        let signer = test_signer();
604        let mut order = test_order();
605        let sig_buy = signer.sign_order(&order, false).unwrap();
606
607        order.side = PolymarketOrderSide::Sell;
608        let sig_sell = signer.sign_order(&order, false).unwrap();
609        assert_ne!(sig_buy, sig_sell);
610    }
611
612    #[rstest]
613    fn test_sign_order_different_amounts() {
614        let signer = test_signer();
615        let mut order = test_order();
616        let sig1 = signer.sign_order(&order, false).unwrap();
617
618        order.maker_amount = dec!(200000000);
619        let sig2 = signer.sign_order(&order, false).unwrap();
620        assert_ne!(sig1, sig2);
621    }
622
623    #[rstest]
624    fn test_build_eip712_order() {
625        let order = test_order();
626        let eip712 = build_eip712_order(&order).unwrap();
627
628        assert_eq!(eip712.salt, U256::from(123456789u64));
629        assert_eq!(
630            eip712.maker,
631            Address::from_str("0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266").unwrap()
632        );
633        assert_eq!(eip712.makerAmount, U256::from(100000000u128));
634        assert_eq!(eip712.takerAmount, U256::from(50000000u128));
635        assert_eq!(eip712.side, 0); // BUY
636        assert_eq!(eip712.signatureType, 0); // EOA
637        assert_eq!(eip712.timestamp, U256::from(1713398400000u128));
638        assert_eq!(eip712.metadata, FixedBytes::<32>::ZERO);
639        assert_eq!(eip712.builder, FixedBytes::<32>::ZERO);
640    }
641
642    #[rstest]
643    fn test_build_eip712_order_with_builder_code() {
644        let mut order = test_order();
645        order.builder =
646            "0x0000000000000000000000000000000000000000000000000000000000000001".to_string();
647        let eip712 = build_eip712_order(&order).unwrap();
648
649        let mut expected = [0u8; 32];
650        expected[31] = 1;
651        assert_eq!(eip712.builder, FixedBytes::<32>::from(expected));
652    }
653
654    #[rstest]
655    fn test_decimal_to_u256_integer() {
656        let result = decimal_to_u256(dec!(100000000), "test").unwrap();
657        assert_eq!(result, U256::from(100000000u128));
658    }
659
660    #[rstest]
661    fn test_decimal_to_u256_zero() {
662        let result = decimal_to_u256(dec!(0), "test").unwrap();
663        assert_eq!(result, U256::ZERO);
664    }
665
666    #[rstest]
667    fn test_decimal_to_u256_rejects_fractional() {
668        let result = decimal_to_u256(dec!(100.5), "test");
669        assert!(result.is_err());
670    }
671
672    #[rstest]
673    fn test_decimal_to_u256_rejects_negative() {
674        let result = decimal_to_u256(dec!(-1), "test");
675        assert!(result.is_err());
676    }
677
678    #[rstest]
679    fn test_order_side_mapping() {
680        assert_eq!(order_side_to_u8(PolymarketOrderSide::Buy), 0);
681        assert_eq!(order_side_to_u8(PolymarketOrderSide::Sell), 1);
682    }
683
684    #[rstest]
685    fn test_contract_addresses_nonzero() {
686        assert_ne!(CTF_EXCHANGE, Address::ZERO);
687        assert_ne!(NEG_RISK_CTF_EXCHANGE, Address::ZERO);
688        assert_ne!(CTF_EXCHANGE, NEG_RISK_CTF_EXCHANGE);
689    }
690
691    #[rstest]
692    fn test_v2_contract_addresses_pinned() {
693        // Pin the V2 contract addresses so a revert to V1 is caught by unit tests.
694        // V1 addresses (must NOT match): 0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E,
695        // 0xC5d563A36AE78145C45a50134d48A1215220f80a.
696        assert_eq!(
697            format!("{CTF_EXCHANGE:#x}"),
698            "0xe111180000d2663c0091e4f400237545b87b996b"
699        );
700        assert_eq!(
701            format!("{NEG_RISK_CTF_EXCHANGE:#x}"),
702            "0xe2222d279d744050d28e00520010520000310f59"
703        );
704        assert_eq!(
705            format!("{NEG_RISK_CTF_COLLATERAL_ADAPTER:#x}"),
706            "0xada2005600dec949baf300f4c6120000bdb6eaab"
707        );
708        assert_eq!(
709            format!("{POLYMARKET_COLLATERAL_TOKEN:#x}"),
710            "0xc011a7e12a19f7b1f670d46f03b03f3342e82dfb"
711        );
712        assert_eq!(
713            format!("{CONDITIONAL_TOKENS:#x}"),
714            "0x4d97dcd97ec945f40cf65f87097ace5ea0476045"
715        );
716        assert_eq!(
717            COLLATERAL_APPROVAL_TARGETS,
718            &[
719                CTF_EXCHANGE,
720                NEG_RISK_CTF_EXCHANGE,
721                NEG_RISK_CTF_COLLATERAL_ADAPTER,
722            ]
723        );
724    }
725
726    #[rstest]
727    fn test_approval_plan() {
728        let collateral_token = address!("0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB");
729        let conditional_tokens = address!("0x4D97DCd97eC945f40cF65F87097ACe5EA0476045");
730        let ctf_exchange = address!("0xE111180000d2663C0091e4f400237545B87B996B");
731        let neg_risk_ctf_exchange = address!("0xe2222d279d744050d28e00520010520000310F59");
732        let neg_risk_collateral_adapter = address!("0xadA2005600Dec949baf300f4C6120000bDB6eAab");
733        let expected = vec![
734            PolymarketApproval::Collateral {
735                contract: collateral_token,
736                spender: ctf_exchange,
737                amount: U256::MAX,
738            },
739            PolymarketApproval::ConditionalTokens {
740                contract: conditional_tokens,
741                operator: ctf_exchange,
742                approved: true,
743            },
744            PolymarketApproval::Collateral {
745                contract: collateral_token,
746                spender: neg_risk_ctf_exchange,
747                amount: U256::MAX,
748            },
749            PolymarketApproval::ConditionalTokens {
750                contract: conditional_tokens,
751                operator: neg_risk_ctf_exchange,
752                approved: true,
753            },
754            PolymarketApproval::Collateral {
755                contract: collateral_token,
756                spender: neg_risk_collateral_adapter,
757                amount: U256::MAX,
758            },
759            PolymarketApproval::ConditionalTokens {
760                contract: conditional_tokens,
761                operator: neg_risk_collateral_adapter,
762                approved: true,
763            },
764        ];
765
766        assert_eq!(approval_plan().collect::<Vec<_>>(), expected);
767    }
768
769    #[rstest]
770    fn test_domain_version_is_v2() {
771        // Domain version is embedded in the EIP-712 signing hash; a revert to
772        // "1" would silently break V2 order acceptance.
773        assert_eq!(DOMAIN_VERSION, "2");
774    }
775
776    #[rstest]
777    fn test_sign_order_recoverable() {
778        let signer = test_signer();
779        let order = test_order();
780        let sig_hex = signer.sign_order(&order, false).unwrap();
781
782        let sig_bytes = hex::decode(&sig_hex[2..]).unwrap();
783        assert_eq!(sig_bytes.len(), 65);
784
785        let r = U256::from_be_slice(&sig_bytes[..32]);
786        let s = U256::from_be_slice(&sig_bytes[32..64]);
787        let v = sig_bytes[64];
788        let y_parity = v == 28;
789
790        let signature = Signature::new(r, s, y_parity);
791
792        let eip712_order = build_eip712_order(&order).unwrap();
793        let domain = eip712_domain! {
794            name: DOMAIN_NAME,
795            version: DOMAIN_VERSION,
796            chain_id: POLYGON_CHAIN_ID,
797            verifying_contract: CTF_EXCHANGE,
798        };
799        let signing_hash = eip712_order.eip712_signing_hash(&domain);
800
801        let recovered = signature
802            .recover_address_from_prehash(&signing_hash)
803            .unwrap();
804        assert_eq!(recovered, signer.address());
805    }
806
807    // Reference vectors generated with `py_clob_client_v2==1.0.0`'s
808    // `ExchangeOrderBuilderV2`. Same test private key (Hardhat account #0),
809    // same contract addresses and chain id. Locks our EIP-712 hash + ECDSA
810    // signature output to the SDK's, so drift between the two signers (domain
811    // typo, struct field reorder, bytes32 padding, etc.) is caught locally
812    // before orders get sent to the venue.
813    const PARITY_TOKEN_ID: &str =
814        "71321045679252212594626385532706912750332728571942532289631379312455583992563";
815
816    fn parity_order(
817        salt: u64,
818        side: PolymarketOrderSide,
819        signature_type: SignatureType,
820        maker_amount: Decimal,
821        taker_amount: Decimal,
822        timestamp: &str,
823        builder: &str,
824    ) -> PolymarketOrder {
825        PolymarketOrder {
826            salt,
827            maker: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266".to_string(),
828            signer: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266".to_string(),
829            token_id: Ustr::from(PARITY_TOKEN_ID),
830            maker_amount,
831            taker_amount,
832            side,
833            signature_type,
834            expiration: "0".to_string(),
835            timestamp: timestamp.to_string(),
836            metadata: ZERO_BYTES32.to_string(),
837            builder: builder.to_string(),
838            signature: String::new(),
839        }
840    }
841
842    #[rstest]
843    #[case::buy_standard_eoa(
844        parity_order(
845            123456789,
846            PolymarketOrderSide::Buy,
847            SignatureType::Eoa,
848            dec!(100000000),
849            dec!(50000000),
850            "1713398400000",
851            ZERO_BYTES32,
852        ),
853        false,
854        "0x32961c48ddac87ed3582f8e02097cd0eff4fcf80460306bd44b3710438dfa64c",
855        "0x89f178136333c8ebb32a19146cb891233e3202d474be6ef730c24dbc06ae4d2a0c99948a86d9b57de0f2c0bb8ec6964aa244ecf17cfa3a95b86878d0b64ad78a1b",
856    )]
857    #[case::sell_neg_risk_eoa(
858        parity_order(
859            987654321,
860            PolymarketOrderSide::Sell,
861            SignatureType::Eoa,
862            dec!(50000000),
863            dec!(100000000),
864            "1713398400000",
865            ZERO_BYTES32,
866        ),
867        true,
868        "0x8b878404bd92dea2bfea9975c9fcd816ec70a57ae431cb20d67bb773744aaef3",
869        "0xf7d60d64364e2615b08d9f69f3ea9afd3b4f83ecfbf05ddd3ca83f4916277fb97d2d080758d17c8e91c928aceb8ff252477ebaec051b672832500f63b4d36b061c",
870    )]
871    #[case::buy_with_builder_code_eoa(
872        parity_order(
873            1,
874            PolymarketOrderSide::Buy,
875            SignatureType::Eoa,
876            dec!(100000000),
877            dec!(50000000),
878            "1713398500000",
879            "0x0000000000000000000000000000000000000000000000000000000000000001",
880        ),
881        false,
882        "0x3df0b6f6ddfca837bc36964cae968b34ad35640b5d98f557c104da97e804e36a",
883        "0xf4d2b34659e8bc07a9572d40ee5a1639a1157409613b4c21566b1f33fd8fe11a364b3f306668cae7248ca7cdf72378f9266bc5628585aa939644400030671e081c",
884    )]
885    #[case::buy_poly_proxy(
886        // V2 unblocks EIP-1271 smart contract wallet signing. signatureType
887        // enters the typed-data hash directly, so a regression that only
888        // manifests for proxy/safe wallets is undetectable from the Eoa
889        // cases above.
890        parity_order(
891            111_111_111,
892            PolymarketOrderSide::Buy,
893            SignatureType::PolyProxy,
894            dec!(100000000),
895            dec!(50000000),
896            "1713398400000",
897            ZERO_BYTES32,
898        ),
899        false,
900        "0x8f88fe2fb3448f4b8ba639992029f0a47a01a14d15b5f2bf9833516571efd279",
901        "0x71a63c85b730cc934688f23ea6374afffef57a61690eba63dcb97a706c8a8d0f3d2a8e0280f3e252eb83be088ebae6b461a8eda18e559e079f59050b90057afa1c",
902    )]
903    #[case::sell_neg_risk_poly_gnosis_safe(
904        parity_order(
905            222_222_222,
906            PolymarketOrderSide::Sell,
907            SignatureType::PolyGnosisSafe,
908            dec!(50000000),
909            dec!(100000000),
910            "1713398400000",
911            ZERO_BYTES32,
912        ),
913        true,
914        "0xb34248702810a1d76580234a33f942a9801c3680de54cb3ef104572a8d482190",
915        "0xab9d33aee8b578fe5588c4a4b16bbef6fa05fc757020f95b98212e877a919e360a90296f02e97ecbabf3c200271ffe88eb9fd86912e8376cd27237bdad5f3abc1c",
916    )]
917    fn test_signature_matches_py_clob_client_v2(
918        #[case] order: PolymarketOrder,
919        #[case] neg_risk: bool,
920        #[case] expected_hash_hex: &str,
921        #[case] expected_signature_hex: &str,
922    ) {
923        let signer = test_signer();
924
925        let hash = order_hash(&order, neg_risk).unwrap();
926        assert_eq!(format!("{hash:#x}"), expected_hash_hex, "signing hash");
927
928        let signature = signer.sign_order(&order, neg_risk).unwrap();
929        assert_eq!(signature, expected_signature_hex, "signature");
930    }
931
932    #[rstest]
933    #[case::standard_exchange(
934        false,
935        "0x48cfd4c03dcee72230750e2dc5ea71048e91244c2a9fec2ed6ef790a74869596",
936        concat!(
937            "0x780beffb568c4510b8a135a92ca8071f124cf368fb0e902fe451c5f51e555791",
938            "0a2a87968caa08a242de85e83c27375fc09242e897aa77680f56622a54e6e7c",
939            "61c3264e159346253e26a64e00b69032db0e7d32f94628de3e6eecb50304d",
940            "7af3d2d3db4f9eed41f0490532a9460395d96602c6534a931bde4f0e3aad5",
941            "71e4f84a04f726465722875696e743235362073616c742c6164647265737320",
942            "6d616b65722c61646472657373207369676e65722c75696e7432353620746f",
943            "6b656e49642c75696e74323536206d616b6572416d6f756e742c75696e7432",
944            "35362074616b6572416d6f756e742c75696e743820736964652c75696e7438",
945            "207369676e6174757265547970652c75696e743235362074696d657374616d",
946            "702c62797465733332206d657461646174612c62797465733332206275696c",
947            "6465722900ba",
948        ),
949    )]
950    #[case::neg_risk_exchange(
951        true,
952        "0x82b94b9d570fdd7b07f517ea848606a9b74029f2387fbf07d8b9c856d652e608",
953        concat!(
954            "0xa411986b67c58113f8787ee54c41def9376d57ef4262b56438438710720604b7",
955            "00137e0175d7ae6afc40c2988179ad84b1b71a739f0974c8df3c12372f31b22c",
956            "1c9b858f53327b0bd13af8ec14cfb35234fb9eb7b0504d1a4e61f433840d3",
957            "0e81ad3db4f9eed41f0490532a9460395d96602c6534a931bde4f0e3aad571",
958            "e4f84a04f726465722875696e743235362073616c742c61646472657373206d",
959            "616b65722c61646472657373207369676e65722c75696e7432353620746f6b",
960            "656e49642c75696e74323536206d616b6572416d6f756e742c75696e743235",
961            "362074616b6572416d6f756e742c75696e743820736964652c75696e743820",
962            "7369676e6174757265547970652c75696e743235362074696d657374616d70",
963            "2c62797465733332206d657461646174612c62797465733332206275696c64",
964            "65722900ba",
965        ),
966    )]
967    fn test_poly_1271_signature_matches_py_clob_client_v2(
968        #[case] neg_risk: bool,
969        #[case] expected_hash: &str,
970        #[case] expected_signature: &str,
971    ) {
972        let signer = test_signer();
973        let mut order = parity_order(
974            333_333_333,
975            PolymarketOrderSide::Buy,
976            SignatureType::Poly1271,
977            dec!(100000000),
978            dec!(50000000),
979            "1713398400000",
980            ZERO_BYTES32,
981        );
982        order.maker = "0x1111111111111111111111111111111111111111".to_string();
983        order.signer = order.maker.clone();
984
985        let hash = order_hash(&order, neg_risk).unwrap();
986        assert_eq!(format!("{hash:#x}"), expected_hash, "signing hash");
987
988        let signature = signer.sign_order(&order, neg_risk).unwrap();
989        assert_eq!(signature, expected_signature, "signature");
990    }
991}