Skip to main content

limitless/
signing.rs

1//! EIP-712 order signing for the Limitless Exchange CLOB.
2//!
3//! Implements the typed structured data hashing per [EIP-712](https://eips.ethereum.org/EIPS/eip-712)
4//! and secp256k1 signing for EOA wallets.
5//!
6//! # Usage
7//!
8//! ```no_run
9//! use limitless::prelude::*;
10//! use limitless::signing::*;
11//!
12//! let signer = Eip712Signer::new(
13//!     "0xYourPrivateKey...",
14//!     "0xVenueExchangeAddress...",  // from GET /markets/:slug
15//! );
16//!
17//! let order = signer.build_gtc_order(
18//!     "0xYourWallet...",
19//!     "1234567890",  // token_id as decimal string
20//!     OrderSide::Buy,
21//!     0.55,    // price
22//!     10.0,    // size
23//!     0,       // fee_rate_bps
24//! );
25//! ```
26
27use k256::ecdsa::SigningKey;
28use sha3::{Digest, Keccak256};
29use std::sync::atomic::{AtomicI64, Ordering};
30use std::time::{Duration, SystemTime, UNIX_EPOCH};
31
32// ── EIP-712 domain constants ──
33
34/// Chain ID for Base mainnet (where Limitless contracts are deployed).
35pub const CHAIN_ID: u64 = 8453;
36
37/// The EIP-712 domain name.
38pub const DOMAIN_NAME: &str = "Limitless CTF Exchange";
39
40/// The EIP-712 domain version.
41pub const DOMAIN_VERSION: &str = "1";
42
43/// EIP-712 type name for the Order struct.
44pub const ORDER_TYPE_NAME: &str = "Order";
45
46/// The EIP-712 type definition string for Order.
47pub const ORDER_TYPE: &str =
48    "Order(uint256 salt,address maker,address signer,address taker,uint256 tokenId,uint256 makerAmount,uint256 takerAmount,uint256 expiration,uint256 nonce,uint256 feeRateBps,uint8 side,uint8 signatureType)";
49
50// ── Global monotonic salt counter ──
51
52/// Ensures every order produced by this process gets a unique, monotonically
53/// increasing salt. The counter is seeded from the current microsecond
54/// timestamp and only ever moves forward.
55static LAST_ORDER_SALT: AtomicI64 = AtomicI64::new(0);
56
57// ── Helper: keccak256 ──
58
59/// Compute the keccak256 hash of arbitrary bytes.
60fn keccak256(data: &[u8]) -> [u8; 32] {
61    let mut hasher = Keccak256::new();
62    hasher.update(data);
63    let result = hasher.finalize();
64    let mut hash = [0u8; 32];
65    hash.copy_from_slice(&result);
66    hash
67}
68
69// ── ABI encoding helpers ──
70
71/// Encode a `uint256` from a `u64` (right-aligned in 32 bytes, big-endian).
72fn encode_u256_from_u64(value: u64) -> [u8; 32] {
73    let mut buf = [0u8; 32];
74    buf[24..].copy_from_slice(&value.to_be_bytes());
75    buf
76}
77
78/// Encode a `uint256` from an `i64` (right-aligned, big-endian).
79/// Returns an error for negative values (matching the reference).
80fn encode_u256_from_i64(value: i64) -> Result<[u8; 32], String> {
81    if value < 0 {
82        return Err(format!("expected non-negative integer, got {value}"));
83    }
84    Ok(encode_u256_from_u64(value as u64))
85}
86
87/// Encode a `uint256` from an `i32` (right-aligned, big-endian).
88fn encode_u256_from_i32(value: i32) -> Result<[u8; 32], String> {
89    if value < 0 {
90        return Err(format!("expected non-negative integer, got {value}"));
91    }
92    Ok(encode_u256_from_u64(value as u64))
93}
94
95/// Encode a decimal string as a uint256 (big-endian, right-aligned 32 bytes).
96///
97/// Performs manual decimal → binary conversion without needing `num_bigint`.
98fn encode_decimal_string_as_u256(value: &str) -> Result<[u8; 32], String> {
99    if value.is_empty() || !value.chars().all(|c| c.is_ascii_digit()) {
100        return Err(format!("invalid uint value: {value}"));
101    }
102    // Strip leading zeros
103    let trimmed = value.trim_start_matches('0');
104    let digits = if trimmed.is_empty() { "0" } else { trimmed };
105
106    // Simple decimal → binary conversion
107    let mut bytes: Vec<u8> = Vec::new();
108    let mut current = digits.to_string();
109
110    while !current.is_empty() && current != "0" {
111        let mut next = String::new();
112        let mut carry = 0u32;
113        for c in current.chars() {
114            let val = carry * 10 + (c as u32 - '0' as u32);
115            let q = val / 256;
116            carry = val % 256;
117            if !next.is_empty() || q > 0 {
118                next.push(char::from_digit(q, 10).unwrap_or('0'));
119            }
120        }
121        bytes.push(carry as u8);
122        if next.is_empty() {
123            next = String::from("0");
124        }
125        current = next;
126    }
127
128    bytes.reverse();
129    if bytes.len() > 32 {
130        return Err(format!("value {} exceeds uint256 size", value));
131    }
132
133    let mut out = [0u8; 32];
134    let start = 32 - bytes.len();
135    out[start..].copy_from_slice(&bytes);
136    Ok(out)
137}
138
139
140/// Encode an Ethereum address as 32 bytes (left-padded, 20-byte address right-aligned).
141fn encode_address(addr: &[u8; 20]) -> [u8; 32] {
142    let mut buf = [0u8; 32];
143    buf[12..].copy_from_slice(addr);
144    buf
145}
146
147/// Encode a `string` field for EIP-712: keccak256 of the UTF-8 bytes.
148fn encode_string(s: &str) -> [u8; 32] {
149    keccak256(s.as_bytes())
150}
151
152// ── Salt generation ──
153
154/// Generate a unique, monotonically-increasing salt for an order.
155///
156/// Uses an atomic compare-and-swap loop seeded from the current microsecond
157/// timestamp. Two successive calls are guaranteed to return `next > prev`.
158pub fn generate_salt() -> i64 {
159    let now = SystemTime::now()
160        .duration_since(UNIX_EPOCH)
161        .unwrap_or_else(|_| Duration::from_millis(0));
162    let candidate = i64::try_from(now.as_micros()).unwrap_or(i64::MAX - 1);
163
164    loop {
165        let previous = LAST_ORDER_SALT.load(Ordering::Relaxed);
166        let next = candidate.max(previous.saturating_add(1));
167        match LAST_ORDER_SALT.compare_exchange(previous, next, Ordering::SeqCst, Ordering::SeqCst) {
168            Ok(_) => return next,
169            Err(_) => continue,
170        }
171    }
172}
173
174// ── EIP-712 hashing ──
175
176/// Compute the EIP-712 domain separator.
177///
178/// `verifying_contract` is the raw 20-byte address of `venue.exchange`.
179pub fn domain_separator(verifying_contract: &[u8; 20]) -> [u8; 32] {
180    // Domain type definition
181    let domain_type =
182        "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)";
183    let type_hash = keccak256(domain_type.as_bytes());
184
185    let name_hash = encode_string(DOMAIN_NAME);
186    let version_hash = encode_string(DOMAIN_VERSION);
187
188    // Full uint256 encoding of chain ID (matches reference `encode_u256_from_u64`)
189    let chain_id_bytes = encode_u256_from_u64(CHAIN_ID);
190
191    let contract = encode_address(verifying_contract);
192
193    // hashStruct = keccak256(typeHash || encodeData)
194    let mut data = Vec::with_capacity(32 * 5);
195    data.extend_from_slice(&type_hash);
196    data.extend_from_slice(&name_hash);
197    data.extend_from_slice(&version_hash);
198    data.extend_from_slice(&chain_id_bytes);
199    data.extend_from_slice(&contract);
200
201    keccak256(&data)
202}
203
204/// Compute the EIP-712 order type hash.
205pub fn order_type_hash() -> [u8; 32] {
206    keccak256(ORDER_TYPE.as_bytes())
207}
208
209/// Compute the EIP-712 `hashStruct` for an order.
210///
211/// This is: `keccak256(typeHash || encodeData(order))`
212pub fn hash_order(
213    salt: i64,
214    maker: &[u8; 20],
215    signer: &[u8; 20],
216    taker: &[u8; 20],
217    token_id: &str,
218    maker_amount: i64,
219    taker_amount: i64,
220    expiration: &str,
221    nonce: i32,
222    fee_rate_bps: i32,
223    side: u8,
224    signature_type: u8,
225) -> Result<[u8; 32], String> {
226    let type_hash = order_type_hash();
227
228    let mut data = Vec::with_capacity(32 * 13);
229    data.extend_from_slice(&type_hash);
230    data.extend_from_slice(&encode_u256_from_i64(salt)?);
231    data.extend_from_slice(&encode_address(maker));
232    data.extend_from_slice(&encode_address(signer));
233    data.extend_from_slice(&encode_address(taker));
234    data.extend_from_slice(&encode_decimal_string_as_u256(token_id)?);
235    data.extend_from_slice(&encode_u256_from_i64(maker_amount)?);
236    data.extend_from_slice(&encode_u256_from_i64(taker_amount)?);
237    data.extend_from_slice(&encode_decimal_string_as_u256(expiration)?);
238    data.extend_from_slice(&encode_u256_from_i32(nonce)?);
239    data.extend_from_slice(&encode_u256_from_i32(fee_rate_bps)?);
240    data.extend_from_slice(&encode_u256_from_u64(side as u64));
241    data.extend_from_slice(&encode_u256_from_u64(signature_type as u64));
242
243    Ok(keccak256(&data))
244}
245
246/// Compute the final EIP-712 message hash to be signed.
247///
248/// `hash = keccak256("\x19\x01" || domainSeparator || orderHash)`
249pub fn eip712_message_hash(domain_separator: &[u8; 32], order_hash: &[u8; 32]) -> [u8; 32] {
250    let mut data = Vec::with_capacity(2 + 32 + 32);
251    data.extend_from_slice(b"\x19\x01");
252    data.extend_from_slice(domain_separator);
253    data.extend_from_slice(order_hash);
254    keccak256(&data)
255}
256
257// ── Address parsing helpers ──
258
259/// Parse a hex address string (with or without `0x` prefix) into a 20-byte array.
260pub fn parse_address(addr: &str) -> Result<[u8; 20], String> {
261    let hex_str = addr.strip_prefix("0x").unwrap_or(addr);
262    let bytes = hex::decode(hex_str).map_err(|e| format!("Invalid hex address: {}", e))?;
263    if bytes.len() != 20 {
264        return Err(format!(
265            "Address must be 20 bytes, got {} bytes",
266            bytes.len()
267        ));
268    }
269    let mut arr = [0u8; 20];
270    arr.copy_from_slice(&bytes);
271    Ok(arr)
272}
273
274/// Parse a hex private key string (with or without `0x` prefix) into a 32-byte array.
275pub fn parse_private_key(key: &str) -> Result<[u8; 32], String> {
276    let hex_str = key.strip_prefix("0x").unwrap_or(key);
277    let bytes = hex::decode(hex_str).map_err(|e| format!("Invalid hex key: {}", e))?;
278    if bytes.len() != 32 {
279        return Err(format!(
280            "Private key must be 32 bytes, got {} bytes",
281            bytes.len()
282        ));
283    }
284    let mut arr = [0u8; 32];
285    arr.copy_from_slice(&bytes);
286    Ok(arr)
287}
288
289/// Validate that an address string looks like a valid Ethereum address.
290pub fn is_valid_address(addr: &str) -> bool {
291    addr.len() == 42 && addr.starts_with("0x") && addr[2..].chars().all(|ch| ch.is_ascii_hexdigit())
292}
293
294// ── EIP-712 Signer ──
295
296/// An EIP-712 order signer for the Limitless Exchange.
297///
298/// Holds a secp256k1 signing key and the venue exchange address
299/// (used as `verifyingContract` in the EIP-712 domain).
300pub struct Eip712Signer {
301    signing_key: SigningKey,
302    /// The derived wallet address (0x-prefixed, checksummed).
303    address: String,
304    domain_separator: [u8; 32],
305}
306
307impl Eip712Signer {
308    /// Create a new signer from a private key and the venue exchange address.
309    ///
310    /// # Arguments
311    ///
312    /// * `private_key` — 0x-prefixed hex private key (32 bytes).
313    /// * `verifying_contract` — 0x-prefixed hex address of `venue.exchange`.
314    pub fn new(private_key: &str, verifying_contract: &str) -> Result<Self, String> {
315        let key_bytes = parse_private_key(private_key)?;
316        let signing_key = SigningKey::from_slice(&key_bytes)
317            .map_err(|e| format!("Invalid private key: {}", e))?;
318        let verifying_contract_bytes = parse_address(verifying_contract)?;
319        let domain_separator = domain_separator(&verifying_contract_bytes);
320
321        // Derive wallet address from the signing key
322        let verifying_key = signing_key.verifying_key();
323        let encoded = verifying_key.to_encoded_point(false);
324        let public_key_bytes = encoded.as_bytes();
325        let hash = keccak256(&public_key_bytes[1..]);
326        let address = checksum_address(&hash[12..]);
327
328        Ok(Self {
329            signing_key,
330            address,
331            domain_separator,
332        })
333    }
334
335    /// Get the wallet address derived from the private key.
336    pub fn wallet_address(&self) -> &str {
337        &self.address
338    }
339
340    /// Sign an EIP-712 order hash and return the 0x-prefixed hex signature.
341    ///
342    /// Returns a 65-byte ECDSA signature in `r || s || v` format.
343    pub fn sign_hash(&self, order_hash: &[u8; 32]) -> Result<String, String> {
344        let message_hash = eip712_message_hash(&self.domain_separator, order_hash);
345
346        // Use recoverable signing to get the recovery ID
347        let (sig, recovery_id) = self
348            .signing_key
349            .sign_prehash_recoverable(&message_hash)
350            .map_err(|e| format!("Signing failed: {}", e))?;
351
352        // Normalize to low-s form
353        let sig = sig.normalize_s().unwrap_or(sig);
354
355        // r || s || v format (65 bytes)
356        let mut sig_bytes = Vec::with_capacity(65);
357        sig_bytes.extend_from_slice(&sig.to_bytes());
358        sig_bytes.push(recovery_id.to_byte() + 27); // Ethereum-style v
359
360        Ok(format!("0x{}", hex::encode(&sig_bytes)))
361    }
362
363    /// Build and sign a GTC limit order.
364    ///
365    /// Validates all fields, generates a unique monotonic salt, and signs
366    /// the EIP-712 typed data.
367    ///
368    /// # Arguments
369    /// * `maker_address` — 0x-prefixed wallet address (must match the signer's key)
370    /// * `token_id` — Decimal string representation of the token ID
371    /// * `side` — BUY or SELL
372    /// * `price` — Price between 0 and 1
373    /// * `size` — Number of contracts
374    /// * `fee_rate_bps` — Fee rate in basis points (0–10000)
375    pub fn build_gtc_order(
376        &self,
377        maker_address: &str,
378        token_id: &str,
379        side: crate::models::order::OrderSide,
380        price: f64,
381        size: f64,
382        fee_rate_bps: i32,
383    ) -> Result<crate::models::order::OrderData, String> {
384        use crate::models::order::{gtc_amounts, validate_gtc_order};
385
386        // Verify the signer's wallet matches the requested maker address
387        if !self.address.eq_ignore_ascii_case(maker_address) {
388            return Err(format!(
389                "wallet address mismatch: signing with '{}' but maker is '{}'",
390                self.address, maker_address
391            ));
392        }
393
394        // Client-side validation
395        validate_gtc_order(price, size, None)?;
396
397        let maker = parse_address(maker_address)?;
398        let taker = [0u8; 20]; // 0x000...000 for open orders
399        let (maker_amount, taker_amount) = gtc_amounts(side, price, size);
400
401        let salt = generate_salt();
402        let expiration = "0".to_string(); // no expiration
403
404        let order_hash = hash_order(
405            salt,
406            &maker,
407            &maker, // signer = maker for EOA
408            &taker,
409            token_id,
410            maker_amount,
411            taker_amount,
412            &expiration,
413            0, // nonce
414            fee_rate_bps,
415            side.to_u8(),
416            0, // signature_type: 0 = EOA
417        )?;
418
419        let signature = self.sign_hash(&order_hash)?;
420
421        Ok(crate::models::order::OrderData {
422            salt,
423            maker: format!("0x{}", hex::encode(maker)),
424            signer: format!("0x{}", hex::encode(maker)),
425            taker: format!("0x{}", hex::encode(taker)),
426            token_id: token_id.to_string(),
427            maker_amount,
428            taker_amount,
429            expiration,
430            nonce: 0,
431            fee_rate_bps,
432            side: side.to_u8(),
433            signature,
434            signature_type: 0,
435        })
436    }
437
438    /// Build and sign a FOK market order.
439    pub fn build_fok_order(
440        &self,
441        maker_address: &str,
442        token_id: &str,
443        side: crate::models::order::OrderSide,
444        amount: f64,
445        fee_rate_bps: i32,
446    ) -> Result<crate::models::order::OrderData, String> {
447        use crate::models::order::{fok_amount, validate_fok_order};
448
449        // Verify the signer's wallet matches the requested maker address
450        if !self.address.eq_ignore_ascii_case(maker_address) {
451            return Err(format!(
452                "wallet address mismatch: signing with '{}' but maker is '{}'",
453                self.address, maker_address
454            ));
455        }
456
457        // Client-side validation
458        validate_fok_order(amount)?;
459
460        let maker = parse_address(maker_address)?;
461        let taker = [0u8; 20];
462        let maker_amount = fok_amount(side, amount);
463
464        let salt = generate_salt();
465        let expiration = "0".to_string();
466
467        let order_hash = hash_order(
468            salt,
469            &maker,
470            &maker,
471            &taker,
472            token_id,
473            maker_amount,
474            1, // FOK: taker_amount always 1
475            &expiration,
476            0, // nonce
477            fee_rate_bps,
478            side.to_u8(),
479            0,
480        )?;
481
482        let signature = self.sign_hash(&order_hash)?;
483
484        Ok(crate::models::order::OrderData {
485            salt,
486            maker: format!("0x{}", hex::encode(maker)),
487            signer: format!("0x{}", hex::encode(maker)),
488            taker: format!("0x{}", hex::encode(taker)),
489            token_id: token_id.to_string(),
490            maker_amount,
491            taker_amount: 1,
492            expiration,
493            nonce: 0,
494            fee_rate_bps,
495            side: side.to_u8(),
496            signature,
497            signature_type: 0,
498        })
499    }
500}
501
502// ── Address checksumming (EIP-55) ──
503
504/// Compute the EIP-55 mixed-case checksum address.
505pub fn checksum_address(addr: &[u8]) -> String {
506    let hex_addr = hex::encode(addr);
507    let hash = keccak256(hex_addr.as_bytes());
508
509    let mut result = String::with_capacity(42);
510    result.push_str("0x");
511    for (i, ch) in hex_addr.chars().enumerate() {
512        let hash_byte = hash[i / 2];
513        let high_nibble = (hash_byte >> 4) & 0x0f;
514        let low_nibble = if i % 2 == 0 {
515            high_nibble
516        } else {
517            hash_byte & 0x0f
518        };
519        if low_nibble >= 8 {
520            result.push(ch.to_ascii_uppercase());
521        } else {
522            result.push(ch);
523        }
524    }
525    result
526}
527
528// ── Tests ──
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533
534    #[test]
535    fn test_parse_address() {
536        let addr = parse_address("0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed").unwrap();
537        assert_eq!(
538            hex::encode(addr),
539            "5aaeb6053f3e94c9b9a09f33669435e7ef1beaed"
540        );
541    }
542
543    #[test]
544    fn test_parse_address_no_prefix() {
545        let addr = parse_address("5aaeb6053f3e94c9b9a09f33669435e7ef1beaed").unwrap();
546        assert_eq!(
547            hex::encode(addr),
548            "5aaeb6053f3e94c9b9a09f33669435e7ef1beaed"
549        );
550    }
551
552    #[test]
553    fn test_parse_private_key() {
554        let key =
555            parse_private_key("0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80")
556                .unwrap();
557        assert_eq!(key.len(), 32);
558    }
559
560    #[test]
561    fn test_domain_separator_is_deterministic() {
562        let contract = [0x11u8; 20];
563        let ds1 = domain_separator(&contract);
564        let ds2 = domain_separator(&contract);
565        assert_eq!(ds1, ds2);
566        // Should not be all zeros
567        assert!(ds1.iter().any(|b| *b != 0));
568    }
569
570    #[test]
571    fn test_signer_derives_wallet_address() {
572        // Hardhat test account #0
573        let signer = Eip712Signer::new(
574            "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
575            "0x5FbDB2315678afecb367f032d93F642f64180aa3", // some random contract
576        )
577        .unwrap();
578
579        let address = signer.wallet_address();
580        // Hardhat account #0
581        assert_eq!(
582            address.to_lowercase(),
583            "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266"
584        );
585    }
586
587    #[test]
588    fn test_eip712_hash_is_deterministic() {
589        let maker = [0x11u8; 20];
590        let signer = [0x11u8; 20];
591        let taker = [0u8; 20];
592
593        let h1 = hash_order(
594            12345, &maker, &signer, &taker, "1000000", 5000000, 10000000, "0", 42, 0, 0, 0,
595        )
596        .unwrap();
597        let h2 = hash_order(
598            12345, &maker, &signer, &taker, "1000000", 5000000, 10000000, "0", 42, 0, 0, 0,
599        )
600        .unwrap();
601        assert_eq!(h1, h2);
602    }
603
604    #[test]
605    fn test_encode_decimal_string_as_u256() {
606        // "1000000" → [0u8;26], [0,15,66,64]
607        let result = encode_decimal_string_as_u256("1000000").unwrap();
608        assert_eq!(result[31], 64); // low byte of 1000000
609        assert_eq!(result[30], 66);
610        assert_eq!(result[29], 15);
611    }
612
613    #[test]
614    fn test_generate_salt_is_monotonic() {
615        let s1 = generate_salt();
616        let s2 = generate_salt();
617        assert!(s2 > s1, "s2={s2} must be greater than s1={s1}");
618    }
619
620    #[test]
621    fn test_chain_id_encoding_is_full_u256() {
622        let encoded = encode_u256_from_u64(CHAIN_ID);
623        // 8453 = 0x2105 → bytes 24..32 should be [0,0,0,0,0,0,0x21,0x05]
624        assert_eq!(encoded[30], 0x21);
625        assert_eq!(encoded[31], 0x05);
626        // High bytes should be zero
627        assert_eq!(encoded[24], 0);
628        assert_eq!(encoded[27], 0);
629    }
630}