Skip to main content

predict_sdk/
order_builder.rs

1use crate::{
2    constants::{self, Addresses, ZERO_ADDRESS},
3    errors::{Error, Result},
4    internal::{amounts, signing},
5    types::*,
6};
7use alloy::primitives::Address;
8use alloy::signers::local::PrivateKeySigner;
9use std::time::{SystemTime, UNIX_EPOCH};
10
11/// Options for creating an OrderBuilder
12#[derive(Debug, Clone)]
13pub struct OrderBuilderOptions {
14    /// Custom addresses (defaults to chain-specific addresses)
15    pub addresses: Option<Addresses>,
16
17    /// Function to generate salt for orders
18    pub generate_salt: Option<fn() -> String>,
19}
20
21impl Default for OrderBuilderOptions {
22    fn default() -> Self {
23        Self {
24            addresses: None,
25            generate_salt: Some(generate_order_salt),
26        }
27    }
28}
29
30/// Default function to generate a random salt for orders
31pub fn generate_order_salt() -> String {
32    use rand::Rng;
33    let mut rng = rand::rng();
34    let salt: u64 = rng.random_range(0..constants::MAX_SALT);
35    salt.to_string()
36}
37
38/// Main OrderBuilder struct for creating and signing orders
39///
40/// This is the primary interface for interact with predict.fun's CTF Exchange
41pub struct OrderBuilder {
42    chain_id: ChainId,
43    signer: Option<PrivateKeySigner>,
44    addresses: Addresses,
45    generate_salt: fn() -> String,
46    /// Predict Account address for smart wallet signing (Kernel-based)
47    /// When set, orders use this address as `maker` and signatures use Kernel wrapping
48    predict_account: Option<Address>,
49}
50
51impl OrderBuilder {
52    /// Create a new OrderBuilder
53    ///
54    /// # Arguments
55    ///
56    /// * `chain_id` - The chain ID (BNB Mainnet or Testnet)
57    /// * `signer` - Optional signer for signing orders
58    /// * `options` - Optional configuration options
59    ///
60    /// # Returns
61    ///
62    /// A new OrderBuilder instance
63    pub fn new(
64        chain_id: ChainId,
65        signer: Option<PrivateKeySigner>,
66        options: Option<OrderBuilderOptions>,
67    ) -> Result<Self> {
68        let opts = options.unwrap_or_default();
69        let addresses = opts.addresses.unwrap_or_else(|| Addresses::for_chain(chain_id));
70        let generate_salt = opts.generate_salt.unwrap_or(generate_order_salt);
71
72        Ok(Self {
73            chain_id,
74            signer,
75            addresses,
76            generate_salt,
77            predict_account: None,
78        })
79    }
80
81    /// Create a new OrderBuilder with Predict Account support
82    ///
83    /// Use this constructor when trading via a Predict Smart Wallet (Kernel-based).
84    /// The `predict_account` address will be used as the order `maker`, and
85    /// signatures will use Kernel EIP-712 wrapping.
86    ///
87    /// # Arguments
88    ///
89    /// * `chain_id` - The chain ID (BNB Mainnet or Testnet)
90    /// * `signer` - The Privy private key signer
91    /// * `predict_account` - The Predict Account (smart wallet) address
92    /// * `options` - Optional configuration options
93    ///
94    /// # Returns
95    ///
96    /// A new OrderBuilder instance configured for Predict Account signing
97    pub fn with_predict_account(
98        chain_id: ChainId,
99        signer: PrivateKeySigner,
100        predict_account: &str,
101        options: Option<OrderBuilderOptions>,
102    ) -> Result<Self> {
103        let opts = options.unwrap_or_default();
104        let addresses = opts.addresses.unwrap_or_else(|| Addresses::for_chain(chain_id));
105        let generate_salt = opts.generate_salt.unwrap_or(generate_order_salt);
106
107        let predict_account_addr = predict_account.parse::<Address>()
108            .map_err(|e| Error::Other(format!("Invalid predict account address: {}", e)))?;
109
110        Ok(Self {
111            chain_id,
112            signer: Some(signer),
113            addresses,
114            generate_salt,
115            predict_account: Some(predict_account_addr),
116        })
117    }
118
119    /// Check if this OrderBuilder uses Predict Account signing
120    pub fn uses_predict_account(&self) -> bool {
121        self.predict_account.is_some()
122    }
123
124    /// Get the Predict Account address if set
125    pub fn predict_account(&self) -> Option<Address> {
126        self.predict_account
127    }
128
129    /// Get the signer address
130    ///
131    /// # Returns
132    ///
133    /// The signer address, or an error if no signer is configured
134    pub fn signer_address(&self) -> Result<Address> {
135        self.signer
136            .as_ref()
137            .map(|s| s.address())
138            .ok_or_else(|| Error::Other("No signer configured".to_string()))
139    }
140
141    /// Get a clone of the signer (for on-chain operations)
142    ///
143    /// # Returns
144    ///
145    /// A clone of the signer, or None if no signer is configured
146    pub fn signer(&self) -> Option<PrivateKeySigner> {
147        self.signer.clone()
148    }
149
150    /// Helper function to calculate the amounts for a LIMIT strategy order
151    ///
152    /// # Arguments
153    ///
154    /// * `data` - The limit order data (side, price, quantity)
155    ///
156    /// # Returns
157    ///
158    /// Order amounts including maker/taker amounts and price per share
159    ///
160    /// # Errors
161    ///
162    /// Returns an error if the quantity is too small (< 1e16)
163    pub fn get_limit_order_amounts(&self, data: LimitOrderData) -> Result<LimitOrderAmounts> {
164        amounts::get_limit_order_amounts(data)
165    }
166
167    /// Build an order struct
168    ///
169    /// # Arguments
170    ///
171    /// * `strategy` - The order strategy (MARKET or LIMIT)
172    /// * `input` - The order input data
173    ///
174    /// # Returns
175    ///
176    /// A constructed Order ready for signing
177    ///
178    /// # Errors
179    ///
180    /// Returns an error if the input data is invalid or expiration is in the past
181    pub fn build_order(&self, strategy: OrderStrategy, input: BuildOrderInput) -> Result<Order> {
182        // Get signer address if available
183        let signer_address = self.signer_address()
184            .unwrap_or_else(|_| {
185                input.signer.as_ref()
186                    .and_then(|s| s.parse::<Address>().ok())
187                    .unwrap_or(ZERO_ADDRESS.parse().unwrap())
188            });
189
190        let signer_str = format!("{}", signer_address);
191
192        // When using a Predict Account, maker and signer must be the predict_account address.
193        // The Predict API verifies signatures via EIP-1271 (Kernel smart wallet).
194        // When using EOA directly, maker and signer are the EOA address.
195        let (maker_str, order_signer_str) = if let Some(predict_account) = self.predict_account {
196            let pa = format!("{}", predict_account);
197            (pa.clone(), pa)
198        } else {
199            (signer_str.clone(), signer_str.clone())
200        };
201
202        // Calculate expiration
203        let expiration = if let Some(expires_at) = input.expires_at {
204            let timestamp = expires_at.timestamp();
205            let now = SystemTime::now()
206                .duration_since(UNIX_EPOCH)
207                .unwrap()
208                .as_secs() as i64;
209
210            if timestamp <= now {
211                return Err(Error::InvalidOrderData("Expiration must be in the future".to_string()));
212            }
213            timestamp.to_string()
214        } else {
215            // Default expiration based on strategy
216            let now = SystemTime::now()
217                .duration_since(UNIX_EPOCH)
218                .unwrap()
219                .as_secs();
220
221            let expiration_secs = match strategy {
222                OrderStrategy::Market => now + constants::FIVE_MINUTES_SECONDS,
223                OrderStrategy::Limit => now + (365 * 24 * 60 * 60), // 1 year
224            };
225
226            expiration_secs.to_string()
227        };
228
229        Ok(Order {
230            salt: input.salt.unwrap_or_else(|| (self.generate_salt)()),
231            maker: input.maker.unwrap_or_else(|| maker_str.clone()),
232            signer: input.signer.unwrap_or_else(|| order_signer_str.clone()),
233            taker: input.taker.unwrap_or_else(|| ZERO_ADDRESS.to_string()),
234            token_id: input.token_id,
235            maker_amount: input.maker_amount,
236            taker_amount: input.taker_amount,
237            expiration,
238            nonce: input.nonce.unwrap_or_else(|| "0".to_string()),
239            fee_rate_bps: input.fee_rate_bps.to_string(),
240            side: input.side,
241            signature_type: input.signature_type.unwrap_or(SignatureType::Eoa),
242        })
243    }
244
245    /// Build EIP-712 typed data for an order
246    ///
247    /// # Arguments
248    ///
249    /// * `order` - The order to build typed data for
250    /// * `is_neg_risk` - Whether this is a neg risk market (winner takes all)
251    /// * `is_yield_bearing` - Whether this market has yield enabled
252    ///
253    /// # Returns
254    ///
255    /// The verifying contract address
256    pub fn get_verifying_contract(&self, is_neg_risk: bool, is_yield_bearing: bool) -> Address {
257        let address_str = self.addresses.get_ctf_exchange(is_yield_bearing, is_neg_risk);
258        address_str.parse().unwrap()
259    }
260
261    /// Build the EIP-712 typed data hash for an order
262    ///
263    /// # Arguments
264    ///
265    /// * `order` - The order to hash
266    /// * `is_neg_risk` - Whether this is a neg risk market
267    /// * `is_yield_bearing` - Whether this market has yield enabled
268    ///
269    /// # Returns
270    ///
271    /// The hash to be signed
272    ///
273    /// # Errors
274    ///
275    /// Returns an error if the order data is invalid
276    pub fn build_typed_data_hash(
277        &self,
278        order: &Order,
279        is_neg_risk: bool,
280        is_yield_bearing: bool,
281    ) -> Result<String> {
282        let verifying_contract = self.get_verifying_contract(is_neg_risk, is_yield_bearing);
283        let domain = signing::get_domain(self.chain_id, verifying_contract);
284        let hash = signing::build_typed_data_hash(order, &domain)?;
285        Ok(format!("0x{}", hex::encode(hash.as_slice())))
286    }
287
288    /// Sign an order using EIP-712
289    ///
290    /// This method automatically uses the appropriate signing method:
291    /// - For regular EOA wallets: Standard EIP-712 signing
292    /// - For Predict Account (Kernel): Kernel-wrapped EIP-712 signing
293    ///
294    /// # Arguments
295    ///
296    /// * `order` - The order to sign
297    /// * `is_neg_risk` - Whether this is a neg risk market
298    /// * `is_yield_bearing` - Whether this market has yield enabled
299    ///
300    /// # Returns
301    ///
302    /// A signed order with signature
303    ///
304    /// # Errors
305    ///
306    /// Returns an error if no signer is configured or signing fails
307    pub async fn sign_typed_data_order(
308        &self,
309        order: Order,
310        is_neg_risk: bool,
311        is_yield_bearing: bool,
312    ) -> Result<SignedOrder> {
313        let signer = self.signer.as_ref()
314            .ok_or_else(|| Error::Other("No signer configured".to_string()))?;
315
316        let verifying_contract = self.get_verifying_contract(is_neg_risk, is_yield_bearing);
317        let hash = self.build_typed_data_hash(&order, is_neg_risk, is_yield_bearing)?;
318
319        let signature = if let Some(predict_account) = self.predict_account {
320            // Use Kernel-wrapped signing for Predict Account
321            // verifyingContract in Kernel domain = predict_account (user's smart wallet),
322            // NOT the global Kernel contract address
323            let ecdsa_validator = self.addresses.ecdsa_validator.parse::<Address>()
324                .map_err(|e| Error::Other(format!("Invalid ECDSA validator address: {}", e)))?;
325
326            signing::sign_order_for_predict_account(
327                &order,
328                self.chain_id,
329                verifying_contract,
330                predict_account,
331                ecdsa_validator,
332                signer,
333            ).await?
334        } else {
335            // Standard EOA signing
336            signing::sign_order(&order, self.chain_id, verifying_contract, signer).await?
337        };
338
339        Ok(SignedOrder {
340            order,
341            hash: Some(hash),
342            signature,
343        })
344    }
345
346    /// Sign an order using standard EOA EIP-712 (never Kernel-wrapped)
347    ///
348    /// This is used for REST API order placement, where the server does plain ecrecover
349    /// to verify the signature. Kernel-wrapped signatures are only needed for on-chain
350    /// settlement, which the platform handles internally.
351    pub async fn sign_typed_data_order_eoa(
352        &self,
353        order: Order,
354        is_neg_risk: bool,
355        is_yield_bearing: bool,
356    ) -> Result<SignedOrder> {
357        let signer = self.signer.as_ref()
358            .ok_or_else(|| Error::Other("No signer configured".to_string()))?;
359
360        let verifying_contract = self.get_verifying_contract(is_neg_risk, is_yield_bearing);
361        let hash = self.build_typed_data_hash(&order, is_neg_risk, is_yield_bearing)?;
362
363        // Always use standard EOA signing, regardless of predict_account
364        let signature = signing::sign_order(&order, self.chain_id, verifying_contract, signer).await?;
365
366        Ok(SignedOrder {
367            order,
368            hash: Some(hash),
369            signature,
370        })
371    }
372
373    /// Get the EOA signer address as a formatted string
374    pub fn signer_address_string(&self) -> Result<String> {
375        self.signer_address().map(|addr| format!("{}", addr))
376    }
377
378    /// Get the chain ID
379    pub fn chain_id(&self) -> ChainId {
380        self.chain_id
381    }
382
383    /// Get the addresses
384    pub fn addresses(&self) -> &Addresses {
385        &self.addresses
386    }
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392    use rust_decimal_macros::dec;
393
394    #[test]
395    fn test_new_order_builder() {
396        let builder = OrderBuilder::new(ChainId::BnbTestnet, None, None).unwrap();
397        assert_eq!(builder.chain_id(), ChainId::BnbTestnet);
398    }
399
400    #[test]
401    fn test_get_limit_order_amounts() {
402        let builder = OrderBuilder::new(ChainId::BnbTestnet, None, None).unwrap();
403
404        let data = LimitOrderData {
405            side: Side::Buy,
406            price_per_share_wei: dec!(500000000000000000),
407            quantity_wei: dec!(10000000000000000000),
408        };
409
410        let amounts = builder.get_limit_order_amounts(data).unwrap();
411        assert!(amounts.maker_amount > rust_decimal::Decimal::ZERO);
412    }
413
414    #[test]
415    fn test_build_order() {
416        let signer = PrivateKeySigner::random();
417        let builder = OrderBuilder::new(ChainId::BnbTestnet, Some(signer), None).unwrap();
418
419        let input = BuildOrderInput {
420            side: Side::Buy,
421            token_id: "12345".to_string(),
422            maker_amount: "1000000000000000000".to_string(),
423            taker_amount: "2000000000000000000".to_string(),
424            fee_rate_bps: 100,
425            signer: None,
426            nonce: None,
427            salt: None,
428            maker: None,
429            taker: None,
430            signature_type: None,
431            expires_at: None,
432        };
433
434        let order = builder.build_order(OrderStrategy::Limit, input).unwrap();
435        assert_eq!(order.side, Side::Buy);
436        assert_eq!(order.token_id, "12345");
437    }
438
439    #[tokio::test]
440    async fn test_sign_order() {
441        let signer = PrivateKeySigner::random();
442        let builder = OrderBuilder::new(ChainId::BnbTestnet, Some(signer), None).unwrap();
443
444        let input = BuildOrderInput {
445            side: Side::Buy,
446            token_id: "12345".to_string(),
447            maker_amount: "1000000000000000000".to_string(),
448            taker_amount: "2000000000000000000".to_string(),
449            fee_rate_bps: 100,
450            signer: None,
451            nonce: None,
452            salt: None,
453            maker: None,
454            taker: None,
455            signature_type: None,
456            expires_at: None,
457        };
458
459        let order = builder.build_order(OrderStrategy::Limit, input).unwrap();
460        let signed_order = builder.sign_typed_data_order(order, false, false).await.unwrap();
461
462        assert!(signed_order.signature.starts_with("0x"));
463        assert!(signed_order.hash.is_some());
464    }
465}