use crate::api_types::*;
use crate::errors::{Error, Result};
use crate::onchain::{OnchainClient, SplitOptions};
use crate::order_builder::OrderBuilder;
use crate::types::{BuildOrderInput, ChainId, LimitOrderData, OrderStrategy, Side};
use alloy::signers::local::PrivateKeySigner;
use alloy::signers::Signer;
use reqwest::Client as HttpClient;
use rust_decimal::Decimal;
use std::sync::Arc;
use tracing::{debug, info};
pub struct PredictClient {
order_builder: Arc<OrderBuilder>,
http_client: HttpClient,
api_base_url: String,
chain_id: ChainId,
api_key: Option<String>,
jwt_token: std::sync::RwLock<Option<String>>,
}
impl PredictClient {
pub fn new(
chain_id: u64,
private_key: &str,
api_base_url: String,
api_key: Option<String>,
) -> Result<Self> {
let chain_id = ChainId::try_from(chain_id)?;
let signer = Self::parse_private_key(private_key)?;
let order_builder =
OrderBuilder::new(chain_id, Some(signer), None).map_err(|e| Error::Other(e.to_string()))?;
Ok(Self {
order_builder: Arc::new(order_builder),
http_client: HttpClient::new(),
api_base_url,
chain_id,
api_key,
jwt_token: std::sync::RwLock::new(None),
})
}
pub fn new_with_predict_account(
chain_id: u64,
privy_private_key: &str,
predict_account: &str,
api_base_url: String,
api_key: Option<String>,
) -> Result<Self> {
let chain_id = ChainId::try_from(chain_id)?;
let signer = Self::parse_private_key(privy_private_key)?;
let order_builder = OrderBuilder::with_predict_account(
chain_id,
signer,
predict_account,
None,
).map_err(|e| Error::Other(e.to_string()))?;
Ok(Self {
order_builder: Arc::new(order_builder),
http_client: HttpClient::new(),
api_base_url,
chain_id,
api_key,
jwt_token: std::sync::RwLock::new(None),
})
}
pub fn new_readonly(
chain_id: u64,
api_base_url: String,
api_key: Option<String>,
) -> Result<Self> {
let chain_id = ChainId::try_from(chain_id)?;
let order_builder =
OrderBuilder::new(chain_id, None, None).map_err(|e| Error::Other(e.to_string()))?;
Ok(Self {
order_builder: Arc::new(order_builder),
http_client: HttpClient::new(),
api_base_url,
chain_id,
api_key,
jwt_token: std::sync::RwLock::new(None),
})
}
pub fn can_sign(&self) -> bool {
self.order_builder.signer_address().is_ok()
}
pub fn uses_predict_account(&self) -> bool {
self.order_builder.uses_predict_account()
}
pub fn predict_account(&self) -> Option<String> {
self.order_builder.predict_account().map(|addr| format!("{}", addr))
}
fn parse_private_key(private_key: &str) -> Result<PrivateKeySigner> {
let key = private_key.trim().trim_start_matches("0x");
let bytes = hex::decode(key)
.map_err(|e| Error::ConfigError(format!("Invalid private key format: {}", e)))?;
if bytes.len() != 32 {
return Err(Error::ConfigError("Private key must be 32 bytes".into()));
}
let mut key_bytes = [0u8; 32];
key_bytes.copy_from_slice(&bytes);
PrivateKeySigner::from_bytes(&key_bytes.into())
.map_err(|e| Error::ConfigError(format!("Failed to create signer: {}", e)))
}
fn add_auth_headers(&self, request: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
let mut request = request;
if let Some(ref api_key) = self.api_key {
request = request.header("x-api-key", api_key);
}
if let Ok(guard) = self.jwt_token.read() {
if let Some(ref jwt) = *guard {
request = request.header("Authorization", format!("Bearer {}", jwt));
}
}
request
}
pub async fn authenticate(&self) -> Result<String> {
let signer = self.order_builder.signer()
.ok_or_else(|| Error::Other("No signer configured - cannot authenticate".into()))?;
let url = format!("{}/v1/auth/message", self.api_base_url);
let request = self.add_auth_headers(self.http_client.get(&url));
let response = request.send().await?;
if !response.status().is_success() {
let error_text = response.text().await.unwrap_or_default();
return Err(Error::ApiError(format!(
"Failed to get auth message: {}", error_text
)));
}
let auth_msg: AuthMessageResponse = response.json().await?;
if !auth_msg.success {
return Err(Error::ApiError("Auth message request returned success=false".into()));
}
let message = auth_msg.data.message;
debug!("Got auth message to sign: {}", &message[..message.len().min(50)]);
let (signature_hex, signer_address) = if let Some(predict_account) = self.order_builder.predict_account() {
let ecdsa_validator = self.order_builder.addresses().ecdsa_validator
.parse::<alloy::primitives::Address>()
.map_err(|e| Error::Other(format!("Invalid ECDSA validator address: {}", e)))?;
let sig = crate::internal::signing::sign_message_for_predict_account(
message.as_bytes(),
self.chain_id,
predict_account,
ecdsa_validator,
&signer,
).await?;
(sig, format!("{}", predict_account))
} else {
let signature = signer
.sign_message(message.as_bytes())
.await
.map_err(|e| Error::SigningError(format!("Failed to sign auth message: {}", e)))?;
let mut sig_bytes = signature.as_bytes().to_vec();
if sig_bytes[64] < 27 {
sig_bytes[64] += 27;
}
(format!("0x{}", hex::encode(sig_bytes)), format!("{}", signer.address()))
};
let url = format!("{}/v1/auth", self.api_base_url);
let auth_request = AuthRequest {
signer: signer_address,
signature: signature_hex,
message,
};
let request = self.add_auth_headers(self.http_client.post(&url))
.json(&auth_request);
let response = request.send().await?;
if !response.status().is_success() {
let error_text = response.text().await.unwrap_or_default();
return Err(Error::ApiError(format!(
"Failed to authenticate: {}", error_text
)));
}
let auth_response: AuthResponse = response.json().await?;
if !auth_response.success {
return Err(Error::ApiError("Authentication returned success=false".into()));
}
info!("Successfully authenticated with Predict API");
Ok(auth_response.data.token)
}
pub async fn authenticate_and_store(&self) -> Result<String> {
let jwt = self.authenticate().await?;
if let Ok(mut guard) = self.jwt_token.write() {
*guard = Some(jwt.clone());
}
Ok(jwt)
}
pub fn jwt_token(&self) -> Option<String> {
self.jwt_token.read().ok().and_then(|guard| guard.clone())
}
pub async fn get_markets(&self) -> Result<Vec<PredictMarket>> {
let url = format!("{}/markets", self.api_base_url);
debug!("Fetching markets from: {}", url);
let response = self.http_client.get(&url).send().await?;
if !response.status().is_success() {
return Err(Error::ApiError(format!(
"Failed to fetch markets: status={}",
response.status()
)));
}
let markets: Vec<PredictMarket> = response.json().await?;
info!("Fetched {} markets from Predict", markets.len());
Ok(markets)
}
pub async fn get_orderbook(&self, market_id: &str) -> Result<PredictOrderBook> {
let url = format!("{}/markets/{}/orderbook", self.api_base_url, market_id);
debug!("Fetching orderbook from: {}", url);
let response = self.http_client.get(&url).send().await?;
if !response.status().is_success() {
return Err(Error::ApiError(format!(
"Failed to fetch orderbook for market {}: status={}",
market_id,
response.status()
)));
}
let orderbook: PredictOrderBook = response.json().await?;
Ok(orderbook)
}
pub async fn place_limit_order(
&self,
token_id: &str,
side: Side,
price: Decimal,
quantity: Decimal,
is_neg_risk: bool,
is_yield_bearing: bool,
fee_rate_bps: u64,
) -> Result<PlaceOrderResponse> {
info!(
"Placing limit order: token_id={}, side={:?}, price={}, quantity={}",
token_id, side, price, quantity
);
let amounts = self
.order_builder
.get_limit_order_amounts(LimitOrderData {
side,
price_per_share_wei: price,
quantity_wei: quantity,
})
.map_err(|e| Error::Other(format!("Failed to calculate order amounts: {}", e)))?;
let order = self
.order_builder
.build_order(
OrderStrategy::Limit,
BuildOrderInput {
side,
token_id: token_id.to_string(),
maker_amount: amounts.maker_amount.trunc().to_string(),
taker_amount: amounts.taker_amount.trunc().to_string(),
fee_rate_bps,
signer: None,
nonce: None,
salt: None,
maker: None,
taker: None,
signature_type: None,
expires_at: None,
},
)
.map_err(|e| Error::Other(format!("Failed to build order: {}", e)))?;
let verifying_contract = self.order_builder.get_verifying_contract(is_neg_risk, is_yield_bearing);
info!(
"Signing order: chain_id={:?}, is_neg_risk={}, is_yield_bearing={}, verifying_contract={}, maker={}, signer={}, uses_predict_account={}",
self.chain_id, is_neg_risk, is_yield_bearing, verifying_contract,
order.maker, order.signer, self.order_builder.uses_predict_account(),
);
let signed_order = self
.order_builder
.sign_typed_data_order(order, is_neg_risk, is_yield_bearing)
.await
.map_err(|e| Error::Other(format!("Failed to sign order: {}", e)))?;
let order_json = serde_json::to_value(&signed_order)?;
let price_per_share = amounts.price_per_share.to_string();
let request_body = CreateOrderRequest {
data: CreateOrderData {
order: order_json,
price_per_share,
strategy: "LIMIT".to_string(),
},
};
info!("Order request body: {}", serde_json::to_string(&request_body).unwrap_or_default());
let url = format!("{}/v1/orders", self.api_base_url);
let request = self.add_auth_headers(self.http_client.post(&url))
.json(&request_body);
let response = request.send().await?;
let status = response.status();
if !status.is_success() {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(Error::ApiError(format!(
"Failed to place order: status={}, error={}",
status, error_text
)));
}
let place_response: PlaceOrderResponse = response.json().await?;
if !place_response.success {
return Err(Error::ApiError("Order placement returned success=false".into()));
}
if let Some(ref data) = place_response.data {
info!(
"Order placed successfully: order_id={}, hash={}",
data.order_id, data.order_hash
);
}
Ok(place_response)
}
pub async fn cancel_orders(&self, order_ids: &[String]) -> Result<RemoveOrdersResponse> {
if order_ids.is_empty() {
return Ok(RemoveOrdersResponse {
success: true,
removed: vec![],
noop: vec![],
});
}
if order_ids.len() > 100 {
return Err(Error::Other("Cannot cancel more than 100 orders at once".into()));
}
info!("Cancelling {} orders on Predict", order_ids.len());
let request_body = RemoveOrdersRequest {
data: RemoveOrdersData {
ids: order_ids.to_vec(),
},
};
let url = format!("{}/v1/orders/remove", self.api_base_url);
let request = self.add_auth_headers(self.http_client.post(&url))
.json(&request_body);
let response = request.send().await?;
let status = response.status();
if !status.is_success() {
let error_text = response.text().await.unwrap_or_default();
return Err(Error::ApiError(format!(
"Failed to cancel orders: status={}, error={}",
status, error_text
)));
}
let cancel_response: RemoveOrdersResponse = response.json().await?;
info!(
"Cancel result: removed={}, noop={}",
cancel_response.removed.len(),
cancel_response.noop.len()
);
Ok(cancel_response)
}
pub async fn get_open_orders(&self) -> Result<Vec<PredictOrder>> {
let url = format!("{}/v1/orders?status=OPEN", self.api_base_url);
debug!("Fetching open orders from: {}", url);
let request = self.add_auth_headers(self.http_client.get(&url));
let response = request.send().await?;
let status = response.status();
if !status.is_success() {
let error_text = response.text().await.unwrap_or_default();
return Err(Error::ApiError(format!(
"Failed to fetch open orders: status={}, error={}",
status, error_text
)));
}
let body = response.text().await?;
debug!("get_open_orders raw response: {}", &body[..500.min(body.len())]);
let orders_response: GetOrdersResponse = serde_json::from_str(&body)
.map_err(|e| Error::ApiError(format!(
"Failed to parse open orders: {} | body: {}",
e, &body[..500.min(body.len())]
)))?;
if !orders_response.success {
return Err(Error::ApiError("Get orders returned success=false".into()));
}
debug!("Fetched {} open orders", orders_response.data.len());
Ok(orders_response.data)
}
pub async fn get_positions(&self) -> Result<Vec<PredictPosition>> {
let url = format!("{}/v1/positions", self.api_base_url);
debug!("Fetching positions from: {}", url);
let request = self.add_auth_headers(self.http_client.get(&url));
let response = request.send().await?;
let status = response.status();
if !status.is_success() {
let error_text = response.text().await.unwrap_or_default();
return Err(Error::ApiError(format!(
"Failed to fetch positions: status={}, error={}",
status, error_text
)));
}
let positions_response: GetPositionsResponse = response.json().await?;
if !positions_response.success {
return Err(Error::ApiError("Get positions returned success=false".into()));
}
debug!("Fetched {} positions", positions_response.data.len());
Ok(positions_response.data)
}
pub fn signer_address(&self) -> Result<String> {
self.order_builder
.signer_address()
.map(|addr| format!("{}", addr))
.map_err(|e| Error::Other(format!("Failed to get signer address: {}", e)))
}
pub fn chain_id(&self) -> ChainId {
self.chain_id
}
pub fn api_key(&self) -> Option<&str> {
self.api_key.as_deref()
}
pub fn order_builder(&self) -> &OrderBuilder {
&self.order_builder
}
pub fn api_base_url(&self) -> &str {
&self.api_base_url
}
pub async fn get_category(&self, slug: &str) -> Result<PredictCategory> {
let url = format!("{}/v1/categories/{}", self.api_base_url, slug);
debug!("Fetching category from: {}", url);
let request = self.add_auth_headers(self.http_client.get(&url));
let response = request.send().await?;
let status = response.status();
if status == reqwest::StatusCode::NOT_FOUND {
return Err(Error::ApiError(format!("Category not found: slug={}", slug)));
}
if !status.is_success() {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(Error::ApiError(format!(
"Failed to fetch category {}: status={}, error={}",
slug, status, error_text
)));
}
let wrapper: CategoryResponse = response.json().await?;
debug!(
"Fetched category '{}' with {} markets",
wrapper.data.slug,
wrapper.data.markets.len()
);
Ok(wrapper.data)
}
pub async fn get_category_optional(&self, slug: &str) -> Result<Option<PredictCategory>> {
match self.get_category(slug).await {
Ok(category) => Ok(Some(category)),
Err(Error::ApiError(msg)) if msg.contains("not found") => Ok(None),
Err(e) => Err(e),
}
}
pub async fn set_approvals(
&self,
is_neg_risk: bool,
is_yield_bearing: bool,
) -> Result<()> {
let signer = self
.order_builder
.signer()
.ok_or_else(|| Error::Other("No signer configured - cannot set approvals".into()))?;
let onchain_client = if let Some(predict_account) = self.order_builder.predict_account() {
OnchainClient::with_predict_account(
self.chain_id,
signer,
&format!("{}", predict_account),
)?
} else {
OnchainClient::new(self.chain_id, signer)
};
onchain_client.set_approvals(is_neg_risk, is_yield_bearing).await
}
pub async fn split_positions(
&self,
condition_id: &str,
amount: f64,
is_neg_risk: bool,
is_yield_bearing: bool,
) -> Result<String> {
let signer = self
.order_builder
.signer()
.ok_or_else(|| Error::Other("No signer configured - cannot perform on-chain operations".into()))?;
let onchain_client = if let Some(predict_account) = self.order_builder.predict_account() {
OnchainClient::with_predict_account(
self.chain_id,
signer,
&format!("{}", predict_account),
)?
} else {
OnchainClient::new(self.chain_id, signer)
};
let options = SplitOptions {
condition_id: condition_id.to_string(),
amount,
is_neg_risk,
is_yield_bearing,
};
onchain_client.split_positions(options).await
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_private_key() {
let key_with_prefix =
"0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef";
let result = PredictClient::parse_private_key(key_with_prefix);
assert!(result.is_ok());
let key_without_prefix =
"1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef";
let result = PredictClient::parse_private_key(key_without_prefix);
assert!(result.is_ok());
let invalid_key = "invalid";
let result = PredictClient::parse_private_key(invalid_key);
assert!(result.is_err());
}
}