use std::time::{SystemTime, UNIX_EPOCH};
use alloy_primitives::{Signature, U256};
use predict_fun_sdk::api::*;
use predict_fun_sdk::execution::*;
use predict_fun_sdk::order::*;
use predict_fun_sdk::ws::*;
const DEFAULT_API_KEY: &str = "7fdc1c33-7bde-46c5-8308-2ce86507e27f";
const DEFAULT_PRIVATE_KEY: &str =
"0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
fn api_key() -> String {
std::env::var("PREDICT_API_KEY").unwrap_or_else(|_| DEFAULT_API_KEY.into())
}
fn private_key() -> String {
std::env::var("PREDICT_TEST_PRIVATE_KEY").unwrap_or_else(|_| DEFAULT_PRIVATE_KEY.into())
}
fn unix_now() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as i64
}
mod rest_tests {
use super::*;
#[tokio::test]
async fn auth_flow_returns_jwt() {
let client = PredictApiClient::new_mainnet(&api_key()).unwrap();
let signer =
PredictOrderSigner::from_private_key(&private_key(), BNB_MAINNET_CHAIN_ID).unwrap();
let auth_msg = client.auth_message().await.unwrap();
let message = auth_msg["data"]["message"].as_str().unwrap();
assert!(!message.is_empty(), "Auth message should not be empty");
let signature = signer.sign_auth_message(message).unwrap();
let auth = client
.auth(&signer.address().to_string(), message, &signature)
.await
.unwrap();
let token = auth["data"]["token"].as_str().unwrap();
assert!(!token.is_empty(), "JWT token should not be empty");
}
#[tokio::test]
async fn search_returns_markets() {
let client = PredictApiClient::new_mainnet(&api_key()).unwrap();
let search = client
.search(&[("query", "btc".to_string())])
.await
.unwrap();
let markets = search["data"]["markets"].as_array().unwrap();
assert!(!markets.is_empty(), "Search should return BTC markets");
let first = &markets[0];
assert!(first["id"].as_i64().is_some(), "Market should have an id");
}
#[tokio::test]
async fn list_markets_returns_data() {
let client = PredictApiClient::new_mainnet(&api_key()).unwrap();
let markets = client
.list_markets(&[("limit", "5".to_string())])
.await
.unwrap();
let data = markets["data"].as_array().unwrap();
assert!(!data.is_empty(), "Should return at least one market");
}
#[tokio::test]
async fn get_market_by_id() {
let client = PredictApiClient::new_mainnet(&api_key()).unwrap();
let search = client
.search(&[("query", "btc".to_string())])
.await
.unwrap();
let market_id = search["data"]["markets"][0]["id"].as_i64().unwrap();
let market = client.get_market(market_id).await.unwrap();
let data = &market["data"];
assert_eq!(data["id"].as_i64().unwrap(), market_id);
assert!(data.get("outcomes").is_some(), "Market should have outcomes");
}
#[tokio::test]
async fn get_market_orderbook() {
let client = PredictApiClient::new_mainnet(&api_key()).unwrap();
let search = client
.search(&[("query", "btc".to_string())])
.await
.unwrap();
let market_id = search["data"]["markets"][0]["id"].as_i64().unwrap();
let ob = client.get_market_orderbook(market_id).await.unwrap();
let data = &ob["data"];
assert!(data.get("asks").is_some(), "Should have asks");
assert!(data.get("bids").is_some(), "Should have bids");
}
#[tokio::test]
async fn get_market_stats() {
let client = PredictApiClient::new_mainnet(&api_key()).unwrap();
let search = client
.search(&[("query", "btc".to_string())])
.await
.unwrap();
let market_id = search["data"]["markets"][0]["id"].as_i64().unwrap();
let stats = client.get_market_stats(market_id).await.unwrap();
assert!(stats.get("data").is_some(), "Should have stats data");
}
#[tokio::test]
async fn get_market_timeseries() {
let client = PredictApiClient::new_mainnet(&api_key()).unwrap();
let search = client
.search(&[("query", "btc".to_string())])
.await
.unwrap();
let market_id = search["data"]["markets"][0]["id"].as_i64().unwrap();
let from = unix_now() - 24 * 3600;
let ts = client
.get_market_timeseries(
market_id,
&[
("metric", "chance".to_string()),
("from", from.to_string()),
("resolution", "1h".to_string()),
],
)
.await
.unwrap();
assert!(ts.get("data").is_some(), "Should have timeseries data");
}
#[tokio::test]
async fn list_categories_and_tags() {
let client = PredictApiClient::new_mainnet(&api_key()).unwrap();
let cats = client.list_categories(&[]).await.unwrap();
let cat_data = cats["data"].as_array().unwrap();
assert!(!cat_data.is_empty(), "Should have categories");
let tags = client.list_tags().await.unwrap();
let tag_data = tags["data"].as_array().unwrap();
assert!(!tag_data.is_empty(), "Should have tags");
}
#[tokio::test]
async fn jwt_gated_endpoints_work_with_auth() {
let client = PredictApiClient::new_mainnet(&api_key()).unwrap();
let signer =
PredictOrderSigner::from_private_key(&private_key(), BNB_MAINNET_CHAIN_ID).unwrap();
let auth_msg = client.auth_message().await.unwrap();
let message = auth_msg["data"]["message"].as_str().unwrap();
let sig = signer.sign_auth_message(message).unwrap();
let auth = client
.auth(&signer.address().to_string(), message, &sig)
.await
.unwrap();
let jwt = auth["data"]["token"].as_str().unwrap();
let authed = PredictApiClient::new_mainnet(&api_key())
.unwrap()
.with_jwt(jwt);
let account = authed.account().await.unwrap();
assert!(
account["data"].get("address").is_some(),
"Account should have address"
);
let orders = authed
.list_orders(&[("first", "5".to_string())])
.await
.unwrap();
assert!(orders.get("data").is_some(), "Should have orders data");
let positions = authed
.list_positions(&[("first", "5".to_string())])
.await
.unwrap();
assert!(
positions.get("data").is_some(),
"Should have positions data"
);
let activity = authed
.account_activity(&[("first", "5".to_string())])
.await
.unwrap();
assert!(activity.get("data").is_some(), "Should have activity data");
}
}
mod signing_tests {
use super::*;
#[tokio::test]
async fn sign_order_for_live_market() {
let client = PredictApiClient::new_mainnet(&api_key()).unwrap();
let signer =
PredictOrderSigner::from_private_key(&private_key(), BNB_MAINNET_CHAIN_ID).unwrap();
let addr = signer.address();
let search = client
.search(&[("query", "btc".to_string())])
.await
.unwrap();
let market_id = search["data"]["markets"][0]["id"].as_i64().unwrap();
let market = client.get_market(market_id).await.unwrap();
let data = &market["data"];
let is_neg_risk = data["isNegRisk"].as_bool().unwrap_or(false);
let is_yield_bearing = data["isYieldBearing"].as_bool().unwrap_or(true);
let fee_rate_bps = data["feeRateBps"].as_u64().unwrap_or(0) as u32;
let outcomes = data["outcomes"].as_array().unwrap();
let yes_token = outcomes
.iter()
.find(|o| o["indexSet"].as_u64() == Some(1))
.and_then(|o| o["onChainId"].as_str())
.unwrap();
let price_wei = U256::from(400_000_000_000_000_000u128); let qty_wei = U256::from(10_000_000_000_000_000_000u128);
let (maker_amount, taker_amount) =
predict_limit_order_amounts(PredictSide::Buy, price_wei, qty_wei);
let order = PredictOrder::new_limit(
addr,
addr,
yes_token,
PredictSide::Buy,
maker_amount,
taker_amount,
fee_rate_bps,
);
let signed = signer
.sign_order(&order, is_neg_risk, is_yield_bearing)
.unwrap();
assert!(signed.hash.starts_with("0x"));
assert!(signed.hash.len() > 10);
let hash = signer
.order_hash(&order, is_neg_risk, is_yield_bearing)
.unwrap();
let sig: Signature = signed.signature.parse().unwrap();
let recovered = sig.recover_address_from_prehash(&hash).unwrap();
assert_eq!(recovered, addr, "Signature recovery should match signer");
let req = signed.to_create_order_request(
price_wei,
PredictStrategy::Limit,
None,
Some(true),
);
let json = serde_json::to_value(&req).unwrap();
let data = &json["data"];
assert_eq!(data["strategy"].as_str().unwrap(), "LIMIT");
assert!(data.get("order").is_some());
assert!(data["order"].get("hash").is_some());
assert!(data["order"].get("signature").is_some());
}
#[test]
fn buy_sell_amounts_symmetric_across_prices() {
for pct in [5, 10, 25, 33, 50, 67, 75, 90, 95] {
let price = U256::from(pct as u128) * U256::from(10_000_000_000_000_000u128);
let qty = U256::from(10_000_000_000_000_000_000u128);
let (buy_m, buy_t) = predict_limit_order_amounts(PredictSide::Buy, price, qty);
let (sell_m, sell_t) = predict_limit_order_amounts(PredictSide::Sell, price, qty);
assert_eq!(buy_m, sell_t, "BUY maker == SELL taker at {}%", pct);
assert_eq!(buy_t, sell_m, "BUY taker == SELL maker at {}%", pct);
}
}
#[test]
fn all_eight_exchange_addresses_unique() {
let mut addrs = std::collections::HashSet::new();
for chain in [BNB_MAINNET_CHAIN_ID, BNB_TESTNET_CHAIN_ID] {
for neg in [false, true] {
for yld in [false, true] {
let addr = predict_exchange_address(chain, neg, yld).unwrap();
assert!(addrs.insert(addr), "Duplicate at chain={} neg={} yld={}", chain, neg, yld);
}
}
}
assert_eq!(addrs.len(), 8);
}
}
mod execution_tests {
use super::*;
#[tokio::test]
async fn execution_client_authenticates_and_prepares_order() {
let config = PredictExecConfig {
api_key: api_key(),
private_key: private_key(),
chain_id: BNB_MAINNET_CHAIN_ID,
live_execution: false,
fill_or_kill: true,
};
let exec = PredictExecutionClient::new(config).await.unwrap();
let search = exec
.api
.search(&[("query", "btc".to_string())])
.await
.unwrap();
let market_id = search["data"]["markets"][0]["id"].as_i64().unwrap();
let result = exec
.place_limit_order(&PredictLimitOrderRequest {
market_id,
outcome: PredictOutcome::Yes,
side: PredictSide::Buy,
price_per_share: 0.10, quantity: 1.0,
strategy: PredictStrategy::Limit,
slippage_bps: None,
})
.await
.unwrap();
assert!(!result.submitted, "Should be dry-run (not submitted)");
assert!(result.response.is_none(), "Dry-run should have no response");
assert!(
!result.prepared.signed_order.hash.is_empty(),
"Should have order hash"
);
}
#[tokio::test]
async fn execution_client_sell_order() {
let config = PredictExecConfig {
api_key: api_key(),
private_key: private_key(),
chain_id: BNB_MAINNET_CHAIN_ID,
live_execution: false,
fill_or_kill: true,
};
let exec = PredictExecutionClient::new(config).await.unwrap();
let search = exec
.api
.search(&[("query", "btc".to_string())])
.await
.unwrap();
let market_id = search["data"]["markets"][0]["id"].as_i64().unwrap();
let result = exec
.place_limit_order(&PredictLimitOrderRequest {
market_id,
outcome: PredictOutcome::No,
side: PredictSide::Sell,
price_per_share: 0.90,
quantity: 1.0,
strategy: PredictStrategy::Limit,
slippage_bps: None,
})
.await
.unwrap();
assert!(!result.submitted);
assert!(!result.prepared.signed_order.hash.is_empty());
}
#[tokio::test]
async fn execution_client_market_order_with_slippage() {
let config = PredictExecConfig {
api_key: api_key(),
private_key: private_key(),
chain_id: BNB_MAINNET_CHAIN_ID,
live_execution: false,
fill_or_kill: false,
};
let exec = PredictExecutionClient::new(config).await.unwrap();
let search = exec
.api
.search(&[("query", "btc".to_string())])
.await
.unwrap();
let market_id = search["data"]["markets"][0]["id"].as_i64().unwrap();
let result = exec
.place_limit_order(&PredictLimitOrderRequest {
market_id,
outcome: PredictOutcome::Yes,
side: PredictSide::Buy,
price_per_share: 0.50,
quantity: 5.0,
strategy: PredictStrategy::Market,
slippage_bps: Some(200), })
.await
.unwrap();
assert!(!result.submitted);
let json = serde_json::to_value(&result.prepared.request).unwrap();
assert_eq!(json["data"]["strategy"].as_str().unwrap(), "MARKET");
assert_eq!(json["data"]["slippageBps"].as_str().unwrap(), "200");
}
#[tokio::test]
async fn remove_orders_dry_run() {
let config = PredictExecConfig {
api_key: api_key(),
private_key: private_key(),
chain_id: BNB_MAINNET_CHAIN_ID,
live_execution: false,
fill_or_kill: true,
};
let exec = PredictExecutionClient::new(config).await.unwrap();
let resp = exec
.remove_order_ids(&["fake-order-id".to_string()])
.await
.unwrap();
let is_dry = resp
.json
.as_ref()
.and_then(|j| j.get("dryRun"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
assert!(is_dry, "Should be a dry-run response");
}
}
mod ws_tests {
use super::*;
use tokio::time::{timeout, Duration};
#[tokio::test]
async fn connect_and_subscribe_orderbook() {
let (client, mut rx) = PredictWsClient::connect_mainnet().await.unwrap();
let api = PredictApiClient::new_mainnet(&api_key()).unwrap();
let search = api
.search(&[("query", "btc".to_string())])
.await
.unwrap();
let market_id = search["data"]["markets"][0]["id"].as_i64().unwrap();
client
.subscribe(Topic::Orderbook { market_id })
.await
.unwrap();
let msg = timeout(Duration::from_secs(10), rx.recv())
.await
.expect("Timeout waiting for orderbook")
.expect("Channel closed");
match msg {
PredictWsMessage::Orderbook(ob) => {
assert_eq!(ob.market_id, market_id);
assert!(
!ob.bids.is_empty() || !ob.asks.is_empty(),
"Orderbook should have at least some levels"
);
assert!(ob.version > 0, "Version should be positive");
}
other => panic!("Expected Orderbook message, got {:?}", other),
}
}
#[tokio::test]
async fn subscribe_asset_price_btc() {
let (client, mut rx) = PredictWsClient::connect_mainnet().await.unwrap();
client
.subscribe(Topic::AssetPrice {
feed_id: feeds::BTC,
})
.await
.unwrap();
let msg = timeout(Duration::from_secs(10), rx.recv())
.await
.expect("Timeout waiting for BTC price")
.expect("Channel closed");
match msg {
PredictWsMessage::AssetPrice(p) => {
assert_eq!(p.feed_id, feeds::BTC);
assert!(p.price > 1000.0, "BTC price should be > $1,000");
assert!(p.price < 1_000_000.0, "BTC price should be < $1M");
assert!(p.timestamp > 0, "Timestamp should be positive");
}
other => panic!("Expected AssetPrice message, got {:?}", other),
}
}
#[tokio::test]
async fn subscribe_asset_price_eth() {
let (client, mut rx) = PredictWsClient::connect_mainnet().await.unwrap();
client
.subscribe(Topic::AssetPrice {
feed_id: feeds::ETH,
})
.await
.unwrap();
let msg = timeout(Duration::from_secs(10), rx.recv())
.await
.expect("Timeout waiting for ETH price")
.expect("Channel closed");
match msg {
PredictWsMessage::AssetPrice(p) => {
assert_eq!(p.feed_id, feeds::ETH);
assert!(p.price > 10.0, "ETH price should be > $10");
assert!(p.price < 100_000.0, "ETH price should be < $100K");
}
other => panic!("Expected AssetPrice message, got {:?}", other),
}
}
#[tokio::test]
async fn subscribe_multiple_topics() {
let (client, mut rx) = PredictWsClient::connect_mainnet().await.unwrap();
client
.subscribe(Topic::AssetPrice {
feed_id: feeds::BTC,
})
.await
.unwrap();
client
.subscribe(Topic::AssetPrice {
feed_id: feeds::ETH,
})
.await
.unwrap();
let topics = client.active_topics().await;
assert_eq!(topics.len(), 2, "Should have 2 active topics");
let mut count = 0;
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
while count < 3 && tokio::time::Instant::now() < deadline {
if let Ok(Some(msg)) = timeout(Duration::from_secs(5), rx.recv()).await {
if matches!(msg, PredictWsMessage::AssetPrice(_)) {
count += 1;
}
}
}
assert!(count >= 3, "Should receive at least 3 price updates");
}
#[tokio::test]
async fn subscribe_polymarket_chance() {
let (client, _rx) = PredictWsClient::connect_mainnet().await.unwrap();
let result = client
.subscribe(Topic::PolymarketChance { market_id: 7731 })
.await;
assert!(result.is_ok(), "polymarketChance subscription should succeed");
}
#[tokio::test]
async fn subscribe_kalshi_chance() {
let (client, _rx) = PredictWsClient::connect_mainnet().await.unwrap();
let result = client
.subscribe(Topic::KalshiChance { market_id: 7731 })
.await;
assert!(result.is_ok(), "kalshiChance subscription should succeed");
}
#[tokio::test]
async fn wallet_events_requires_auth() {
let (client, _rx) = PredictWsClient::connect_mainnet().await.unwrap();
let result = client
.subscribe(Topic::WalletEvents {
jwt: "invalid-token".to_string(),
})
.await;
assert!(result.is_err(), "WalletEvents with bad JWT should fail");
let err = result.unwrap_err().to_string();
assert!(
err.contains("authorization") || err.contains("auth") || err.contains("failed"),
"Error should mention auth: {}",
err
);
}
#[tokio::test]
async fn orderbook_snapshot_has_helper_methods() {
let (client, mut rx) = PredictWsClient::connect_mainnet().await.unwrap();
let api = PredictApiClient::new_mainnet(&api_key()).unwrap();
let search = api
.search(&[("query", "btc".to_string())])
.await
.unwrap();
let markets = search["data"]["markets"].as_array().unwrap();
let mut found_ob = false;
for m in markets.iter().take(5) {
let market_id = m["id"].as_i64().unwrap();
client
.subscribe(Topic::Orderbook { market_id })
.await
.unwrap();
if let Ok(Some(PredictWsMessage::Orderbook(ob))) =
timeout(Duration::from_secs(5), rx.recv()).await
{
if ob.best_bid().is_some() && ob.best_ask().is_some() {
let mid = ob.mid().unwrap();
let spread = ob.spread().unwrap();
assert!(mid > 0.0 && mid < 1.0, "Mid should be in (0, 1)");
assert!(spread >= 0.0, "Spread should be non-negative");
assert!(spread < 1.0, "Spread should be < 1.0");
found_ob = true;
break;
}
}
}
assert!(found_ob, "Should find at least one market with bid+ask");
}
}
#[tokio::test]
async fn full_pipeline_rest_ws_signing() {
let api = PredictApiClient::new_mainnet(&api_key()).unwrap();
let search = api
.search(&[("query", "btc".to_string())])
.await
.unwrap();
let market_id = search["data"]["markets"][0]["id"].as_i64().unwrap();
assert!(market_id > 0, "Should find a BTC market");
let market = api.get_market(market_id).await.unwrap();
let outcomes = market["data"]["outcomes"].as_array().unwrap();
assert!(!outcomes.is_empty(), "Market should have outcomes");
let ob = api.get_market_orderbook(market_id).await.unwrap();
assert!(ob["data"].get("asks").is_some());
let (ws, mut rx) = PredictWsClient::connect_mainnet().await.unwrap();
ws.subscribe(Topic::Orderbook { market_id }).await.unwrap();
ws.subscribe(Topic::AssetPrice {
feed_id: feeds::BTC,
})
.await
.unwrap();
let msg = tokio::time::timeout(std::time::Duration::from_secs(10), rx.recv())
.await
.expect("Timeout")
.expect("Channel closed");
assert!(
matches!(
msg,
PredictWsMessage::Orderbook(_) | PredictWsMessage::AssetPrice(_)
),
"Should receive OB or price update"
);
let signer =
PredictOrderSigner::from_private_key(&private_key(), BNB_MAINNET_CHAIN_ID).unwrap();
let addr = signer.address();
let is_neg_risk = market["data"]["isNegRisk"].as_bool().unwrap_or(false);
let is_yield_bearing = market["data"]["isYieldBearing"].as_bool().unwrap_or(true);
let fee_bps = market["data"]["feeRateBps"].as_u64().unwrap_or(0) as u32;
let yes_token = outcomes
.iter()
.find(|o| o["indexSet"].as_u64() == Some(1))
.and_then(|o| o["onChainId"].as_str())
.unwrap();
let price = U256::from(100_000_000_000_000_000u128); let qty = U256::from(1_000_000_000_000_000_000u128); let (maker_amount, taker_amount) =
predict_limit_order_amounts(PredictSide::Buy, price, qty);
let order = PredictOrder::new_limit(
addr, addr, yes_token, PredictSide::Buy, maker_amount, taker_amount, fee_bps,
);
let signed = signer.sign_order(&order, is_neg_risk, is_yield_bearing).unwrap();
let sig: Signature = signed.signature.parse().unwrap();
let hash = signer.order_hash(&order, is_neg_risk, is_yield_bearing).unwrap();
let recovered = sig.recover_address_from_prehash(&hash).unwrap();
assert_eq!(recovered, addr, "Full pipeline: signature recovery must match");
let exec = PredictExecutionClient::new(PredictExecConfig {
api_key: api_key(),
private_key: private_key(),
chain_id: BNB_MAINNET_CHAIN_ID,
live_execution: false,
fill_or_kill: true,
})
.await
.unwrap();
let result = exec
.place_limit_order(&PredictLimitOrderRequest {
market_id,
outcome: PredictOutcome::Yes,
side: PredictSide::Buy,
price_per_share: 0.10,
quantity: 1.0,
strategy: PredictStrategy::Limit,
slippage_bps: None,
})
.await
.unwrap();
assert!(!result.submitted, "Should be dry-run");
assert!(
!result.prepared.signed_order.hash.is_empty(),
"Should have signed hash"
);
}
mod cache_tests {
use super::*;
#[tokio::test]
async fn market_meta_caches_on_second_call() {
let exec = make_exec_client().await;
let market_id = find_btc_market_id(&exec.api).await;
let meta1 = exec.market_meta(market_id).await.unwrap();
assert_eq!(meta1.market_id, market_id);
assert!(!meta1.yes_token_id.is_empty());
assert!(!meta1.no_token_id.is_empty());
assert_ne!(meta1.yes_token_id, meta1.no_token_id);
let meta2 = exec.market_meta(market_id).await.unwrap();
assert_eq!(meta1.yes_token_id, meta2.yes_token_id);
assert_eq!(meta1.no_token_id, meta2.no_token_id);
assert_eq!(meta1.fee_rate_bps, meta2.fee_rate_bps);
}
#[tokio::test]
async fn preload_markets_parallel() {
let exec = make_exec_client().await;
let search = exec
.api
.search(&[("query", "btc".to_string())])
.await
.unwrap();
let markets = search["data"]["markets"].as_array().unwrap();
let ids: Vec<i64> = markets.iter().take(3).filter_map(|m| m["id"].as_i64()).collect();
assert!(ids.len() >= 2, "Need at least 2 markets to test preload");
exec.preload_markets(&ids).await.unwrap();
for &id in &ids {
let meta = exec.market_meta(id).await.unwrap();
assert_eq!(meta.market_id, id);
}
}
#[tokio::test]
async fn prepare_with_meta_zero_network() {
let exec = make_exec_client().await;
let market_id = find_btc_market_id(&exec.api).await;
let meta = exec.market_meta(market_id).await.unwrap();
let req = PredictLimitOrderRequest {
market_id,
outcome: PredictOutcome::Yes,
side: PredictSide::Buy,
price_per_share: 0.10,
quantity: 1.0,
strategy: PredictStrategy::Limit,
slippage_bps: None,
};
let prepared = exec.prepare_limit_order_with_meta(&req, &meta).unwrap();
assert!(!prepared.signed_order.hash.is_empty());
assert_eq!(prepared.is_neg_risk, meta.is_neg_risk);
}
#[tokio::test]
async fn refresh_market_meta_overwrites_cache() {
let exec = make_exec_client().await;
let market_id = find_btc_market_id(&exec.api).await;
let meta1 = exec.market_meta(market_id).await.unwrap();
let meta2 = exec.refresh_market_meta(market_id).await.unwrap();
assert_eq!(meta1.yes_token_id, meta2.yes_token_id);
assert_eq!(meta1.fee_rate_bps, meta2.fee_rate_bps);
}
#[tokio::test]
async fn clear_cache_forces_refetch() {
let exec = make_exec_client().await;
let market_id = find_btc_market_id(&exec.api).await;
exec.market_meta(market_id).await.unwrap();
exec.clear_cache().await;
let meta = exec.market_meta(market_id).await.unwrap();
assert_eq!(meta.market_id, market_id);
}
}
mod edge_case_tests {
use super::*;
#[tokio::test]
async fn invalid_market_id_returns_error() {
let exec = make_exec_client().await;
let result = exec.market_meta(999999999).await;
assert!(result.is_err(), "Nonexistent market should error");
}
#[test]
fn wei_from_zero_errors() {
let req = PredictLimitOrderRequest {
market_id: 1,
outcome: PredictOutcome::Yes,
side: PredictSide::Buy,
price_per_share: 0.0,
quantity: 1.0,
strategy: PredictStrategy::Limit,
slippage_bps: None,
};
let exec_config = PredictExecConfig {
api_key: "test".into(),
private_key: private_key(),
chain_id: BNB_MAINNET_CHAIN_ID,
live_execution: false,
fill_or_kill: true,
};
let signer =
PredictOrderSigner::from_private_key(&exec_config.private_key, BNB_MAINNET_CHAIN_ID)
.unwrap();
let meta = MarketMeta {
market_id: 1,
yes_token_id: "123".into(),
no_token_id: "456".into(),
fee_rate_bps: 200,
is_neg_risk: false,
is_yield_bearing: true,
};
let api = PredictApiClient::new_mainnet("test").unwrap();
let exec = PredictExecutionClient {
api,
signer,
config: exec_config,
market_cache: std::sync::Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())),
};
let result = exec.prepare_limit_order_with_meta(&req, &meta);
assert!(result.is_err(), "price=0 should error");
}
#[test]
fn negative_price_errors() {
let signer =
PredictOrderSigner::from_private_key(&private_key(), BNB_MAINNET_CHAIN_ID).unwrap();
let api = PredictApiClient::new_mainnet("test").unwrap();
let exec = PredictExecutionClient {
api,
signer,
config: PredictExecConfig {
api_key: "test".into(),
private_key: private_key(),
chain_id: BNB_MAINNET_CHAIN_ID,
live_execution: false,
fill_or_kill: true,
},
market_cache: std::sync::Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())),
};
let meta = MarketMeta {
market_id: 1,
yes_token_id: "123".into(),
no_token_id: "456".into(),
fee_rate_bps: 0,
is_neg_risk: false,
is_yield_bearing: false,
};
let result = exec.prepare_limit_order_with_meta(
&PredictLimitOrderRequest {
market_id: 1,
outcome: PredictOutcome::No,
side: PredictSide::Sell,
price_per_share: -0.5,
quantity: 1.0,
strategy: PredictStrategy::Limit,
slippage_bps: None,
},
&meta,
);
assert!(result.is_err(), "negative price should error");
}
#[test]
fn invalid_private_key_errors() {
let result = PredictOrderSigner::from_private_key("not-a-key", BNB_MAINNET_CHAIN_ID);
assert!(result.is_err());
}
#[test]
fn unsupported_chain_id_errors() {
let result = predict_exchange_address(999, false, false);
assert!(result.is_err());
}
#[test]
fn order_amounts_zero_quantity() {
let price = U256::from(500_000_000_000_000_000u128); let qty = U256::ZERO;
let (m, t) = predict_limit_order_amounts(PredictSide::Buy, price, qty);
assert_eq!(m, U256::ZERO);
assert_eq!(t, U256::ZERO);
}
#[test]
fn order_amounts_extreme_prices() {
let qty = U256::from(1_000_000_000_000_000_000u128);
let (m, _t) = predict_limit_order_amounts(
PredictSide::Buy,
U256::from(10_000_000_000_000_000u128),
qty,
);
assert_eq!(m, U256::from(10_000_000_000_000_000u128));
let (m, _t) = predict_limit_order_amounts(
PredictSide::Buy,
U256::from(990_000_000_000_000_000u128),
qty,
);
assert_eq!(m, U256::from(990_000_000_000_000_000u128));
}
#[test]
fn topic_parse_invalid_ids() {
let t = Topic::from_topic_string("predictOrderbook/abc");
assert!(matches!(t, Topic::Raw(_)));
let t = Topic::from_topic_string("assetPriceUpdate/");
assert!(matches!(t, Topic::Raw(_)));
let t = Topic::from_topic_string("");
assert!(matches!(t, Topic::Raw(_)));
}
#[test]
fn orderbook_snapshot_single_side() {
let ob = OrderbookSnapshot {
market_id: 1,
bids: vec![(0.45, 100.0)],
asks: vec![],
version: 1,
update_timestamp_ms: 0,
order_count: 1,
last_order_settled: None,
};
assert_eq!(ob.best_bid(), Some(0.45));
assert_eq!(ob.best_ask(), None);
assert_eq!(ob.mid(), None); assert_eq!(ob.spread(), None);
}
#[test]
fn market_meta_outcome_lookup() {
let meta = MarketMeta {
market_id: 42,
yes_token_id: "token_yes".into(),
no_token_id: "token_no".into(),
fee_rate_bps: 150,
is_neg_risk: true,
is_yield_bearing: false,
};
assert_eq!(meta.token_id(PredictOutcome::Yes), "token_yes");
assert_eq!(meta.token_id(PredictOutcome::No), "token_no");
}
}
mod ws_edge_tests {
use super::*;
use tokio::time::{timeout, Duration};
#[tokio::test]
async fn unsubscribe_removes_topic() {
let (client, _rx) = PredictWsClient::connect_mainnet().await.unwrap();
client
.subscribe(Topic::AssetPrice {
feed_id: feeds::BTC,
})
.await
.unwrap();
let topics = client.active_topics().await;
assert_eq!(topics.len(), 1);
client
.unsubscribe(Topic::AssetPrice {
feed_id: feeds::BTC,
})
.await
.unwrap();
let topics = client.active_topics().await;
assert_eq!(topics.len(), 0, "Unsubscribe should remove topic");
}
#[tokio::test]
async fn subscribe_same_topic_twice_idempotent() {
let (client, _rx) = PredictWsClient::connect_mainnet().await.unwrap();
client
.subscribe(Topic::AssetPrice {
feed_id: feeds::ETH,
})
.await
.unwrap();
client
.subscribe(Topic::AssetPrice {
feed_id: feeds::ETH,
})
.await
.unwrap();
let topics = client.active_topics().await;
assert_eq!(topics.len(), 1, "Duplicate subscribe should not add twice");
}
#[tokio::test]
async fn raw_topic_subscribe() {
let (client, _rx) = PredictWsClient::connect_mainnet().await.unwrap();
let result = client.subscribe(Topic::Raw("nonexistent/123".to_string())).await;
let _ = result;
}
#[tokio::test]
async fn multiple_orderbook_markets() {
let (client, mut rx) = PredictWsClient::connect_mainnet().await.unwrap();
let api = PredictApiClient::new_mainnet(&api_key()).unwrap();
let search = api
.search(&[("query", "btc".to_string())])
.await
.unwrap();
let markets = search["data"]["markets"].as_array().unwrap();
let ids: Vec<i64> = markets.iter().take(2).filter_map(|m| m["id"].as_i64()).collect();
for &id in &ids {
client.subscribe(Topic::Orderbook { market_id: id }).await.unwrap();
}
let topics = client.active_topics().await;
assert_eq!(topics.len(), ids.len());
let msg = timeout(Duration::from_secs(10), rx.recv())
.await
.expect("Timeout")
.expect("Channel closed");
assert!(matches!(msg, PredictWsMessage::Orderbook(_)));
}
}
#[tokio::test]
async fn api_uses_http2() {
let client = PredictApiClient::new_mainnet(&api_key()).unwrap();
let result = client.list_tags().await;
assert!(result.is_ok(), "HTTP/2 request should succeed");
}
async fn make_exec_client() -> PredictExecutionClient {
PredictExecutionClient::new(PredictExecConfig {
api_key: api_key(),
private_key: private_key(),
chain_id: BNB_MAINNET_CHAIN_ID,
live_execution: false,
fill_or_kill: true,
})
.await
.unwrap()
}
async fn find_btc_market_id(api: &PredictApiClient) -> i64 {
let search = api
.search(&[("query", "btc".to_string())])
.await
.unwrap();
search["data"]["markets"][0]["id"].as_i64().unwrap()
}