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
26use std::str::FromStr;
27
28use alloy::{
29    signers::{SignerSync, local::PrivateKeySigner},
30    sol_types::{SolStruct, eip712_domain},
31};
32use alloy_primitives::{Address, B256, FixedBytes, U256, address};
33use rust_decimal::Decimal;
34
35use crate::{
36    common::{credential::EvmPrivateKey, enums::PolymarketOrderSide},
37    http::{
38        error::{Error, Result},
39        models::PolymarketOrder,
40    },
41};
42
43// L1 ClobAuth constants
44const CLOB_AUTH_DOMAIN_NAME: &str = "ClobAuthDomain";
45const CLOB_AUTH_DOMAIN_VERSION: &str = "1";
46const CLOB_AUTH_MESSAGE: &str = "This message attests that I control the given wallet";
47
48/// CTF Exchange contract address on Polygon mainnet (CLOB V2).
49pub const CTF_EXCHANGE: Address = address!("0xE111180000d2663C0091e4f400237545B87B996B");
50
51/// Neg Risk CTF Exchange contract address on Polygon mainnet (CLOB V2).
52pub const NEG_RISK_CTF_EXCHANGE: Address = address!("0xe2222d279d744050d28e00520010520000310F59");
53
54const DOMAIN_NAME: &str = "Polymarket CTF Exchange";
55const DOMAIN_VERSION: &str = "2";
56const POLYGON_CHAIN_ID: u64 = 137;
57
58// EIP-712 ClobAuth struct for L1 API authentication.
59//
60// Reference: <https://docs.polymarket.com/api-reference/authentication#l1-authentication>
61alloy::sol! {
62    struct ClobAuth {
63        address address;
64        string timestamp;
65        uint256 nonce;
66        string message;
67    }
68}
69
70// EIP-712 Order struct for CLOB V2 CTFExchange.
71//
72// Fees are set by the protocol at match time (not signed) and per-address
73// uniqueness comes from `timestamp` (milliseconds) rather than `nonce`.
74alloy::sol! {
75    struct Order {
76        uint256 salt;
77        address maker;
78        address signer;
79        uint256 tokenId;
80        uint256 makerAmount;
81        uint256 takerAmount;
82        uint8 side;
83        uint8 signatureType;
84        uint256 timestamp;
85        bytes32 metadata;
86        bytes32 builder;
87    }
88}
89
90/// EIP-712 order signer for the Polymarket CTF Exchange.
91#[derive(Debug)]
92pub struct OrderSigner {
93    signer: PrivateKeySigner,
94}
95
96impl OrderSigner {
97    /// Creates a new [`OrderSigner`] from an EVM private key.
98    pub fn new(private_key: &EvmPrivateKey) -> Result<Self> {
99        let key_hex = private_key
100            .as_hex()
101            .strip_prefix("0x")
102            .unwrap_or(private_key.as_hex());
103        let signer = PrivateKeySigner::from_str(key_hex)
104            .map_err(|e| Error::bad_request(format!("Failed to create signer: {e}")))?;
105        Ok(Self { signer })
106    }
107
108    /// Returns the signer's Ethereum address.
109    #[must_use]
110    pub fn address(&self) -> Address {
111        self.signer.address()
112    }
113
114    /// Signs a [`PolymarketOrder`] and returns the hex-encoded ECDSA signature.
115    ///
116    /// The `neg_risk` flag selects which exchange contract to use as the
117    /// EIP-712 `verifyingContract`.
118    ///
119    /// # Errors
120    ///
121    /// Returns an error if `order.signer` does not match this signer's address.
122    pub fn sign_order(&self, order: &PolymarketOrder, neg_risk: bool) -> Result<String> {
123        let order_signer = parse_address(&order.signer, "signer")?;
124        if order_signer != self.signer.address() {
125            return Err(Error::bad_request(format!(
126                "Order signer {order_signer} does not match local signer {}",
127                self.signer.address(),
128            )));
129        }
130
131        let eip712_order = build_eip712_order(order)?;
132
133        let contract = if neg_risk {
134            NEG_RISK_CTF_EXCHANGE
135        } else {
136            CTF_EXCHANGE
137        };
138
139        let domain = eip712_domain! {
140            name: DOMAIN_NAME,
141            version: DOMAIN_VERSION,
142            chain_id: POLYGON_CHAIN_ID,
143            verifying_contract: contract,
144        };
145
146        let signing_hash = eip712_order.eip712_signing_hash(&domain);
147        self.sign_hash(&signing_hash.0)
148    }
149
150    fn sign_hash(&self, hash: &[u8; 32]) -> Result<String> {
151        let hash_b256 = B256::from(*hash);
152        let signature = self
153            .signer
154            .sign_hash_sync(&hash_b256)
155            .map_err(|e| Error::bad_request(format!("Failed to sign order: {e}")))?;
156
157        let r = signature.r();
158        let s = signature.s();
159        let v = if signature.v() { 28u8 } else { 27u8 };
160
161        Ok(format!("0x{r:064x}{s:064x}{v:02x}"))
162    }
163}
164
165/// Signs a ClobAuth EIP-712 message for L1 API authentication.
166///
167/// Used to create or derive API credentials via the CLOB `/auth/api-key`
168/// and `/auth/derive-api-key` endpoints.
169///
170/// Returns `(signer_address_hex, signature_hex)`.
171pub fn sign_clob_auth(
172    private_key: &EvmPrivateKey,
173    timestamp: &str,
174    nonce: u64,
175) -> Result<(String, String)> {
176    let key_hex = private_key
177        .as_hex()
178        .strip_prefix("0x")
179        .unwrap_or(private_key.as_hex());
180    let signer = PrivateKeySigner::from_str(key_hex)
181        .map_err(|e| Error::bad_request(format!("Failed to create signer: {e}")))?;
182
183    let address = signer.address();
184
185    let auth = ClobAuth {
186        address,
187        timestamp: timestamp.to_string(),
188        nonce: U256::from(nonce),
189        message: CLOB_AUTH_MESSAGE.to_string(),
190    };
191
192    let domain = eip712_domain! {
193        name: CLOB_AUTH_DOMAIN_NAME,
194        version: CLOB_AUTH_DOMAIN_VERSION,
195        chain_id: POLYGON_CHAIN_ID,
196    };
197
198    let signing_hash = auth.eip712_signing_hash(&domain);
199    let signature = signer
200        .sign_hash_sync(&signing_hash)
201        .map_err(|e| Error::bad_request(format!("Failed to sign ClobAuth: {e}")))?;
202
203    let r = signature.r();
204    let s = signature.s();
205    let v = if signature.v() { 28u8 } else { 27u8 };
206
207    Ok((
208        format!("{address:#x}"),
209        format!("0x{r:064x}{s:064x}{v:02x}"),
210    ))
211}
212
213// Converts a PolymarketOrder to the EIP-712 Order struct
214fn build_eip712_order(order: &PolymarketOrder) -> Result<Order> {
215    Ok(Order {
216        salt: U256::from(order.salt),
217        maker: parse_address(&order.maker, "maker")?,
218        signer: parse_address(&order.signer, "signer")?,
219        tokenId: U256::from_str(order.token_id.as_str())
220            .map_err(|e| Error::bad_request(format!("Invalid token ID: {e}")))?,
221        makerAmount: decimal_to_u256(order.maker_amount, "maker_amount")?,
222        takerAmount: decimal_to_u256(order.taker_amount, "taker_amount")?,
223        side: order_side_to_u8(order.side),
224        signatureType: order.signature_type as u8,
225        timestamp: U256::from_str(&order.timestamp)
226            .map_err(|e| Error::bad_request(format!("Invalid timestamp: {e}")))?,
227        metadata: parse_bytes32(&order.metadata, "metadata")?,
228        builder: parse_bytes32(&order.builder, "builder")?,
229    })
230}
231
232fn parse_address(addr: &str, field: &str) -> Result<Address> {
233    Address::from_str(addr).map_err(|e| Error::bad_request(format!("Invalid {field} address: {e}")))
234}
235
236fn parse_bytes32(value: &str, field: &str) -> Result<FixedBytes<32>> {
237    FixedBytes::<32>::from_str(value)
238        .map_err(|e| Error::bad_request(format!("Invalid {field} bytes32: {e}")))
239}
240
241fn decimal_to_u256(d: Decimal, field: &str) -> Result<U256> {
242    let normalized = d.normalize();
243    if normalized.scale() != 0 {
244        return Err(Error::bad_request(format!("{field} must be an integer")));
245    }
246    let mantissa = normalized.mantissa();
247    if mantissa < 0 {
248        return Err(Error::bad_request(format!("{field} must be non-negative")));
249    }
250    Ok(U256::from(mantissa as u128))
251}
252
253fn order_side_to_u8(side: PolymarketOrderSide) -> u8 {
254    match side {
255        PolymarketOrderSide::Buy => 0,
256        PolymarketOrderSide::Sell => 1,
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    use alloy_primitives::{Signature, keccak256};
263    use nautilus_core::hex;
264    use rstest::rstest;
265    use rust_decimal_macros::dec;
266    use ustr::Ustr;
267
268    use super::*;
269    use crate::common::enums::SignatureType;
270
271    const TEST_PRIVATE_KEY: &str =
272        "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
273
274    fn test_signer() -> OrderSigner {
275        let pk = EvmPrivateKey::new(TEST_PRIVATE_KEY).unwrap();
276        OrderSigner::new(&pk).unwrap()
277    }
278
279    const ZERO_BYTES32: &str = "0x0000000000000000000000000000000000000000000000000000000000000000";
280
281    fn test_order() -> PolymarketOrder {
282        PolymarketOrder {
283            salt: 123456789,
284            maker: "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266".to_string(),
285            signer: "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266".to_string(),
286            token_id: Ustr::from(
287                "71321045679252212594626385532706912750332728571942532289631379312455583992563",
288            ),
289            maker_amount: dec!(100000000),
290            taker_amount: dec!(50000000),
291            side: PolymarketOrderSide::Buy,
292            signature_type: SignatureType::Eoa,
293            expiration: "0".to_string(),
294            timestamp: "1713398400000".to_string(),
295            metadata: ZERO_BYTES32.to_string(),
296            builder: ZERO_BYTES32.to_string(),
297            signature: String::new(),
298        }
299    }
300
301    #[rstest]
302    fn test_order_typehash_matches_contract() {
303        // ORDER_TYPEHASH from the CLOB V2 CTFExchange contract
304        let expected = keccak256(
305            "Order(uint256 salt,address maker,address signer,uint256 tokenId,uint256 makerAmount,uint256 takerAmount,uint8 side,uint8 signatureType,uint256 timestamp,bytes32 metadata,bytes32 builder)",
306        );
307        let order = test_order();
308        let eip712_order = build_eip712_order(&order).unwrap();
309        assert_eq!(eip712_order.eip712_type_hash(), expected);
310    }
311
312    #[rstest]
313    fn test_signer_address_derivation() {
314        let signer = test_signer();
315        // Hardhat account #0
316        let expected = Address::from_str("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266").unwrap();
317        assert_eq!(signer.address(), expected);
318    }
319
320    #[rstest]
321    fn test_sign_order_format() {
322        let signer = test_signer();
323        let order = test_order();
324
325        let sig = signer.sign_order(&order, false).unwrap();
326
327        assert!(sig.starts_with("0x"));
328        assert_eq!(sig.len(), 132); // 0x + r(64) + s(64) + v(2)
329    }
330
331    #[rstest]
332    fn test_sign_order_deterministic() {
333        let signer = test_signer();
334        let order = test_order();
335
336        let sig1 = signer.sign_order(&order, false).unwrap();
337        let sig2 = signer.sign_order(&order, false).unwrap();
338        assert_eq!(sig1, sig2);
339    }
340
341    #[rstest]
342    fn test_sign_order_neg_risk_differs() {
343        let signer = test_signer();
344        let order = test_order();
345
346        let sig_normal = signer.sign_order(&order, false).unwrap();
347        let sig_neg_risk = signer.sign_order(&order, true).unwrap();
348        assert_ne!(sig_normal, sig_neg_risk);
349    }
350
351    #[rstest]
352    fn test_sign_order_sell_side() {
353        let signer = test_signer();
354        let mut order = test_order();
355        let sig_buy = signer.sign_order(&order, false).unwrap();
356
357        order.side = PolymarketOrderSide::Sell;
358        let sig_sell = signer.sign_order(&order, false).unwrap();
359        assert_ne!(sig_buy, sig_sell);
360    }
361
362    #[rstest]
363    fn test_sign_order_different_amounts() {
364        let signer = test_signer();
365        let mut order = test_order();
366        let sig1 = signer.sign_order(&order, false).unwrap();
367
368        order.maker_amount = dec!(200000000);
369        let sig2 = signer.sign_order(&order, false).unwrap();
370        assert_ne!(sig1, sig2);
371    }
372
373    #[rstest]
374    fn test_build_eip712_order() {
375        let order = test_order();
376        let eip712 = build_eip712_order(&order).unwrap();
377
378        assert_eq!(eip712.salt, U256::from(123456789u64));
379        assert_eq!(
380            eip712.maker,
381            Address::from_str("0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266").unwrap()
382        );
383        assert_eq!(eip712.makerAmount, U256::from(100000000u128));
384        assert_eq!(eip712.takerAmount, U256::from(50000000u128));
385        assert_eq!(eip712.side, 0); // BUY
386        assert_eq!(eip712.signatureType, 0); // EOA
387        assert_eq!(eip712.timestamp, U256::from(1713398400000u128));
388        assert_eq!(eip712.metadata, FixedBytes::<32>::ZERO);
389        assert_eq!(eip712.builder, FixedBytes::<32>::ZERO);
390    }
391
392    #[rstest]
393    fn test_build_eip712_order_with_builder_code() {
394        let mut order = test_order();
395        order.builder =
396            "0x0000000000000000000000000000000000000000000000000000000000000001".to_string();
397        let eip712 = build_eip712_order(&order).unwrap();
398
399        let mut expected = [0u8; 32];
400        expected[31] = 1;
401        assert_eq!(eip712.builder, FixedBytes::<32>::from(expected));
402    }
403
404    #[rstest]
405    fn test_decimal_to_u256_integer() {
406        let result = decimal_to_u256(dec!(100000000), "test").unwrap();
407        assert_eq!(result, U256::from(100000000u128));
408    }
409
410    #[rstest]
411    fn test_decimal_to_u256_zero() {
412        let result = decimal_to_u256(dec!(0), "test").unwrap();
413        assert_eq!(result, U256::ZERO);
414    }
415
416    #[rstest]
417    fn test_decimal_to_u256_rejects_fractional() {
418        let result = decimal_to_u256(dec!(100.5), "test");
419        assert!(result.is_err());
420    }
421
422    #[rstest]
423    fn test_decimal_to_u256_rejects_negative() {
424        let result = decimal_to_u256(dec!(-1), "test");
425        assert!(result.is_err());
426    }
427
428    #[rstest]
429    fn test_order_side_mapping() {
430        assert_eq!(order_side_to_u8(PolymarketOrderSide::Buy), 0);
431        assert_eq!(order_side_to_u8(PolymarketOrderSide::Sell), 1);
432    }
433
434    #[rstest]
435    fn test_contract_addresses_nonzero() {
436        assert_ne!(CTF_EXCHANGE, Address::ZERO);
437        assert_ne!(NEG_RISK_CTF_EXCHANGE, Address::ZERO);
438        assert_ne!(CTF_EXCHANGE, NEG_RISK_CTF_EXCHANGE);
439    }
440
441    #[rstest]
442    fn test_v2_contract_addresses_pinned() {
443        // Pin the V2 contract addresses so a revert to V1 is caught by unit tests.
444        // V1 addresses (must NOT match): 0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E,
445        // 0xC5d563A36AE78145C45a50134d48A1215220f80a.
446        assert_eq!(
447            format!("{CTF_EXCHANGE:#x}"),
448            "0xe111180000d2663c0091e4f400237545b87b996b"
449        );
450        assert_eq!(
451            format!("{NEG_RISK_CTF_EXCHANGE:#x}"),
452            "0xe2222d279d744050d28e00520010520000310f59"
453        );
454    }
455
456    #[rstest]
457    fn test_domain_version_is_v2() {
458        // Domain version is embedded in the EIP-712 signing hash; a revert to
459        // "1" would silently break V2 order acceptance.
460        assert_eq!(DOMAIN_VERSION, "2");
461    }
462
463    #[rstest]
464    fn test_sign_order_recoverable() {
465        let signer = test_signer();
466        let order = test_order();
467        let sig_hex = signer.sign_order(&order, false).unwrap();
468
469        let sig_bytes = hex::decode(&sig_hex[2..]).unwrap();
470        assert_eq!(sig_bytes.len(), 65);
471
472        let r = U256::from_be_slice(&sig_bytes[..32]);
473        let s = U256::from_be_slice(&sig_bytes[32..64]);
474        let v = sig_bytes[64];
475        let y_parity = v == 28;
476
477        let signature = Signature::new(r, s, y_parity);
478
479        let eip712_order = build_eip712_order(&order).unwrap();
480        let domain = eip712_domain! {
481            name: DOMAIN_NAME,
482            version: DOMAIN_VERSION,
483            chain_id: POLYGON_CHAIN_ID,
484            verifying_contract: CTF_EXCHANGE,
485        };
486        let signing_hash = eip712_order.eip712_signing_hash(&domain);
487
488        let recovered = signature
489            .recover_address_from_prehash(&signing_hash)
490            .unwrap();
491        assert_eq!(recovered, signer.address());
492    }
493
494    // Reference vectors generated with `py_clob_client_v2==1.0.0`'s
495    // `ExchangeOrderBuilderV2`. Same test private key (Hardhat account #0),
496    // same contract addresses and chain id. Locks our EIP-712 hash + ECDSA
497    // signature output to the SDK's, so drift between the two signers (domain
498    // typo, struct field reorder, bytes32 padding, etc.) is caught locally
499    // before orders get sent to the venue.
500    const PARITY_TOKEN_ID: &str =
501        "71321045679252212594626385532706912750332728571942532289631379312455583992563";
502
503    fn parity_order(
504        salt: u64,
505        side: PolymarketOrderSide,
506        signature_type: SignatureType,
507        maker_amount: Decimal,
508        taker_amount: Decimal,
509        timestamp: &str,
510        builder: &str,
511    ) -> PolymarketOrder {
512        PolymarketOrder {
513            salt,
514            maker: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266".to_string(),
515            signer: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266".to_string(),
516            token_id: Ustr::from(PARITY_TOKEN_ID),
517            maker_amount,
518            taker_amount,
519            side,
520            signature_type,
521            expiration: "0".to_string(),
522            timestamp: timestamp.to_string(),
523            metadata: ZERO_BYTES32.to_string(),
524            builder: builder.to_string(),
525            signature: String::new(),
526        }
527    }
528
529    fn expected_signing_hash(order: &PolymarketOrder, neg_risk: bool) -> B256 {
530        let eip712_order = build_eip712_order(order).unwrap();
531        let contract = if neg_risk {
532            NEG_RISK_CTF_EXCHANGE
533        } else {
534            CTF_EXCHANGE
535        };
536        let domain = eip712_domain! {
537            name: DOMAIN_NAME,
538            version: DOMAIN_VERSION,
539            chain_id: POLYGON_CHAIN_ID,
540            verifying_contract: contract,
541        };
542        eip712_order.eip712_signing_hash(&domain)
543    }
544
545    #[rstest]
546    #[case::buy_standard_eoa(
547        parity_order(
548            123456789,
549            PolymarketOrderSide::Buy,
550            SignatureType::Eoa,
551            dec!(100000000),
552            dec!(50000000),
553            "1713398400000",
554            ZERO_BYTES32,
555        ),
556        false,
557        "0x32961c48ddac87ed3582f8e02097cd0eff4fcf80460306bd44b3710438dfa64c",
558        "0x89f178136333c8ebb32a19146cb891233e3202d474be6ef730c24dbc06ae4d2a0c99948a86d9b57de0f2c0bb8ec6964aa244ecf17cfa3a95b86878d0b64ad78a1b",
559    )]
560    #[case::sell_neg_risk_eoa(
561        parity_order(
562            987654321,
563            PolymarketOrderSide::Sell,
564            SignatureType::Eoa,
565            dec!(50000000),
566            dec!(100000000),
567            "1713398400000",
568            ZERO_BYTES32,
569        ),
570        true,
571        "0x8b878404bd92dea2bfea9975c9fcd816ec70a57ae431cb20d67bb773744aaef3",
572        "0xf7d60d64364e2615b08d9f69f3ea9afd3b4f83ecfbf05ddd3ca83f4916277fb97d2d080758d17c8e91c928aceb8ff252477ebaec051b672832500f63b4d36b061c",
573    )]
574    #[case::buy_with_builder_code_eoa(
575        parity_order(
576            1,
577            PolymarketOrderSide::Buy,
578            SignatureType::Eoa,
579            dec!(100000000),
580            dec!(50000000),
581            "1713398500000",
582            "0x0000000000000000000000000000000000000000000000000000000000000001",
583        ),
584        false,
585        "0x3df0b6f6ddfca837bc36964cae968b34ad35640b5d98f557c104da97e804e36a",
586        "0xf4d2b34659e8bc07a9572d40ee5a1639a1157409613b4c21566b1f33fd8fe11a364b3f306668cae7248ca7cdf72378f9266bc5628585aa939644400030671e081c",
587    )]
588    #[case::buy_poly_proxy(
589        // V2 unblocks EIP-1271 smart contract wallet signing. signatureType
590        // enters the typed-data hash directly, so a regression that only
591        // manifests for proxy/safe wallets is undetectable from the Eoa
592        // cases above.
593        parity_order(
594            111_111_111,
595            PolymarketOrderSide::Buy,
596            SignatureType::PolyProxy,
597            dec!(100000000),
598            dec!(50000000),
599            "1713398400000",
600            ZERO_BYTES32,
601        ),
602        false,
603        "0x8f88fe2fb3448f4b8ba639992029f0a47a01a14d15b5f2bf9833516571efd279",
604        "0x71a63c85b730cc934688f23ea6374afffef57a61690eba63dcb97a706c8a8d0f3d2a8e0280f3e252eb83be088ebae6b461a8eda18e559e079f59050b90057afa1c",
605    )]
606    #[case::sell_neg_risk_poly_gnosis_safe(
607        parity_order(
608            222_222_222,
609            PolymarketOrderSide::Sell,
610            SignatureType::PolyGnosisSafe,
611            dec!(50000000),
612            dec!(100000000),
613            "1713398400000",
614            ZERO_BYTES32,
615        ),
616        true,
617        "0xb34248702810a1d76580234a33f942a9801c3680de54cb3ef104572a8d482190",
618        "0xab9d33aee8b578fe5588c4a4b16bbef6fa05fc757020f95b98212e877a919e360a90296f02e97ecbabf3c200271ffe88eb9fd86912e8376cd27237bdad5f3abc1c",
619    )]
620    fn test_signature_matches_py_clob_client_v2(
621        #[case] order: PolymarketOrder,
622        #[case] neg_risk: bool,
623        #[case] expected_hash_hex: &str,
624        #[case] expected_signature_hex: &str,
625    ) {
626        let signer = test_signer();
627
628        let hash = expected_signing_hash(&order, neg_risk);
629        assert_eq!(format!("{hash:#x}"), expected_hash_hex, "signing hash");
630
631        let signature = signer.sign_order(&order, neg_risk).unwrap();
632        assert_eq!(signature, expected_signature_hex, "signature");
633    }
634}