Skip to main content

predict_fun_sdk/
order.rs

1//! Predict.fun order signing (EIP-712) using alloy.
2//!
3//! Includes order structs, amount calculation, exchange address mapping,
4//! and signing via [`PredictOrderSigner`].
5
6use std::str::FromStr;
7use std::time::{SystemTime, UNIX_EPOCH};
8
9use alloy_primitives::{Address, B256, U256};
10use alloy_signer::{Signer, SignerSync};
11use alloy_signer_local::PrivateKeySigner;
12use alloy_sol_types::{eip712_domain, sol, SolStruct};
13use anyhow::{anyhow, Context, Result};
14use serde::{Deserialize, Serialize};
15
16pub const PREDICT_PROTOCOL_NAME: &str = "predict.fun CTF Exchange";
17pub const PREDICT_PROTOCOL_VERSION: &str = "1";
18
19pub const BNB_MAINNET_CHAIN_ID: u64 = 56;
20pub const BNB_TESTNET_CHAIN_ID: u64 = 97;
21
22pub const ZERO_ADDRESS: &str = "0x0000000000000000000000000000000000000000";
23
24// Exchange addresses from https://github.com/PredictDotFun/sdk/blob/main/src/Constants.ts
25const MAINNET_CTF_EXCHANGE: &str = "0x8BC070BEdAB741406F4B1Eb65A72bee27894B689";
26const MAINNET_NEG_RISK_CTF_EXCHANGE: &str = "0x365fb81bd4A24D6303cd2F19c349dE6894D8d58A";
27const MAINNET_YIELD_CTF_EXCHANGE: &str = "0x6bEb5a40C032AFc305961162d8204CDA16DECFa5";
28const MAINNET_YIELD_NEG_RISK_CTF_EXCHANGE: &str = "0x8A289d458f5a134bA40015085A8F50Ffb681B41d";
29
30const TESTNET_CTF_EXCHANGE: &str = "0x2A6413639BD3d73a20ed8C95F634Ce198ABbd2d7";
31const TESTNET_NEG_RISK_CTF_EXCHANGE: &str = "0xd690b2bd441bE36431F6F6639D7Ad351e7B29680";
32const TESTNET_YIELD_CTF_EXCHANGE: &str = "0x8a6B4Fa700A1e310b106E7a48bAFa29111f66e89";
33const TESTNET_YIELD_NEG_RISK_CTF_EXCHANGE: &str = "0x95D5113bc50eD201e319101bbca3e0E250662fCC";
34
35const WEI_SCALE: u128 = 1_000_000_000_000_000_000;
36
37/// Predict market outcome side.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum PredictOutcome {
40    Yes,
41    No,
42}
43
44/// Predict order side.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46#[repr(u8)]
47pub enum PredictSide {
48    Buy = 0,
49    Sell = 1,
50}
51
52/// Predict order strategy in API payload.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum PredictStrategy {
55    Limit,
56    Market,
57}
58
59impl PredictStrategy {
60    pub const fn as_str(self) -> &'static str {
61        match self {
62            PredictStrategy::Limit => "LIMIT",
63            PredictStrategy::Market => "MARKET",
64        }
65    }
66}
67
68/// Signature type from Predict SDK constants.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70#[repr(u8)]
71pub enum PredictSignatureType {
72    Eoa = 0,
73    PolyProxy = 1,
74    PolyGnosisSafe = 2,
75}
76
77/// Contract order payload shape used by Predict REST API.
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct PredictOrder {
80    pub salt: String,
81    pub maker: String,
82    pub signer: String,
83    pub taker: String,
84    #[serde(rename = "tokenId")]
85    pub token_id: String,
86    #[serde(rename = "makerAmount")]
87    pub maker_amount: String,
88    #[serde(rename = "takerAmount")]
89    pub taker_amount: String,
90    pub expiration: String,
91    pub nonce: String,
92    #[serde(rename = "feeRateBps")]
93    pub fee_rate_bps: String,
94    pub side: u8,
95    #[serde(rename = "signatureType")]
96    pub signature_type: u8,
97}
98
99impl PredictOrder {
100    /// Create a LIMIT order payload from canonical fields.
101    pub fn new_limit(
102        maker: Address,
103        signer: Address,
104        token_id: impl Into<String>,
105        side: PredictSide,
106        maker_amount_wei: U256,
107        taker_amount_wei: U256,
108        fee_rate_bps: u32,
109    ) -> Self {
110        Self {
111            salt: generate_order_salt(),
112            maker: maker.to_string(),
113            signer: signer.to_string(),
114            taker: ZERO_ADDRESS.to_string(),
115            token_id: token_id.into(),
116            maker_amount: maker_amount_wei.to_string(),
117            taker_amount: taker_amount_wei.to_string(),
118            expiration: "0".to_string(),
119            nonce: "0".to_string(),
120            fee_rate_bps: fee_rate_bps.to_string(),
121            side: side as u8,
122            signature_type: PredictSignatureType::Eoa as u8,
123        }
124    }
125}
126
127/// Signed order shape expected under `data.order` in `POST /orders`.
128#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct SignedPredictOrder {
130    #[serde(flatten)]
131    pub order: PredictOrder,
132    pub signature: String,
133    pub hash: String,
134}
135
136#[derive(Debug, Clone, Serialize)]
137pub struct PredictCreateOrderRequest {
138    pub data: PredictCreateOrderData,
139}
140
141#[derive(Debug, Clone, Serialize)]
142pub struct PredictCreateOrderData {
143    #[serde(rename = "pricePerShare")]
144    pub price_per_share: String,
145    pub strategy: String,
146    #[serde(rename = "slippageBps", skip_serializing_if = "Option::is_none")]
147    pub slippage_bps: Option<String>,
148    #[serde(rename = "isFillOrKill", skip_serializing_if = "Option::is_none")]
149    pub is_fill_or_kill: Option<bool>,
150    pub order: SignedPredictOrder,
151}
152
153impl SignedPredictOrder {
154    pub fn to_create_order_request(
155        &self,
156        price_per_share_wei: U256,
157        strategy: PredictStrategy,
158        slippage_bps: Option<u32>,
159        is_fill_or_kill: Option<bool>,
160    ) -> PredictCreateOrderRequest {
161        PredictCreateOrderRequest {
162            data: PredictCreateOrderData {
163                price_per_share: price_per_share_wei.to_string(),
164                strategy: strategy.as_str().to_string(),
165                slippage_bps: slippage_bps.map(|v| v.to_string()),
166                is_fill_or_kill,
167                order: self.clone(),
168            },
169        }
170    }
171}
172
173/// Limit order amount calculator ported from Predict TypeScript SDK logic.
174///
175/// Inputs are 18-decimal wei values:
176/// - `price_per_share_wei`: price in [0, 1e18]
177/// - `quantity_wei`: quantity in wei units
178///
179/// BUY:
180/// - makerAmount = quantity * price
181/// - takerAmount = quantity
182///
183/// SELL:
184/// - makerAmount = quantity
185/// - takerAmount = quantity * price
186pub fn predict_limit_order_amounts(
187    side: PredictSide,
188    price_per_share_wei: U256,
189    quantity_wei: U256,
190) -> (U256, U256) {
191    let notionals = quantity_wei.saturating_mul(price_per_share_wei) / U256::from(WEI_SCALE);
192
193    match side {
194        PredictSide::Buy => (notionals, quantity_wei),
195        PredictSide::Sell => (quantity_wei, notionals),
196    }
197}
198
199/// Thin order signer wrapper backed by alloy `PrivateKeySigner`.
200#[derive(Clone)]
201pub struct PredictOrderSigner {
202    signer: PrivateKeySigner,
203    chain_id: u64,
204}
205
206impl PredictOrderSigner {
207    pub fn from_private_key(private_key: &str, chain_id: u64) -> Result<Self> {
208        let signer: PrivateKeySigner = private_key
209            .parse()
210            .context("invalid private key for alloy signer")?;
211
212        Ok(Self {
213            signer: signer.with_chain_id(Some(chain_id)),
214            chain_id,
215        })
216    }
217
218    pub fn address(&self) -> Address {
219        self.signer.address()
220    }
221
222    pub fn chain_id(&self) -> u64 {
223        self.chain_id
224    }
225
226    /// Sign the message used by `GET /auth/message` + `POST /auth` flow.
227    pub fn sign_auth_message(&self, message: &str) -> Result<String> {
228        let sig = self
229            .signer
230            .sign_message_sync(message.as_bytes())
231            .context("failed to sign auth message")?;
232        Ok(sig.to_string())
233    }
234
235    /// Compute EIP-712 signing hash for a Predict order.
236    pub fn order_hash(
237        &self,
238        order: &PredictOrder,
239        is_neg_risk: bool,
240        is_yield_bearing: bool,
241    ) -> Result<B256> {
242        let order_sol = to_sol_order(order)?;
243        let contract = predict_exchange_address(self.chain_id, is_neg_risk, is_yield_bearing)?;
244        let domain = eip712_domain! {
245            name: PREDICT_PROTOCOL_NAME,
246            version: PREDICT_PROTOCOL_VERSION,
247            chain_id: self.chain_id,
248            verifying_contract: contract,
249        };
250
251        Ok(order_sol.eip712_signing_hash(&domain))
252    }
253
254    /// Sign order using EIP-712 domain from Predict SDK contract map.
255    pub fn sign_order(
256        &self,
257        order: &PredictOrder,
258        is_neg_risk: bool,
259        is_yield_bearing: bool,
260    ) -> Result<SignedPredictOrder> {
261        let hash = self.order_hash(order, is_neg_risk, is_yield_bearing)?;
262        let signature = self
263            .signer
264            .sign_hash_sync(&hash)
265            .context("failed to sign order hash")?;
266
267        Ok(SignedPredictOrder {
268            order: order.clone(),
269            signature: signature.to_string(),
270            hash: hash.to_string(),
271        })
272    }
273}
274
275/// Resolve the verifying contract used in EIP-712 domain.
276pub fn predict_exchange_address(
277    chain_id: u64,
278    is_neg_risk: bool,
279    is_yield_bearing: bool,
280) -> Result<Address> {
281    let address = match (chain_id, is_neg_risk, is_yield_bearing) {
282        (BNB_MAINNET_CHAIN_ID, false, false) => MAINNET_CTF_EXCHANGE,
283        (BNB_MAINNET_CHAIN_ID, true, false) => MAINNET_NEG_RISK_CTF_EXCHANGE,
284        (BNB_MAINNET_CHAIN_ID, false, true) => MAINNET_YIELD_CTF_EXCHANGE,
285        (BNB_MAINNET_CHAIN_ID, true, true) => MAINNET_YIELD_NEG_RISK_CTF_EXCHANGE,
286        (BNB_TESTNET_CHAIN_ID, false, false) => TESTNET_CTF_EXCHANGE,
287        (BNB_TESTNET_CHAIN_ID, true, false) => TESTNET_NEG_RISK_CTF_EXCHANGE,
288        (BNB_TESTNET_CHAIN_ID, false, true) => TESTNET_YIELD_CTF_EXCHANGE,
289        (BNB_TESTNET_CHAIN_ID, true, true) => TESTNET_YIELD_NEG_RISK_CTF_EXCHANGE,
290        _ => {
291            return Err(anyhow!(
292                "unsupported Predict chain_id={} (expected {} or {})",
293                chain_id,
294                BNB_MAINNET_CHAIN_ID,
295                BNB_TESTNET_CHAIN_ID
296            ));
297        }
298    };
299
300    Address::from_str(address).context("invalid static Predict exchange address")
301}
302
303#[inline(always)]
304fn parse_u256_decimal(value: &str, field: &'static str) -> Result<U256> {
305    U256::from_str(value)
306        .with_context(|| format!("invalid {}='{}' (expected decimal string)", field, value))
307}
308
309#[inline(always)]
310fn parse_address(value: &str, field: &'static str) -> Result<Address> {
311    Address::from_str(value).with_context(|| format!("invalid {}='{}'", field, value))
312}
313
314#[inline(always)]
315fn generate_order_salt() -> String {
316    (SystemTime::now()
317        .duration_since(UNIX_EPOCH)
318        .unwrap()
319        .as_nanos()
320        % u128::from(i32::MAX as u32))
321    .to_string()
322}
323
324sol! {
325    struct PredictOrderSol {
326        uint256 salt;
327        address maker;
328        address signer;
329        address taker;
330        uint256 tokenId;
331        uint256 makerAmount;
332        uint256 takerAmount;
333        uint256 expiration;
334        uint256 nonce;
335        uint256 feeRateBps;
336        uint8 side;
337        uint8 signatureType;
338    }
339}
340
341fn to_sol_order(order: &PredictOrder) -> Result<PredictOrderSol> {
342    Ok(PredictOrderSol {
343        salt: parse_u256_decimal(&order.salt, "salt")?,
344        maker: parse_address(&order.maker, "maker")?,
345        signer: parse_address(&order.signer, "signer")?,
346        taker: parse_address(&order.taker, "taker")?,
347        tokenId: parse_u256_decimal(&order.token_id, "token_id")?,
348        makerAmount: parse_u256_decimal(&order.maker_amount, "maker_amount")?,
349        takerAmount: parse_u256_decimal(&order.taker_amount, "taker_amount")?,
350        expiration: parse_u256_decimal(&order.expiration, "expiration")?,
351        nonce: parse_u256_decimal(&order.nonce, "nonce")?,
352        feeRateBps: parse_u256_decimal(&order.fee_rate_bps, "fee_rate_bps")?,
353        side: order.side,
354        signatureType: order.signature_type,
355    })
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361    use alloy_primitives::Signature;
362    use std::str::FromStr;
363
364    const TEST_PRIVATE_KEY: &str =
365        "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
366
367    #[test]
368    fn exchange_address_mapping_is_correct() {
369        assert_eq!(
370            predict_exchange_address(BNB_MAINNET_CHAIN_ID, false, false).unwrap(),
371            Address::from_str(MAINNET_CTF_EXCHANGE).unwrap()
372        );
373        assert_eq!(
374            predict_exchange_address(BNB_MAINNET_CHAIN_ID, true, false).unwrap(),
375            Address::from_str(MAINNET_NEG_RISK_CTF_EXCHANGE).unwrap()
376        );
377        assert_eq!(
378            predict_exchange_address(BNB_TESTNET_CHAIN_ID, false, true).unwrap(),
379            Address::from_str(TESTNET_YIELD_CTF_EXCHANGE).unwrap()
380        );
381    }
382
383    #[test]
384    fn limit_order_amounts_match_sdk_logic() {
385        // price = 0.4, quantity = 10
386        let price = U256::from(400_000_000_000_000_000u128);
387        let qty = U256::from(10_000_000_000_000_000_000u128);
388
389        let (buy_maker, buy_taker) = predict_limit_order_amounts(PredictSide::Buy, price, qty);
390        assert_eq!(buy_maker, U256::from(4_000_000_000_000_000_000u128));
391        assert_eq!(buy_taker, qty);
392
393        let (sell_maker, sell_taker) = predict_limit_order_amounts(PredictSide::Sell, price, qty);
394        assert_eq!(sell_maker, qty);
395        assert_eq!(sell_taker, U256::from(4_000_000_000_000_000_000u128));
396    }
397
398    #[test]
399    fn order_signature_recovers_signer() {
400        let signer =
401            PredictOrderSigner::from_private_key(TEST_PRIVATE_KEY, BNB_MAINNET_CHAIN_ID).unwrap();
402        let address = signer.address();
403
404        let (maker_amount, taker_amount) = predict_limit_order_amounts(
405            PredictSide::Buy,
406            U256::from(400_000_000_000_000_000u128),
407            U256::from(10_000_000_000_000_000_000u128),
408        );
409
410        let order = PredictOrder {
411            salt: "1".to_string(),
412            maker: address.to_string(),
413            signer: address.to_string(),
414            taker: ZERO_ADDRESS.to_string(),
415            token_id: "123456789012345678901234567890".to_string(),
416            maker_amount: maker_amount.to_string(),
417            taker_amount: taker_amount.to_string(),
418            expiration: "0".to_string(),
419            nonce: "0".to_string(),
420            fee_rate_bps: "200".to_string(),
421            side: PredictSide::Buy as u8,
422            signature_type: PredictSignatureType::Eoa as u8,
423        };
424
425        let signed = signer.sign_order(&order, false, false).unwrap();
426        let hash = signer.order_hash(&order, false, false).unwrap();
427        let sig: Signature = signed.signature.parse().unwrap();
428
429        let recovered = sig.recover_address_from_prehash(&hash).unwrap();
430        assert_eq!(recovered, address);
431    }
432
433    #[test]
434    fn auth_message_signature_recovers_signer() {
435        let signer =
436            PredictOrderSigner::from_private_key(TEST_PRIVATE_KEY, BNB_MAINNET_CHAIN_ID).unwrap();
437        let sig = signer.sign_auth_message("hello predict").unwrap();
438        let sig: Signature = sig.parse().unwrap();
439        let recovered = sig.recover_address_from_msg("hello predict").unwrap();
440        assert_eq!(recovered, signer.address());
441    }
442}