predict-sdk 0.1.0

Rust SDK for Predict.fun prediction market - order building, EIP-712 signing, and real-time WebSocket data
Documentation
use crate::{constants, Error, Result};
use crate::types::{ChainId, Order as OrderData};
use alloy::primitives::{Address, U256, B256, keccak256};
use alloy::signers::{Signer as AlloySigner, local::PrivateKeySigner};
use alloy::sol;
use alloy::sol_types::{Eip712Domain, SolStruct};

// Define the EIP-712 Order struct matching predict.fun specification.
// CRITICAL: The struct name MUST be "Order" (not "OrderStruct") because
// EIP-712 includes the type name in the type hash: "Order(uint256 salt,...)"
sol! {
    #[derive(Debug)]
    struct 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;
    }

    // Kernel wrapper struct for smart wallet signing
    // CRITICAL: The struct name MUST be "Kernel" (not "Kernel") because
    // EIP-712 includes the type name in the type hash: "Kernel(bytes32 hash)"
    #[derive(Debug)]
    struct Kernel {
        bytes32 hash;
    }
}

/// Kernel domain name
const KERNEL_DOMAIN_NAME: &str = "Kernel";

/// Kernel domain version
const KERNEL_DOMAIN_VERSION: &str = "0.3.1";

/// Get the Kernel EIP-712 domain for Predict Smart Wallet signing
///
/// The Kernel smart wallet uses a nested EIP-712 structure where the order
/// hash is wrapped in a Kernel struct and signed with the Kernel domain.
///
/// # Arguments
///
/// * `chain_id` - The chain ID (BNB Mainnet or Testnet)
/// * `kernel_address` - The address of the Kernel contract
///
/// # Returns
///
/// The EIP-712 domain for Kernel signing
pub fn get_kernel_domain(chain_id: ChainId, kernel_address: Address) -> Eip712Domain {
    Eip712Domain {
        name: Some(KERNEL_DOMAIN_NAME.into()),
        version: Some(KERNEL_DOMAIN_VERSION.into()),
        chain_id: Some(U256::from(chain_id.as_u64())),
        verifying_contract: Some(kernel_address),
        salt: None,
    }
}

/// Build the Kernel-wrapped EIP-712 hash for Predict Smart Wallet signing
///
/// This wraps the order hash in a Kernel struct and computes the
/// EIP-712 hash using the Kernel domain.
///
/// # Arguments
///
/// * `order_hash` - The original order EIP-712 hash
/// * `kernel_domain` - The Kernel EIP-712 domain
///
/// # Returns
///
/// The wrapped hash to be signed
pub fn build_kernel_wrapped_hash(order_hash: B256, kernel_domain: &Eip712Domain) -> B256 {
    let wrapper = Kernel { hash: order_hash };
    wrapper.eip712_signing_hash(kernel_domain)
}

/// Sign an order for Predict Smart Wallet (Kernel-based)
///
/// This implements the Kernel signing flow matching the TypeScript SDK:
/// 1. Compute the order EIP-712 hash using the CTF Exchange domain
/// 2. Wrap the hash with Kernel EIP-712 domain (verifyingContract = predict_account)
/// 3. Sign the wrapped hash using signMessage (EIP-191 personal message prefix)
/// 4. Prepend 0x01 + ECDSA_VALIDATOR_ADDRESS to the signature
///
/// # Arguments
///
/// * `order` - The order to sign
/// * `chain_id` - The chain ID
/// * `verifying_contract` - The address of the CTF Exchange contract
/// * `predict_account` - The user's Predict Account (smart wallet) address,
///   used as verifyingContract in the Kernel EIP-712 domain
/// * `ecdsa_validator_address` - The address of the ECDSA validator
/// * `signer` - The Privy private key signer
pub async fn sign_order_for_predict_account(
    order: &OrderData,
    chain_id: ChainId,
    verifying_contract: Address,
    predict_account: Address,
    ecdsa_validator_address: Address,
    signer: &PrivateKeySigner,
) -> Result<String> {
    // Step 1: Get the order hash using CTF Exchange domain
    let ctf_domain = get_domain(chain_id, verifying_contract);
    let order_hash = build_typed_data_hash(order, &ctf_domain)?;

    // Step 2: Wrap with Kernel domain (verifyingContract = predict_account, NOT global Kernel contract)
    // This matches the TS SDK: { ...kernelDomain, verifyingContract: this.predictAccount }
    let kernel_domain = get_kernel_domain(chain_id, predict_account);
    let wrapped_hash = build_kernel_wrapped_hash(order_hash, &kernel_domain);

    // Step 3: Sign using signMessage (EIP-191 personal message prefix), NOT sign_hash
    // The TS SDK does: signer.signMessage(Buffer.from(digest, "hex"))
    // which prepends "\x19Ethereum Signed Message:\n32" before signing.
    let signature = signer
        .sign_message(wrapped_hash.as_slice())
        .await
        .map_err(|e| Error::SigningError(format!("Failed to sign order for Predict Account: {}", e)))?;

    // Step 4: Prepend 0x01 + ECDSA_VALIDATOR_ADDRESS to the signature
    // Format: 0x01 (1 byte) + validator address (20 bytes) + signature (65 bytes)
    let validator_bytes = ecdsa_validator_address.as_slice();
    // alloy returns v as y_parity (0 or 1), convert to Ethereum v (27 or 28)
    let mut signature_bytes = signature.as_bytes().to_vec();
    if signature_bytes[64] < 27 {
        signature_bytes[64] += 27;
    }

    let mut full_signature = Vec::with_capacity(1 + 20 + signature_bytes.len());
    full_signature.push(0x01); // Mode byte
    full_signature.extend_from_slice(validator_bytes);
    full_signature.extend_from_slice(&signature_bytes);

    Ok(format!("0x{}", hex::encode(full_signature)))
}

/// Sign a message for Predict Smart Wallet (Kernel-based) authentication
///
/// This matches the TS SDK's `signPredictAccountMessage(message: string)` flow:
/// 1. Compute EIP-191 hash of the message: hashMessage(message)
/// 2. Wrap with Kernel EIP-712 domain (verifyingContract = predict_account)
/// 3. Sign the wrapped hash using signMessage (adds another EIP-191 prefix)
/// 4. Prepend 0x01 + ECDSA_VALIDATOR_ADDRESS
pub async fn sign_message_for_predict_account(
    message: &[u8],
    chain_id: ChainId,
    predict_account: Address,
    ecdsa_validator_address: Address,
    signer: &PrivateKeySigner,
) -> Result<String> {
    // Step 1: Compute EIP-191 hash of the message (matches ethers hashMessage)
    // hashMessage(msg) = keccak256("\x19Ethereum Signed Message:\n" + len + msg)
    let msg_len = message.len();
    let mut prefixed = Vec::new();
    prefixed.extend_from_slice(b"\x19Ethereum Signed Message:\n");
    prefixed.extend_from_slice(msg_len.to_string().as_bytes());
    prefixed.extend_from_slice(message);
    let message_hash = keccak256(&prefixed);

    // Step 2: Wrap with Kernel EIP-712 domain (verifyingContract = predict_account)
    let kernel_domain = get_kernel_domain(chain_id, predict_account);
    let wrapped_hash = build_kernel_wrapped_hash(B256::from(message_hash), &kernel_domain);

    // Step 3: Sign using signMessage (another EIP-191 prefix layer)
    let signature = signer
        .sign_message(wrapped_hash.as_slice())
        .await
        .map_err(|e| Error::SigningError(format!("Failed to sign message for Predict Account: {}", e)))?;

    // Step 4: Prepend 0x01 + ECDSA_VALIDATOR_ADDRESS
    let validator_bytes = ecdsa_validator_address.as_slice();
    let mut signature_bytes = signature.as_bytes().to_vec();
    if signature_bytes[64] < 27 {
        signature_bytes[64] += 27;
    }

    let mut full_signature = Vec::with_capacity(1 + 20 + signature_bytes.len());
    full_signature.push(0x01);
    full_signature.extend_from_slice(validator_bytes);
    full_signature.extend_from_slice(&signature_bytes);

    Ok(format!("0x{}", hex::encode(full_signature)))
}

/// Get the EIP-712 domain for predict.fun
///
/// # Arguments
///
/// * `chain_id` - The chain ID (BNB Mainnet or Testnet)
/// * `verifying_contract` - The address of the CTF Exchange contract
///
/// # Returns
///
/// The EIP-712 domain for signing orders
pub fn get_domain(chain_id: ChainId, verifying_contract: Address) -> Eip712Domain {
    Eip712Domain {
        name: Some(constants::PROTOCOL_NAME.into()),
        version: Some(constants::PROTOCOL_VERSION.into()),
        chain_id: Some(U256::from(chain_id.as_u64())),
        verifying_contract: Some(verifying_contract),
        salt: None,
    }
}

/// Convert a predict.fun OrderData to the EIP-712 Order sol struct
///
/// # Arguments
///
/// * `order` - The order to convert
///
/// # Returns
///
/// The EIP-712 Order sol struct ready for signing
///
/// # Errors
///
/// Returns an error if any field cannot be parsed
fn order_to_sol_struct(order: &OrderData) -> Result<Order> {
    Ok(Order {
        salt: order.salt.parse::<U256>()
            .map_err(|e| Error::InvalidOrderData(format!("Invalid salt: {}", e)))?,
        maker: order.maker.parse::<Address>()
            .map_err(|e| Error::InvalidOrderData(format!("Invalid maker address: {}", e)))?,
        signer: order.signer.parse::<Address>()
            .map_err(|e| Error::InvalidOrderData(format!("Invalid signer address: {}", e)))?,
        taker: order.taker.parse::<Address>()
            .map_err(|e| Error::InvalidOrderData(format!("Invalid taker address: {}", e)))?,
        tokenId: order.token_id.parse::<U256>()
            .map_err(|e| Error::InvalidOrderData(format!("Invalid token ID: {}", e)))?,
        makerAmount: order.maker_amount.parse::<U256>()
            .map_err(|e| Error::InvalidOrderData(format!("Invalid maker amount: {}", e)))?,
        takerAmount: order.taker_amount.parse::<U256>()
            .map_err(|e| Error::InvalidOrderData(format!("Invalid taker amount: {}", e)))?,
        expiration: order.expiration.parse::<U256>()
            .map_err(|e| Error::InvalidOrderData(format!("Invalid expiration: {}", e)))?,
        nonce: order.nonce.parse::<U256>()
            .map_err(|e| Error::InvalidOrderData(format!("Invalid nonce: {}", e)))?,
        feeRateBps: order.fee_rate_bps.parse::<U256>()
            .map_err(|e| Error::InvalidOrderData(format!("Invalid fee rate: {}", e)))?,
        side: order.side as u8,
        signatureType: order.signature_type as u8,
    })
}

/// Build the EIP-712 typed data hash for an order
///
/// This computes the hash that needs to be signed according to EIP-712
///
/// # Arguments
///
/// * `order` - The order to hash
/// * `domain` - The EIP-712 domain
///
/// # Returns
///
/// The hash to be signed (32 bytes)
///
/// # Errors
///
/// Returns an error if the order data is invalid
pub fn build_typed_data_hash(order: &OrderData, domain: &Eip712Domain) -> Result<B256> {
    let sol_order = order_to_sol_struct(order)?;
    Ok(sol_order.eip712_signing_hash(domain))
}

/// Sign an order using EIP-712
///
/// # Arguments
///
/// * `order` - The order to sign
/// * `chain_id` - The chain ID
/// * `verifying_contract` - The address of the CTF Exchange contract
/// * `signer` - The signer to use for signing
///
/// # Returns
///
/// The signature as a hex string (with 0x prefix)
///
/// # Errors
///
/// Returns an error if signing fails or order data is invalid
pub async fn sign_order(
    order: &OrderData,
    chain_id: ChainId,
    verifying_contract: Address,
    signer: &PrivateKeySigner,
) -> Result<String> {
    let domain = get_domain(chain_id, verifying_contract);
    let hash = build_typed_data_hash(order, &domain)?;

    let signature = signer
        .sign_hash(&hash)
        .await
        .map_err(|e| Error::SigningError(format!("Failed to sign order: {}", e)))?;

    // alloy returns v as y_parity (0 or 1), but Predict's server expects
    // Ethereum-style v (27 or 28) as used by ethers.js signTypedData.
    let mut sig_bytes = signature.as_bytes().to_vec();
    if sig_bytes[64] < 27 {
        sig_bytes[64] += 27;
    }

    Ok(format!("0x{}", hex::encode(sig_bytes)))
}

/// Compute the order hash without signing
///
/// This is useful for tracking orders and checking if they match
///
/// # Arguments
///
/// * `order` - The order to hash
/// * `chain_id` - The chain ID
/// * `verifying_contract` - The address of the CTF Exchange contract
///
/// # Returns
///
/// The order hash as a hex string (with 0x prefix)
///
/// # Errors
///
/// Returns an error if order data is invalid
#[allow(dead_code)]
pub fn get_order_hash(
    order: &OrderData,
    chain_id: ChainId,
    verifying_contract: Address,
) -> Result<String> {
    let domain = get_domain(chain_id, verifying_contract);
    let hash = build_typed_data_hash(order, &domain)?;
    Ok(format!("0x{}", hex::encode(hash.as_slice())))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::types::{Side, SignatureType};

    #[test]
    fn test_get_domain() {
        let verifying_contract: Address = "0x8BC070BEdAB741406F4B1Eb65A72bee27894B689"
            .parse()
            .unwrap();

        let domain = get_domain(ChainId::BnbMainnet, verifying_contract);

        assert_eq!(domain.name, Some(constants::PROTOCOL_NAME.into()));
        assert_eq!(domain.version, Some(constants::PROTOCOL_VERSION.into()));
        assert_eq!(domain.chain_id, Some(U256::from(56)));
        assert_eq!(domain.verifying_contract, Some(verifying_contract));
    }

    #[test]
    fn test_order_to_sol_struct() {
        let order = OrderData {
            salt: "12345".to_string(),
            maker: "0x8BC070BEdAB741406F4B1Eb65A72bee27894B689".to_string(),
            signer: "0x8BC070BEdAB741406F4B1Eb65A72bee27894B689".to_string(),
            taker: "0x0000000000000000000000000000000000000000".to_string(),
            token_id: "67890".to_string(),
            maker_amount: "1000000000000000000".to_string(),
            taker_amount: "2000000000000000000".to_string(),
            expiration: "1700000000".to_string(),
            nonce: "0".to_string(),
            fee_rate_bps: "100".to_string(),
            side: Side::Buy,
            signature_type: SignatureType::Eoa,
        };

        let sol_order = order_to_sol_struct(&order).unwrap();

        assert_eq!(sol_order.salt, U256::from(12345));
        assert_eq!(sol_order.side, 0);
        assert_eq!(sol_order.signatureType, 0);
    }

    // More comprehensive tests in integration tests
}