1use 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
24const 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum PredictOutcome {
40 Yes,
41 No,
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46#[repr(u8)]
47pub enum PredictSide {
48 Buy = 0,
49 Sell = 1,
50}
51
52#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70#[repr(u8)]
71pub enum PredictSignatureType {
72 Eoa = 0,
73 PolyProxy = 1,
74 PolyGnosisSafe = 2,
75}
76
77#[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 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#[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
173pub 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#[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 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 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 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
275pub 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 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}