use std::str::FromStr;
use std::time::{SystemTime, UNIX_EPOCH};
use alloy_primitives::{Address, B256, U256};
use alloy_signer::{Signer, SignerSync};
use alloy_signer_local::PrivateKeySigner;
use alloy_sol_types::{eip712_domain, sol, SolStruct};
use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
pub const PREDICT_PROTOCOL_NAME: &str = "predict.fun CTF Exchange";
pub const PREDICT_PROTOCOL_VERSION: &str = "1";
pub const BNB_MAINNET_CHAIN_ID: u64 = 56;
pub const BNB_TESTNET_CHAIN_ID: u64 = 97;
pub const ZERO_ADDRESS: &str = "0x0000000000000000000000000000000000000000";
const MAINNET_CTF_EXCHANGE: &str = "0x8BC070BEdAB741406F4B1Eb65A72bee27894B689";
const MAINNET_NEG_RISK_CTF_EXCHANGE: &str = "0x365fb81bd4A24D6303cd2F19c349dE6894D8d58A";
const MAINNET_YIELD_CTF_EXCHANGE: &str = "0x6bEb5a40C032AFc305961162d8204CDA16DECFa5";
const MAINNET_YIELD_NEG_RISK_CTF_EXCHANGE: &str = "0x8A289d458f5a134bA40015085A8F50Ffb681B41d";
const TESTNET_CTF_EXCHANGE: &str = "0x2A6413639BD3d73a20ed8C95F634Ce198ABbd2d7";
const TESTNET_NEG_RISK_CTF_EXCHANGE: &str = "0xd690b2bd441bE36431F6F6639D7Ad351e7B29680";
const TESTNET_YIELD_CTF_EXCHANGE: &str = "0x8a6B4Fa700A1e310b106E7a48bAFa29111f66e89";
const TESTNET_YIELD_NEG_RISK_CTF_EXCHANGE: &str = "0x95D5113bc50eD201e319101bbca3e0E250662fCC";
const WEI_SCALE: u128 = 1_000_000_000_000_000_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PredictOutcome {
Yes,
No,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum PredictSide {
Buy = 0,
Sell = 1,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PredictStrategy {
Limit,
Market,
}
impl PredictStrategy {
pub const fn as_str(self) -> &'static str {
match self {
PredictStrategy::Limit => "LIMIT",
PredictStrategy::Market => "MARKET",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum PredictSignatureType {
Eoa = 0,
PolyProxy = 1,
PolyGnosisSafe = 2,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PredictOrder {
pub salt: String,
pub maker: String,
pub signer: String,
pub taker: String,
#[serde(rename = "tokenId")]
pub token_id: String,
#[serde(rename = "makerAmount")]
pub maker_amount: String,
#[serde(rename = "takerAmount")]
pub taker_amount: String,
pub expiration: String,
pub nonce: String,
#[serde(rename = "feeRateBps")]
pub fee_rate_bps: String,
pub side: u8,
#[serde(rename = "signatureType")]
pub signature_type: u8,
}
impl PredictOrder {
pub fn new_limit(
maker: Address,
signer: Address,
token_id: impl Into<String>,
side: PredictSide,
maker_amount_wei: U256,
taker_amount_wei: U256,
fee_rate_bps: u32,
) -> Self {
Self {
salt: generate_order_salt(),
maker: maker.to_string(),
signer: signer.to_string(),
taker: ZERO_ADDRESS.to_string(),
token_id: token_id.into(),
maker_amount: maker_amount_wei.to_string(),
taker_amount: taker_amount_wei.to_string(),
expiration: "0".to_string(),
nonce: "0".to_string(),
fee_rate_bps: fee_rate_bps.to_string(),
side: side as u8,
signature_type: PredictSignatureType::Eoa as u8,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SignedPredictOrder {
#[serde(flatten)]
pub order: PredictOrder,
pub signature: String,
pub hash: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct PredictCreateOrderRequest {
pub data: PredictCreateOrderData,
}
#[derive(Debug, Clone, Serialize)]
pub struct PredictCreateOrderData {
#[serde(rename = "pricePerShare")]
pub price_per_share: String,
pub strategy: String,
#[serde(rename = "slippageBps", skip_serializing_if = "Option::is_none")]
pub slippage_bps: Option<String>,
#[serde(rename = "isFillOrKill", skip_serializing_if = "Option::is_none")]
pub is_fill_or_kill: Option<bool>,
pub order: SignedPredictOrder,
}
impl SignedPredictOrder {
pub fn to_create_order_request(
&self,
price_per_share_wei: U256,
strategy: PredictStrategy,
slippage_bps: Option<u32>,
is_fill_or_kill: Option<bool>,
) -> PredictCreateOrderRequest {
PredictCreateOrderRequest {
data: PredictCreateOrderData {
price_per_share: price_per_share_wei.to_string(),
strategy: strategy.as_str().to_string(),
slippage_bps: slippage_bps.map(|v| v.to_string()),
is_fill_or_kill,
order: self.clone(),
},
}
}
}
pub fn predict_limit_order_amounts(
side: PredictSide,
price_per_share_wei: U256,
quantity_wei: U256,
) -> (U256, U256) {
let notionals = quantity_wei.saturating_mul(price_per_share_wei) / U256::from(WEI_SCALE);
match side {
PredictSide::Buy => (notionals, quantity_wei),
PredictSide::Sell => (quantity_wei, notionals),
}
}
#[derive(Clone)]
pub struct PredictOrderSigner {
signer: PrivateKeySigner,
chain_id: u64,
}
impl PredictOrderSigner {
pub fn from_private_key(private_key: &str, chain_id: u64) -> Result<Self> {
let signer: PrivateKeySigner = private_key
.parse()
.context("invalid private key for alloy signer")?;
Ok(Self {
signer: signer.with_chain_id(Some(chain_id)),
chain_id,
})
}
pub fn address(&self) -> Address {
self.signer.address()
}
pub fn chain_id(&self) -> u64 {
self.chain_id
}
pub fn sign_auth_message(&self, message: &str) -> Result<String> {
let sig = self
.signer
.sign_message_sync(message.as_bytes())
.context("failed to sign auth message")?;
Ok(sig.to_string())
}
pub fn order_hash(
&self,
order: &PredictOrder,
is_neg_risk: bool,
is_yield_bearing: bool,
) -> Result<B256> {
let order_sol = to_sol_order(order)?;
let contract = predict_exchange_address(self.chain_id, is_neg_risk, is_yield_bearing)?;
let domain = eip712_domain! {
name: PREDICT_PROTOCOL_NAME,
version: PREDICT_PROTOCOL_VERSION,
chain_id: self.chain_id,
verifying_contract: contract,
};
Ok(order_sol.eip712_signing_hash(&domain))
}
pub fn sign_order(
&self,
order: &PredictOrder,
is_neg_risk: bool,
is_yield_bearing: bool,
) -> Result<SignedPredictOrder> {
let hash = self.order_hash(order, is_neg_risk, is_yield_bearing)?;
let signature = self
.signer
.sign_hash_sync(&hash)
.context("failed to sign order hash")?;
Ok(SignedPredictOrder {
order: order.clone(),
signature: signature.to_string(),
hash: hash.to_string(),
})
}
}
pub fn predict_exchange_address(
chain_id: u64,
is_neg_risk: bool,
is_yield_bearing: bool,
) -> Result<Address> {
let address = match (chain_id, is_neg_risk, is_yield_bearing) {
(BNB_MAINNET_CHAIN_ID, false, false) => MAINNET_CTF_EXCHANGE,
(BNB_MAINNET_CHAIN_ID, true, false) => MAINNET_NEG_RISK_CTF_EXCHANGE,
(BNB_MAINNET_CHAIN_ID, false, true) => MAINNET_YIELD_CTF_EXCHANGE,
(BNB_MAINNET_CHAIN_ID, true, true) => MAINNET_YIELD_NEG_RISK_CTF_EXCHANGE,
(BNB_TESTNET_CHAIN_ID, false, false) => TESTNET_CTF_EXCHANGE,
(BNB_TESTNET_CHAIN_ID, true, false) => TESTNET_NEG_RISK_CTF_EXCHANGE,
(BNB_TESTNET_CHAIN_ID, false, true) => TESTNET_YIELD_CTF_EXCHANGE,
(BNB_TESTNET_CHAIN_ID, true, true) => TESTNET_YIELD_NEG_RISK_CTF_EXCHANGE,
_ => {
return Err(anyhow!(
"unsupported Predict chain_id={} (expected {} or {})",
chain_id,
BNB_MAINNET_CHAIN_ID,
BNB_TESTNET_CHAIN_ID
));
}
};
Address::from_str(address).context("invalid static Predict exchange address")
}
#[inline(always)]
fn parse_u256_decimal(value: &str, field: &'static str) -> Result<U256> {
U256::from_str(value)
.with_context(|| format!("invalid {}='{}' (expected decimal string)", field, value))
}
#[inline(always)]
fn parse_address(value: &str, field: &'static str) -> Result<Address> {
Address::from_str(value).with_context(|| format!("invalid {}='{}'", field, value))
}
#[inline(always)]
fn generate_order_salt() -> String {
(SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
% u128::from(i32::MAX as u32))
.to_string()
}
sol! {
struct PredictOrderSol {
uint256 salt;
address maker;
address signer;
address taker;
uint256 tokenId;
uint256 makerAmount;
uint256 takerAmount;
uint256 expiration;
uint256 nonce;
uint256 feeRateBps;
uint8 side;
uint8 signatureType;
}
}
fn to_sol_order(order: &PredictOrder) -> Result<PredictOrderSol> {
Ok(PredictOrderSol {
salt: parse_u256_decimal(&order.salt, "salt")?,
maker: parse_address(&order.maker, "maker")?,
signer: parse_address(&order.signer, "signer")?,
taker: parse_address(&order.taker, "taker")?,
tokenId: parse_u256_decimal(&order.token_id, "token_id")?,
makerAmount: parse_u256_decimal(&order.maker_amount, "maker_amount")?,
takerAmount: parse_u256_decimal(&order.taker_amount, "taker_amount")?,
expiration: parse_u256_decimal(&order.expiration, "expiration")?,
nonce: parse_u256_decimal(&order.nonce, "nonce")?,
feeRateBps: parse_u256_decimal(&order.fee_rate_bps, "fee_rate_bps")?,
side: order.side,
signatureType: order.signature_type,
})
}
#[cfg(test)]
mod tests {
use super::*;
use alloy_primitives::Signature;
use std::str::FromStr;
const TEST_PRIVATE_KEY: &str =
"0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
#[test]
fn exchange_address_mapping_is_correct() {
assert_eq!(
predict_exchange_address(BNB_MAINNET_CHAIN_ID, false, false).unwrap(),
Address::from_str(MAINNET_CTF_EXCHANGE).unwrap()
);
assert_eq!(
predict_exchange_address(BNB_MAINNET_CHAIN_ID, true, false).unwrap(),
Address::from_str(MAINNET_NEG_RISK_CTF_EXCHANGE).unwrap()
);
assert_eq!(
predict_exchange_address(BNB_TESTNET_CHAIN_ID, false, true).unwrap(),
Address::from_str(TESTNET_YIELD_CTF_EXCHANGE).unwrap()
);
}
#[test]
fn limit_order_amounts_match_sdk_logic() {
let price = U256::from(400_000_000_000_000_000u128);
let qty = U256::from(10_000_000_000_000_000_000u128);
let (buy_maker, buy_taker) = predict_limit_order_amounts(PredictSide::Buy, price, qty);
assert_eq!(buy_maker, U256::from(4_000_000_000_000_000_000u128));
assert_eq!(buy_taker, qty);
let (sell_maker, sell_taker) = predict_limit_order_amounts(PredictSide::Sell, price, qty);
assert_eq!(sell_maker, qty);
assert_eq!(sell_taker, U256::from(4_000_000_000_000_000_000u128));
}
#[test]
fn order_signature_recovers_signer() {
let signer =
PredictOrderSigner::from_private_key(TEST_PRIVATE_KEY, BNB_MAINNET_CHAIN_ID).unwrap();
let address = signer.address();
let (maker_amount, taker_amount) = predict_limit_order_amounts(
PredictSide::Buy,
U256::from(400_000_000_000_000_000u128),
U256::from(10_000_000_000_000_000_000u128),
);
let order = PredictOrder {
salt: "1".to_string(),
maker: address.to_string(),
signer: address.to_string(),
taker: ZERO_ADDRESS.to_string(),
token_id: "123456789012345678901234567890".to_string(),
maker_amount: maker_amount.to_string(),
taker_amount: taker_amount.to_string(),
expiration: "0".to_string(),
nonce: "0".to_string(),
fee_rate_bps: "200".to_string(),
side: PredictSide::Buy as u8,
signature_type: PredictSignatureType::Eoa as u8,
};
let signed = signer.sign_order(&order, false, false).unwrap();
let hash = signer.order_hash(&order, false, false).unwrap();
let sig: Signature = signed.signature.parse().unwrap();
let recovered = sig.recover_address_from_prehash(&hash).unwrap();
assert_eq!(recovered, address);
}
#[test]
fn auth_message_signature_recovers_signer() {
let signer =
PredictOrderSigner::from_private_key(TEST_PRIVATE_KEY, BNB_MAINNET_CHAIN_ID).unwrap();
let sig = signer.sign_auth_message("hello predict").unwrap();
let sig: Signature = sig.parse().unwrap();
let recovered = sig.recover_address_from_msg("hello predict").unwrap();
assert_eq!(recovered, signer.address());
}
}