pub mod constants;
pub mod constants_v2;
pub mod types;
pub mod signer;
pub mod onboarding;
pub mod cosigner;
pub mod sqlite_backend;
pub mod position_management;
pub mod escrow;
#[cfg(feature = "privy")]
pub mod privy;
use crate::error::{Error, Result};
use self::constants::{CLOB_HOST, CHAIN_ID, CTF_EXCHANGE, NEG_RISK_CTF_EXCHANGE};
use self::constants_v2::{V2_CLOB_HOST, V2_CTF_EXCHANGE, V2_NEG_RISK_EXCHANGE_A, V2_DOMAIN_VERSION, V2_DOMAIN_NAME};
use self::cosigner::{send_via_cosigner, CosignerConfig};
use self::onboarding::create_clob_credentials;
use self::sqlite_backend::OrderHistoryInsert;
pub use self::types::*;
pub use self::signer::{TradingSigner, PrivateKeySigner};
pub use self::onboarding::{
derive_safe_address, derive_proxy_address, derive_funder_address,
detect_wallet_type, is_safe_deployed,
};
pub use self::cosigner::build_l2_headers;
pub use self::sqlite_backend::TradingSqliteBackend;
pub use alloy_primitives::Address;
pub use async_trait::async_trait;
#[cfg(feature = "privy")]
pub use self::privy::{PrivyConfig, PrivySigner};
pub struct PolyNodeTrader {
config: TraderConfig,
db: Option<TradingSqliteBackend>,
active_signer: Option<Box<dyn TradingSigner>>,
active_wallet: Option<String>,
http: reqwest::Client,
}
impl PolyNodeTrader {
pub fn new(config: TraderConfig) -> Result<Self> {
Ok(Self {
config,
db: None,
active_signer: None,
active_wallet: None,
http: reqwest::Client::new(),
})
}
pub fn generate_wallet() -> (String, String) {
let (signer, key) = PrivateKeySigner::generate();
let addr = format!("{}", signer.inner().address());
(key, addr)
}
fn get_db_mut(&mut self) -> Result<&TradingSqliteBackend> {
if self.db.is_none() {
self.db = Some(TradingSqliteBackend::open(&self.config.db_path)?);
}
Ok(self.db.as_ref().unwrap())
}
fn cosigner_config(&self) -> CosignerConfig {
CosignerConfig {
cosigner_url: self.config.cosigner_url.clone(),
polynode_key: self.config.polynode_key.clone(),
fallback_direct: self.config.fallback_direct,
builder_credentials: self.config.builder_credentials.clone(),
}
}
pub async fn ensure_ready(
&mut self,
signer: Box<dyn TradingSigner>,
opts: Option<EnsureReadyOpts>,
) -> Result<ReadyStatus> {
let eoa = signer.address();
let mut actions = Vec::new();
let (sig_type, funder_address) = if let Some(ref o) = opts {
if let Some(t) = o.signature_type {
(t, derive_funder_address(eoa, t))
} else {
let (t, f) = detect_wallet_type(eoa).await?;
actions.push(format!("auto_detected_type_{}", t.as_u8()));
(t, f)
}
} else {
let (t, f) = detect_wallet_type(eoa).await?;
actions.push(format!("auto_detected_type_{}", t.as_u8()));
(t, f)
};
let db = self.get_db_mut()?;
let existing = db.get_credentials(&format!("{}", eoa))?;
let mut safe_deployed = existing.as_ref().map(|c| c.safe_deployed).unwrap_or(false);
let mut approvals_set = existing.as_ref().map(|c| c.approvals_set).unwrap_or(false);
if sig_type == SignatureType::PolyGnosisSafe && !safe_deployed {
let deployed = is_safe_deployed(funder_address).await.unwrap_or(false);
if deployed {
safe_deployed = true;
actions.push("safe_already_deployed".into());
} else {
actions.push("safe_needs_deployment".into());
}
}
if !approvals_set {
match onboarding::check_approvals(funder_address, &self.config.rpc_url, self.config.exchange_version).await {
Ok(status) => {
if status.all_approved {
approvals_set = true;
actions.push("approvals_already_set".into());
} else {
actions.push("approvals_missing".into());
}
}
Err(_) => {
actions.push("approval_check_failed".into());
}
}
}
let creds = if let Some(ref existing) = existing {
actions.push("credentials_loaded".into());
ClobCredentials {
api_key: existing.api_key.clone(),
api_secret: existing.api_secret.clone(),
api_passphrase: existing.api_passphrase.clone(),
}
} else {
let creds = create_clob_credentials(&*signer, funder_address, sig_type).await?;
actions.push("credentials_created".into());
creds
};
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs_f64();
let db = self.get_db_mut()?;
db.upsert_credentials(&StoredCredentials {
wallet_address: format!("{}", eoa),
funder_address: Some(format!("{}", funder_address)),
api_key: creds.api_key.clone(),
api_secret: creds.api_secret.clone(),
api_passphrase: creds.api_passphrase.clone(),
signature_type: sig_type,
safe_deployed,
approvals_set,
created_at: existing.as_ref().map(|c| c.created_at).unwrap_or(now),
updated_at: now,
})?;
self.active_wallet = Some(format!("{}", eoa));
self.active_signer = Some(signer);
Ok(ReadyStatus {
wallet: format!("{}", eoa),
funder_address: format!("{}", funder_address),
signature_type: sig_type,
safe_deployed,
approvals_set,
credentials_stored: true,
credentials: creds,
actions,
})
}
pub async fn link_wallet(
&mut self,
signer: Box<dyn TradingSigner>,
opts: Option<LinkOpts>,
) -> Result<LinkResult> {
let eoa = signer.address();
let sig_type = opts
.as_ref()
.and_then(|o| o.signature_type)
.unwrap_or(self.config.default_signature_type);
let funder_address = derive_funder_address(eoa, sig_type);
let db = self.get_db_mut()?;
let existing = db.get_credentials(&format!("{}", eoa))?;
let creds = if let Some(ref ex) = existing {
ClobCredentials {
api_key: ex.api_key.clone(),
api_secret: ex.api_secret.clone(),
api_passphrase: ex.api_passphrase.clone(),
}
} else {
create_clob_credentials(&*signer, funder_address, sig_type).await?
};
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs_f64();
let db = self.get_db_mut()?;
db.upsert_credentials(&StoredCredentials {
wallet_address: format!("{}", eoa),
funder_address: Some(format!("{}", funder_address)),
api_key: creds.api_key.clone(),
api_secret: creds.api_secret.clone(),
api_passphrase: creds.api_passphrase.clone(),
signature_type: sig_type,
safe_deployed: existing.as_ref().map(|c| c.safe_deployed).unwrap_or(false),
approvals_set: existing.as_ref().map(|c| c.approvals_set).unwrap_or(false),
created_at: existing.as_ref().map(|c| c.created_at).unwrap_or(now),
updated_at: now,
})?;
self.active_wallet = Some(format!("{}", eoa));
self.active_signer = Some(signer);
Ok(LinkResult {
wallet: format!("{}", eoa),
funder_address: format!("{}", funder_address),
signature_type: sig_type,
credentials: creds,
})
}
pub fn link_credentials(&mut self, opts: LinkCredentialsOpts) -> Result<()> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs_f64();
let db = self.get_db_mut()?;
db.upsert_credentials(&StoredCredentials {
wallet_address: opts.wallet.clone(),
funder_address: opts.funder_address,
api_key: opts.api_key,
api_secret: opts.api_secret,
api_passphrase: opts.api_passphrase,
signature_type: opts.signature_type.unwrap_or(SignatureType::Eoa),
safe_deployed: true,
approvals_set: true,
created_at: now,
updated_at: now,
})?;
self.active_wallet = Some(opts.wallet);
Ok(())
}
pub fn get_linked_wallets(&mut self) -> Result<Vec<WalletInfo>> {
let db = self.get_db_mut()?;
let creds = db.get_all_credentials()?;
Ok(creds
.into_iter()
.map(|c| WalletInfo {
wallet: c.wallet_address.clone(),
funder_address: c.funder_address.unwrap_or(c.wallet_address),
signature_type: c.signature_type,
credentials: ClobCredentials {
api_key: c.api_key,
api_secret: c.api_secret,
api_passphrase: c.api_passphrase,
},
created_at: c.created_at,
})
.collect())
}
pub fn export_wallet(&mut self, wallet: Option<&str>) -> Result<Option<WalletExport>> {
let addr = wallet
.map(String::from)
.or_else(|| self.active_wallet.clone())
.ok_or_else(|| Error::Trading("No wallet specified".into()))?;
let db = self.get_db_mut()?;
let creds = db.get_credentials(&addr)?;
Ok(creds.map(|c| WalletExport {
wallet: c.wallet_address.clone(),
funder_address: c.funder_address.unwrap_or(c.wallet_address),
signature_type: c.signature_type,
credentials: ClobCredentials {
api_key: c.api_key,
api_secret: c.api_secret,
api_passphrase: c.api_passphrase,
},
safe_deployed: c.safe_deployed,
approvals_set: c.approvals_set,
created_at: c.created_at,
}))
}
pub fn import_wallet(&mut self, exported: WalletExport) -> Result<()> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs_f64();
let db = self.get_db_mut()?;
db.upsert_credentials(&StoredCredentials {
wallet_address: exported.wallet,
funder_address: Some(exported.funder_address),
api_key: exported.credentials.api_key,
api_secret: exported.credentials.api_secret,
api_passphrase: exported.credentials.api_passphrase,
signature_type: exported.signature_type,
safe_deployed: exported.safe_deployed,
approvals_set: exported.approvals_set,
created_at: exported.created_at,
updated_at: now,
})?;
Ok(())
}
pub async fn check_approvals(&mut self, wallet: Option<&str>) -> Result<ApprovalStatus> {
let creds = self.get_stored_creds(wallet)?;
let funder = creds.funder_address.as_deref().unwrap_or(&creds.wallet_address);
let addr: Address = funder
.parse()
.map_err(|_| Error::Trading(format!("Invalid address: {}", funder)))?;
onboarding::check_approvals(addr, &self.config.rpc_url, self.config.exchange_version).await
}
pub async fn check_balance(&mut self, wallet: Option<&str>) -> Result<BalanceInfo> {
let creds = self.get_stored_creds(wallet)?;
let funder = creds.funder_address.as_deref().unwrap_or(&creds.wallet_address);
let addr: Address = funder
.parse()
.map_err(|_| Error::Trading(format!("Invalid address: {}", funder)))?;
onboarding::check_balance(addr, &self.config.rpc_url).await
}
pub async fn wrap_to_polyusd(&self, amount: u64) -> Result<String> {
let signer = self.active_signer.as_ref()
.ok_or_else(|| Error::Trading("No active signer. Call ensure_ready() first.".into()))?;
let eoa = signer.address();
let rpc_url = &self.config.rpc_url;
let client = &self.http;
let usdc_addr: Address = constants::USDC.parse().unwrap();
let onramp_addr: Address = constants_v2::COLLATERAL_ONRAMP.parse().unwrap();
let amount_u256 = alloy_primitives::U256::from(amount);
let max_u256 = alloy_primitives::U256::MAX;
let approve_data = onboarding::encode_approve(onramp_addr, max_u256);
let tx1_hash = self.send_raw_tx(signer.as_ref(), client, rpc_url, usdc_addr, alloy_primitives::U256::ZERO, &approve_data).await?;
tracing::info!("USDC.e approve tx: {}", tx1_hash);
let wrap_data = onboarding::encode_wrap(usdc_addr, eoa, amount_u256);
let tx2_hash = self.send_raw_tx(signer.as_ref(), client, rpc_url, onramp_addr, alloy_primitives::U256::ZERO, &wrap_data).await?;
Ok(tx2_hash)
}
pub async fn unwrap_from_polyusd(&self, amount: u64) -> Result<String> {
let signer = self.active_signer.as_ref()
.ok_or_else(|| Error::Trading("No active signer. Call ensure_ready() first.".into()))?;
let eoa = signer.address();
let rpc_url = &self.config.rpc_url;
let client = &self.http;
let usdc_addr: Address = constants::USDC.parse().unwrap();
let polyusd_addr: Address = constants_v2::POLY_USD.parse().unwrap();
let offramp_addr: Address = constants_v2::COLLATERAL_OFFRAMP.parse().unwrap();
let amount_u256 = alloy_primitives::U256::from(amount);
let max_u256 = alloy_primitives::U256::MAX;
let approve_data = onboarding::encode_approve(offramp_addr, max_u256);
let tx1_hash = self.send_raw_tx(signer.as_ref(), client, rpc_url, polyusd_addr, alloy_primitives::U256::ZERO, &approve_data).await?;
tracing::info!("PolyUSD approve tx: {}", tx1_hash);
let unwrap_data = onboarding::encode_unwrap(usdc_addr, eoa, amount_u256);
let tx2_hash = self.send_raw_tx(signer.as_ref(), client, rpc_url, offramp_addr, alloy_primitives::U256::ZERO, &unwrap_data).await?;
Ok(tx2_hash)
}
pub async fn get_polyusd_balance(&self) -> Result<u64> {
let signer = self.active_signer.as_ref()
.ok_or_else(|| Error::Trading("No active signer. Call ensure_ready() first.".into()))?;
let funder = signer.address();
let polyusd_addr: Address = constants_v2::POLY_USD.parse().unwrap();
let data = onboarding::encode_balance_of(funder);
let val = onboarding::eth_call_u256(&self.http, &self.config.rpc_url, polyusd_addr, data).await?;
Ok(u64::try_from(val).unwrap_or(u64::MAX))
}
pub async fn get_usdce_balance(&self) -> Result<u64> {
let signer = self.active_signer.as_ref()
.ok_or_else(|| Error::Trading("No active signer. Call ensure_ready() first.".into()))?;
let funder = signer.address();
let usdc_addr: Address = constants::USDC.parse().unwrap();
let data = onboarding::encode_balance_of(funder);
let val = onboarding::eth_call_u256(&self.http, &self.config.rpc_url, usdc_addr, data).await?;
Ok(u64::try_from(val).unwrap_or(u64::MAX))
}
async fn send_raw_tx(
&self,
signer: &dyn TradingSigner,
client: &reqwest::Client,
rpc_url: &str,
to: Address,
value: alloy_primitives::U256,
data: &[u8],
) -> Result<String> {
let from = signer.address();
let nonce = onboarding::eth_get_transaction_count(client, rpc_url, from).await?;
let gas_price = onboarding::eth_gas_price(client, rpc_url).await?;
let gas_price = gas_price + gas_price / 5;
let gas_limit: u64 = 150_000;
let tx_hash = onboarding::build_legacy_tx_hash(nonce, gas_price, gas_limit, to, value, data);
let signature = signer.sign_hash(&tx_hash).await?;
let raw_tx = onboarding::build_legacy_tx_raw(nonce, gas_price, gas_limit, to, value, data, &signature);
onboarding::eth_send_raw_transaction(client, rpc_url, &raw_tx).await
}
fn get_clob_host(&self) -> &str {
match self.config.exchange_version {
ExchangeVersion::V1 => CLOB_HOST,
ExchangeVersion::V2 => V2_CLOB_HOST,
}
}
pub async fn order(&mut self, params: OrderParams) -> Result<OrderResult> {
let creds = self.get_stored_creds(None)?;
let signer_addr = self.active_signer.as_ref()
.ok_or_else(|| Error::Trading("No active signer. Call ensure_ready() first.".into()))?
.address();
let meta = self.fetch_meta(¶ms.token_id).await?;
let exchange_version = self.config.exchange_version;
let order_payload = match exchange_version {
ExchangeVersion::V1 => build_order_payload(signer_addr, &creds, ¶ms, &meta)?,
ExchangeVersion::V2 => build_v2_order_payload(signer_addr, &creds, ¶ms, &meta)?,
};
let signer = self.active_signer.as_ref()
.ok_or_else(|| Error::Trading("No active signer".into()))?;
let signature = signer.sign_typed_data(&order_payload).await?;
let sig_hex = format!("0x{}", hex::encode(&signature));
let body = match exchange_version {
ExchangeVersion::V1 => build_order_body(¶ms, &order_payload, &sig_hex, &creds.api_key, &creds, &meta)?,
ExchangeVersion::V2 => build_v2_order_body(¶ms, &order_payload, &sig_hex, &creds.api_key, &creds)?,
};
let body_str = serde_json::to_string(&body)
.map_err(|e| Error::Trading(format!("Failed to serialize order: {}", e)))?;
let headers = build_l2_headers(
&creds.api_key,
&creds.api_secret,
&creds.api_passphrase,
&creds.wallet_address,
"POST",
"/order",
Some(&body_str),
);
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs_f64();
let db = self.get_db_mut()?;
let local_id = db.insert_order(&creds.wallet_address, &OrderHistoryInsert {
order_id: None,
token_id: params.token_id.clone(),
side: params.side.to_string(),
price: params.price,
size: params.size,
order_type: params.order_type.to_string(),
status: "submitting".into(),
error_msg: None,
response_json: None,
created_at: now,
fee_amount_raw: None,
escrow_order_id: None,
fee_escrow_tx_hash: None,
})?;
let fee_config = params.fee_config.as_ref().or(self.config.fee_config.as_ref());
let mut fee_auth_req: Option<FeeAuthRequest> = None;
let mut fee_amount_raw: u64 = 0;
if let Some(fc) = fee_config {
if fc.fee_bps > 0 {
let aff = fc.affiliate.as_deref().unwrap_or("");
if aff.is_empty() || aff == "0x0000000000000000000000000000000000000000" {
return Err(Error::Trading("fee_config.affiliate is required when fee_bps > 0 — set it to the wallet address where you want fees sent".into()));
}
fee_amount_raw = escrow::calculate_fee(params.price, params.size, fc.fee_bps);
if fee_amount_raw > 0 {
let funder = creds.funder_address.as_deref().unwrap_or(&creds.wallet_address);
let nonce = escrow::fetch_escrow_nonce(&self.config.rpc_url, &format!("{}", signer_addr)).await?;
let deadline = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() + 300;
let escrow_oid = escrow::generate_escrow_order_id();
let signer_ref = self.active_signer.as_ref()
.ok_or_else(|| Error::Trading("No active signer".into()))?;
fee_auth_req = Some(escrow::sign_fee_auth(
signer_ref.as_ref(),
&escrow_oid,
funder,
fee_amount_raw,
deadline,
nonce,
fc.affiliate.as_deref(),
fc.affiliate_share_bps,
).await?);
}
}
}
let result = send_via_cosigner(
&self.cosigner_config(),
&CosignerRequest {
method: "POST".into(),
path: "/order".into(),
body: Some(body_str),
headers,
builder_credentials: self.config.builder_credentials.clone(),
fee_auth: fee_auth_req.clone(),
clob_host: Some(self.get_clob_host().to_string()),
},
).await?;
let order_id = result.get("orderID")
.or_else(|| result.get("orderId"))
.and_then(|v| v.as_str())
.map(String::from);
let success = order_id.is_some() || result.get("success").and_then(|v| v.as_bool()).unwrap_or(false);
let error = result.get("error")
.or_else(|| result.get("errorMsg"))
.and_then(|v| v.as_str())
.map(String::from);
let db = self.get_db_mut()?;
let fee_tx = result.get("feeEscrowTxHash").and_then(|v| v.as_str());
db.update_order_status(
local_id,
if success { "submitted" } else { "failed" },
order_id.as_deref(),
error.as_deref(),
Some(&result.to_string()),
fee_auth_req.as_ref().map(|fa| fa.fee_amount.as_str()),
fee_auth_req.as_ref().map(|fa| fa.escrow_order_id.as_str()),
fee_tx,
)?;
Ok(OrderResult {
success,
order_id,
status: result.get("status").and_then(|v| v.as_str()).map(String::from),
error,
making_amount: result.get("makingAmount").and_then(|v| v.as_str()).map(String::from),
taking_amount: result.get("takingAmount").and_then(|v| v.as_str()).map(String::from),
fee_escrow_tx_hash: result.get("feeEscrowTxHash").and_then(|v| v.as_str()).map(String::from),
fee_amount: if fee_amount_raw > 0 { Some(format!("{:.6}", fee_amount_raw as f64 / 1e6)) } else { None },
})
}
pub async fn cancel_order(&mut self, order_id: &str) -> Result<CancelResult> {
let creds = self.get_stored_creds(None)?;
let body_str = serde_json::to_string(&serde_json::json!({ "orderID": order_id }))
.map_err(|e| Error::Trading(format!("serialize failed: {}", e)))?;
let headers = build_l2_headers(
&creds.api_key, &creds.api_secret, &creds.api_passphrase,
&creds.wallet_address, "DELETE", "/order", Some(&body_str),
);
let result = send_via_cosigner(&self.cosigner_config(), &CosignerRequest {
method: "DELETE".into(),
path: "/order".into(),
body: Some(body_str),
headers,
builder_credentials: self.config.builder_credentials.clone(),
fee_auth: None,
clob_host: Some(self.get_clob_host().to_string()),
}).await?;
Ok(CancelResult {
canceled: result.get("canceled")
.and_then(|v| v.as_array())
.map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default(),
not_canceled: result.get("not_canceled")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default(),
})
}
pub async fn cancel_all(&mut self, market: Option<&str>) -> Result<CancelResult> {
let creds = self.get_stored_creds(None)?;
let path = if market.is_some() { "/cancel-market-orders" } else { "/cancel-all" };
let body = market.map(|m| serde_json::json!({ "market": m }).to_string());
let headers = build_l2_headers(
&creds.api_key, &creds.api_secret, &creds.api_passphrase,
&creds.wallet_address, "DELETE", path, body.as_deref(),
);
let result = send_via_cosigner(&self.cosigner_config(), &CosignerRequest {
method: "DELETE".into(),
path: path.into(),
body,
headers,
builder_credentials: self.config.builder_credentials.clone(),
fee_auth: None,
clob_host: Some(self.get_clob_host().to_string()),
}).await?;
Ok(CancelResult {
canceled: result.get("canceled")
.and_then(|v| v.as_array())
.map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default(),
not_canceled: result.get("not_canceled")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default(),
})
}
pub async fn get_open_orders(&mut self, market: Option<&str>) -> Result<Vec<OpenOrder>> {
let creds = self.get_stored_creds(None)?;
let mut path = "/data/orders".to_string();
if let Some(m) = market {
path = format!("{}?market={}", path, m);
}
let headers = build_l2_headers(
&creds.api_key, &creds.api_secret, &creds.api_passphrase,
&creds.wallet_address, "GET", &path, None,
);
let result = send_via_cosigner(&self.cosigner_config(), &CosignerRequest {
method: "GET".into(),
path,
body: None,
headers,
builder_credentials: self.config.builder_credentials.clone(),
fee_auth: None,
clob_host: Some(self.get_clob_host().to_string()),
}).await?;
let orders: Vec<OpenOrder> = if result.is_array() {
serde_json::from_value(result).unwrap_or_default()
} else {
result.get("data")
.or_else(|| result.get("orders"))
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default()
};
Ok(orders)
}
pub fn get_order_history(&mut self, params: Option<HistoryParams>) -> Result<Vec<OrderHistoryRow>> {
let wallet = self.active_wallet.as_ref()
.ok_or_else(|| Error::Trading("No active wallet".into()))?
.clone();
let db = self.get_db_mut()?;
db.get_order_history(&wallet, ¶ms.unwrap_or_default())
}
pub fn split(&self, params: types::SplitParams) -> Result<types::TransactionRequest> {
Ok(position_management::build_split_txn(¶ms.condition_id, params.amount, true))
}
pub fn merge(&self, params: types::MergeParams) -> Result<types::TransactionRequest> {
Ok(position_management::build_merge_txn(¶ms.condition_id, params.amount, true))
}
pub fn convert(&self, params: types::ConvertParams) -> Result<types::TransactionRequest> {
Ok(position_management::build_convert_txn(
¶ms.market_id,
¶ms.outcome_indices,
params.amount,
))
}
pub fn close(&mut self) {
if let Some(db) = self.db.take() {
db.close();
}
self.active_signer = None;
self.active_wallet = None;
}
fn get_stored_creds(&mut self, wallet: Option<&str>) -> Result<StoredCredentials> {
let addr = wallet
.map(String::from)
.or_else(|| self.active_wallet.clone())
.ok_or_else(|| Error::Trading("No active wallet. Call ensure_ready() first.".into()))?;
let db = self.get_db_mut()?;
db.get_credentials(&addr)?
.ok_or_else(|| Error::Trading(format!("No credentials for {}. Call ensure_ready() first.", addr)))
}
async fn fetch_meta(&mut self, token_id: &str) -> Result<MarketMeta> {
let db = self.get_db_mut()?;
if let Some(cached) = db.get_market_meta(token_id)? {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs_f64();
if now - cached.fetched_at < constants::META_TTL_SECONDS {
return Ok(cached);
}
}
let clob_host = self.get_clob_host();
let tick_resp: serde_json::Value = self.http
.get(format!("{}/tick-size?token_id={}", clob_host, token_id))
.send().await?
.json().await?;
let neg_resp: serde_json::Value = self.http
.get(format!("{}/neg-risk?token_id={}", clob_host, token_id))
.send().await?
.json().await?;
let mut fee_rate_bps: i32 = 0;
let book_resp: serde_json::Value = self.http
.get(format!("{}/book?token_id={}", clob_host, token_id))
.send().await?
.json().await?;
if let Some(condition_id) = book_resp.get("market").and_then(|v| v.as_str()) {
if let Ok(market_resp) = self.http
.get(format!("{}/markets/{}", clob_host, condition_id))
.send().await
{
if let Ok(market_data) = market_resp.json::<serde_json::Value>().await {
fee_rate_bps = market_data
.get("maker_base_fee")
.and_then(|v| v.as_i64())
.unwrap_or(0) as i32;
}
}
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs_f64();
let meta = MarketMeta {
token_id: token_id.into(),
tick_size: tick_resp.get("minimum_tick_size")
.and_then(|v| v.as_f64())
.map(|v| v.to_string())
.or_else(|| tick_resp.as_str().map(|s| s.to_string()))
.unwrap_or_else(|| "0.01".to_string()),
fee_rate_bps,
neg_risk: neg_resp.get("neg_risk")
.and_then(|v| v.as_bool())
.unwrap_or(false),
fetched_at: now,
};
let db = self.get_db_mut()?;
db.upsert_market_meta(&meta)?;
Ok(meta)
}
}
#[derive(Debug, Clone, Default)]
pub struct EnsureReadyOpts {
pub signature_type: Option<SignatureType>,
}
#[derive(Debug, Clone, Default)]
pub struct LinkOpts {
pub signature_type: Option<SignatureType>,
}
#[derive(Debug, Clone)]
pub struct LinkCredentialsOpts {
pub wallet: String,
pub api_key: String,
pub api_secret: String,
pub api_passphrase: String,
pub signature_type: Option<SignatureType>,
pub funder_address: Option<String>,
}
fn build_order_payload(
signer_address: Address,
creds: &StoredCredentials,
params: &OrderParams,
meta: &MarketMeta,
) -> Result<Eip712Payload> {
let exchange = if meta.neg_risk { NEG_RISK_CTF_EXCHANGE } else { CTF_EXCHANGE };
let tick_size_str = meta.tick_size.as_str();
let rc = match tick_size_str {
"0.1" => RoundingConfig { price: 1, size: 2, amount: 3 },
"0.001" => RoundingConfig { price: 3, size: 2, amount: 5 },
"0.0001" => RoundingConfig { price: 4, size: 2, amount: 6 },
_ => RoundingConfig { price: 2, size: 2, amount: 4 }, };
let raw_price = round_normal(params.price, rc.price);
let side_num: u8;
let making_amount: u64;
let taking_amount: u64;
match params.side {
OrderSide::Buy => {
side_num = 0;
let raw_taker = round_down(params.size, rc.size);
let mut raw_maker = raw_taker * raw_price;
if decimal_places(raw_maker) > rc.amount {
raw_maker = round_up(raw_maker, rc.amount + 4);
if decimal_places(raw_maker) > rc.amount {
raw_maker = round_down(raw_maker, rc.amount);
}
}
making_amount = (raw_maker * 1_000_000.0) as u64;
taking_amount = (raw_taker * 1_000_000.0) as u64;
}
OrderSide::Sell => {
side_num = 1;
let raw_maker = round_down(params.size, rc.size);
let mut raw_taker = raw_maker * raw_price;
if decimal_places(raw_taker) > rc.amount {
raw_taker = round_up(raw_taker, rc.amount + 4);
if decimal_places(raw_taker) > rc.amount {
raw_taker = round_down(raw_taker, rc.amount);
}
}
making_amount = (raw_maker * 1_000_000.0) as u64;
taking_amount = (raw_taker * 1_000_000.0) as u64;
}
}
let funder = creds.funder_address.as_deref().unwrap_or(&creds.wallet_address);
let expiration = match params.expiration {
Some(exp) => {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
if exp > 0 && exp < now + 90 {
exp + 60
} else {
exp
}
}
None => 0,
};
let nonce: u64 = (rand::random::<u32>()) as u64;
let domain = serde_json::json!({
"name": "Polymarket CTF Exchange",
"version": "1",
"chainId": CHAIN_ID,
"verifyingContract": exchange,
});
let types = serde_json::json!({
"EIP712Domain": [
{"name": "name", "type": "string"},
{"name": "version", "type": "string"},
{"name": "chainId", "type": "uint256"},
{"name": "verifyingContract", "type": "address"}
],
"Order": [
{"name": "salt", "type": "uint256"},
{"name": "maker", "type": "address"},
{"name": "signer", "type": "address"},
{"name": "taker", "type": "address"},
{"name": "tokenId", "type": "uint256"},
{"name": "makerAmount", "type": "uint256"},
{"name": "takerAmount", "type": "uint256"},
{"name": "expiration", "type": "uint256"},
{"name": "nonce", "type": "uint256"},
{"name": "feeRateBps", "type": "uint256"},
{"name": "side", "type": "uint8"},
{"name": "signatureType", "type": "uint8"}
]
});
let message = serde_json::json!({
"salt": nonce.to_string(),
"maker": funder,
"signer": format!("{}", signer_address),
"taker": "0x0000000000000000000000000000000000000000",
"tokenId": params.token_id,
"makerAmount": making_amount.to_string(),
"takerAmount": taking_amount.to_string(),
"expiration": expiration.to_string(),
"nonce": "0",
"feeRateBps": meta.fee_rate_bps.to_string(),
"side": side_num.to_string(),
"signatureType": creds.signature_type.as_u8().to_string(),
});
Ok(Eip712Payload {
domain,
types,
primary_type: "Order".into(),
message,
})
}
fn build_order_body(
params: &OrderParams,
payload: &Eip712Payload,
signature: &str,
funder: &str,
creds: &StoredCredentials,
_meta: &MarketMeta,
) -> Result<serde_json::Value> {
let order_type = params.order_type.to_string();
let salt: u64 = payload.message["salt"].as_str()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let side_str = match params.side {
OrderSide::Buy => "BUY",
OrderSide::Sell => "SELL",
};
let sig_type = creds.signature_type.as_u8();
let mut body = serde_json::json!({
"order": {
"salt": salt,
"maker": payload.message["maker"],
"signer": payload.message["signer"],
"taker": payload.message["taker"],
"tokenId": payload.message["tokenId"],
"makerAmount": payload.message["makerAmount"],
"takerAmount": payload.message["takerAmount"],
"expiration": payload.message["expiration"],
"nonce": payload.message["nonce"],
"feeRateBps": payload.message["feeRateBps"],
"side": side_str,
"signatureType": sig_type,
"signature": signature,
},
"owner": funder,
"orderType": order_type,
"deferExec": false,
});
if params.post_only {
body["postOnly"] = serde_json::Value::Bool(true);
}
Ok(body)
}
fn build_v2_order_payload(
signer_address: Address,
creds: &StoredCredentials,
params: &OrderParams,
meta: &MarketMeta,
) -> Result<Eip712Payload> {
let exchange = if meta.neg_risk { V2_NEG_RISK_EXCHANGE_A } else { V2_CTF_EXCHANGE };
let tick_size_str = meta.tick_size.as_str();
let rc = match tick_size_str {
"0.1" => RoundingConfig { price: 1, size: 2, amount: 3 },
"0.001" => RoundingConfig { price: 3, size: 2, amount: 5 },
"0.0001" => RoundingConfig { price: 4, size: 2, amount: 6 },
_ => RoundingConfig { price: 2, size: 2, amount: 4 }, };
let raw_price = round_normal(params.price, rc.price);
let side_num: u8;
let making_amount: u64;
let taking_amount: u64;
match params.side {
OrderSide::Buy => {
side_num = 0;
let raw_taker = round_down(params.size, rc.size);
let mut raw_maker = raw_taker * raw_price;
if decimal_places(raw_maker) > rc.amount {
raw_maker = round_up(raw_maker, rc.amount + 4);
if decimal_places(raw_maker) > rc.amount {
raw_maker = round_down(raw_maker, rc.amount);
}
}
making_amount = (raw_maker * 1_000_000.0) as u64;
taking_amount = (raw_taker * 1_000_000.0) as u64;
}
OrderSide::Sell => {
side_num = 1;
let raw_maker = round_down(params.size, rc.size);
let mut raw_taker = raw_maker * raw_price;
if decimal_places(raw_taker) > rc.amount {
raw_taker = round_up(raw_taker, rc.amount + 4);
if decimal_places(raw_taker) > rc.amount {
raw_taker = round_down(raw_taker, rc.amount);
}
}
making_amount = (raw_maker * 1_000_000.0) as u64;
taking_amount = (raw_taker * 1_000_000.0) as u64;
}
}
let funder = creds.funder_address.as_deref().unwrap_or(&creds.wallet_address);
let timestamp_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
let salt = timestamp_ms;
let zero_bytes32 = "0x0000000000000000000000000000000000000000000000000000000000000000";
let domain = serde_json::json!({
"name": V2_DOMAIN_NAME,
"version": V2_DOMAIN_VERSION,
"chainId": CHAIN_ID,
"verifyingContract": exchange,
});
let types = serde_json::json!({
"EIP712Domain": [
{"name": "name", "type": "string"},
{"name": "version", "type": "string"},
{"name": "chainId", "type": "uint256"},
{"name": "verifyingContract", "type": "address"}
],
"Order": [
{"name": "salt", "type": "uint256"},
{"name": "maker", "type": "address"},
{"name": "signer", "type": "address"},
{"name": "tokenId", "type": "uint256"},
{"name": "makerAmount", "type": "uint256"},
{"name": "takerAmount", "type": "uint256"},
{"name": "side", "type": "uint8"},
{"name": "signatureType", "type": "uint8"},
{"name": "timestamp", "type": "uint256"},
{"name": "metadata", "type": "bytes32"},
{"name": "builder", "type": "bytes32"}
]
});
let message = serde_json::json!({
"salt": salt.to_string(),
"maker": funder,
"signer": format!("{}", signer_address),
"tokenId": params.token_id,
"makerAmount": making_amount.to_string(),
"takerAmount": taking_amount.to_string(),
"side": side_num.to_string(),
"signatureType": creds.signature_type.as_u8().to_string(),
"timestamp": timestamp_ms.to_string(),
"metadata": zero_bytes32,
"builder": zero_bytes32,
});
Ok(Eip712Payload {
domain,
types,
primary_type: "Order".into(),
message,
})
}
fn build_v2_order_body(
params: &OrderParams,
payload: &Eip712Payload,
signature: &str,
owner: &str,
creds: &StoredCredentials,
) -> Result<serde_json::Value> {
let order_type = params.order_type.to_string();
let salt: u64 = payload.message["salt"].as_str()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let side_str = match params.side {
OrderSide::Buy => "BUY",
OrderSide::Sell => "SELL",
};
let sig_type = creds.signature_type.as_u8();
let mut body = serde_json::json!({
"order": {
"salt": salt,
"maker": payload.message["maker"],
"signer": payload.message["signer"],
"tokenId": payload.message["tokenId"],
"makerAmount": payload.message["makerAmount"],
"takerAmount": payload.message["takerAmount"],
"side": side_str,
"signatureType": sig_type,
"timestamp": payload.message["timestamp"],
"metadata": payload.message["metadata"],
"builder": payload.message["builder"],
"signature": signature,
},
"owner": owner,
"orderType": order_type,
});
if params.post_only {
body["postOnly"] = serde_json::Value::Bool(true);
}
Ok(body)
}
impl Default for OrderParams {
fn default() -> Self {
Self {
token_id: String::new(),
side: OrderSide::Buy,
price: 0.0,
size: 0.0,
order_type: OrderType::GTC,
expiration: None,
post_only: false,
fee_config: None,
}
}
}
struct RoundingConfig {
price: u32,
size: u32,
amount: u32,
}
fn decimal_places(num: f64) -> u32 {
if num.fract() == 0.0 {
return 0;
}
let s = format!("{}", num);
match s.split_once('.') {
Some((_, frac)) => frac.len() as u32,
None => 0,
}
}
fn round_normal(num: f64, decimals: u32) -> f64 {
if decimal_places(num) <= decimals {
return num;
}
let factor = 10f64.powi(decimals as i32);
((num + f64::EPSILON) * factor).round() / factor
}
fn round_down(num: f64, decimals: u32) -> f64 {
if decimal_places(num) <= decimals {
return num;
}
let factor = 10f64.powi(decimals as i32);
(num * factor).floor() / factor
}
fn round_up(num: f64, decimals: u32) -> f64 {
if decimal_places(num) <= decimals {
return num;
}
let factor = 10f64.powi(decimals as i32);
(num * factor).ceil() / factor
}