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;
pub mod relayer;
#[cfg(feature = "privy")]
pub mod privy;
use alloy_primitives::keccak256;
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 sig_type == SignatureType::Poly1271 {
if !safe_deployed {
let deployed = onboarding::is_contract_deployed(funder_address, &self.config.rpc_url)
.await
.unwrap_or(false);
if deployed {
safe_deployed = true;
actions.push("deposit_wallet_already_deployed".into());
}
}
if let Some(ref bc) = self.config.builder_credentials {
let rc = relayer::RelayClient::new(bc.clone());
match rc.submit_deposit_wallet_create(signer.as_ref()).await {
Ok(tx_id) => actions.push(format!("deposit_wallet_create_submitted: {}", tx_id)),
Err(e) => actions.push(format!("deposit_wallet_create_relayer_error: {}", e)),
}
} else if !safe_deployed {
actions.push("deposit_wallet_needs_deployment_no_builder_creds".into());
}
if !safe_deployed {
for _ in 0..60 {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
let deployed = onboarding::is_contract_deployed(funder_address, &self.config.rpc_url)
.await
.unwrap_or(false);
if deployed {
safe_deployed = true;
actions.push("deposit_wallet_deployed".into());
break;
}
}
if !safe_deployed {
actions.push("deposit_wallet_deploy_failed: not found on-chain after 120s".into());
}
}
if safe_deployed && self.config.builder_credentials.is_some() {
tokio::time::sleep(std::time::Duration::from_secs(15)).await;
}
}
if sig_type == SignatureType::Poly1271 && safe_deployed {
let approvals_ready = match onboarding::check_approvals(funder_address, &self.config.rpc_url, self.config.exchange_version).await {
Ok(status) if deposit_wallet_trade_approvals_ready(
&status,
funder_address,
&self.config.rpc_url,
self.config.exchange_version,
).await => true,
_ => false,
};
if approvals_ready {
approvals_set = true;
actions.push("approvals_already_set".into());
} else {
if let Some(ref bc) = self.config.builder_credentials {
let rc = relayer::RelayClient::new(bc.clone());
match rc.submit_deposit_wallet_approvals(signer.as_ref(), funder_address).await {
Ok(tx_id) => {
actions.push(format!("deposit_wallet_approval_submitted: {}", tx_id));
for _ in 0..60 {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
match onboarding::check_approvals(funder_address, &self.config.rpc_url, self.config.exchange_version).await {
Ok(status) if deposit_wallet_trade_approvals_ready(
&status,
funder_address,
&self.config.rpc_url,
self.config.exchange_version,
).await => {
approvals_set = true;
actions.push("deposit_wallet_approvals_set".into());
break;
}
_ => {}
}
}
if !approvals_set {
actions.push("deposit_wallet_approvals_failed: not confirmed on-chain after 120s".into());
}
}
Err(e) => {
actions.push(format!("deposit_wallet_approvals_failed: {}", e));
}
}
} else {
actions.push("deposit_wallet_approvals_needed_no_builder_creds".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);
if self.config.exchange_version == ExchangeVersion::V2 && approvals_set {
let wallet = format!("{}", eoa);
match self.refresh_balance_allowance("COLLATERAL", Some(&wallet)).await {
Ok((true, _)) => actions.push("clob_cache_refreshed".into()),
Ok((false, _)) => actions.push("clob_cache_refresh_failed".into()),
Err(_) => actions.push("clob_cache_refresh_failed".into()),
}
}
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, self.config.exchange_version).await
}
pub async fn refresh_balance_allowance(
&mut self,
asset_type: &str,
wallet: Option<&str>,
) -> Result<(bool, u16)> {
if self.config.exchange_version != ExchangeVersion::V2 {
return Ok((true, 0));
}
let creds = self.get_stored_creds(wallet)?;
let sig_type = creds.signature_type.as_u8();
let path = "/balance-allowance/update";
let headers = build_l2_headers(
&creds.api_key,
&creds.api_secret,
&creds.api_passphrase,
&creds.wallet_address,
"GET",
path, None,
);
let url = format!(
"{}{}?asset_type={}&signature_type={}",
V2_CLOB_HOST, path, asset_type, sig_type
);
let mut req = self.http.get(&url);
for (k, v) in &headers {
req = req.header(k, v);
}
let resp = req.send().await
.map_err(|e| Error::Trading(format!("refresh_balance_allowance failed: {}", e)))?;
Ok((resp.status().is_success(), resp.status().as_u16()))
}
pub async fn get_balance_allowance(
&mut self,
asset_type: &str,
wallet: Option<&str>,
) -> Result<Option<serde_json::Value>> {
if self.config.exchange_version != ExchangeVersion::V2 {
return Ok(None);
}
let creds = self.get_stored_creds(wallet)?;
let sig_type = creds.signature_type.as_u8();
let path = "/balance-allowance";
let headers = build_l2_headers(
&creds.api_key,
&creds.api_secret,
&creds.api_passphrase,
&creds.wallet_address,
"GET",
path,
None,
);
let url = format!(
"{}{}?asset_type={}&signature_type={}",
V2_CLOB_HOST, path, asset_type, sig_type
);
let mut req = self.http.get(&url);
for (k, v) in &headers {
req = req.header(k, v);
}
let resp = req.send().await
.map_err(|e| Error::Trading(format!("get_balance_allowance failed: {}", e)))?;
if !resp.status().is_success() {
return Ok(None);
}
let json: serde_json::Value = resp.json().await
.map_err(|e| Error::Trading(format!("get_balance_allowance parse: {}", e)))?;
Ok(Some(json))
}
pub async fn wrap_to_polyusd(&mut self, amount: u64) -> Result<String> {
let creds = self.get_stored_creds(None)?;
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 funder: Address = creds.funder_address.as_deref().unwrap_or(&creds.wallet_address).parse()
.map_err(|_| Error::Trading("bad funder address".into()))?;
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 wrap_data = onboarding::encode_wrap(usdc_addr, funder, amount_u256);
let allowance = onboarding::eth_call_u256(
&self.http,
&self.config.rpc_url,
usdc_addr,
onboarding::encode_allowance(funder, onramp_addr),
).await.unwrap_or(alloy_primitives::U256::ZERO);
if creds.signature_type == SignatureType::Poly1271 {
let bc = self.config.builder_credentials.clone().ok_or_else(|| {
Error::Trading(
"wrap_to_polyusd on a deposit wallet requires builder_credentials in \
TraderConfig. Pass your Polymarket builder key/secret/passphrase.".into()
)
})?;
let rc = relayer::RelayClient::new(bc);
let mut txns = Vec::new();
if allowance < amount_u256 {
txns.push(relayer::SafeSubTx { to: usdc_addr, value: alloy_primitives::U256::ZERO, data: approve_data, operation: 0 });
}
txns.push(relayer::SafeSubTx { to: onramp_addr, value: alloy_primitives::U256::ZERO, data: wrap_data, operation: 0 });
return rc.execute_deposit_wallet_calls(signer.as_ref(), funder, txns).await;
}
if matches!(creds.signature_type, SignatureType::PolyGnosisSafe | SignatureType::PolyProxy) {
let bc = self.config.builder_credentials.clone().ok_or_else(|| {
Error::Trading(
"wrap_to_polyusd on a Safe/Proxy wallet requires builder_credentials in \
TraderConfig. Pass your Polymarket builder key/secret/passphrase.".into()
)
})?;
let rc = relayer::RelayClient::new(bc);
let mut txns = Vec::new();
if allowance < amount_u256 {
txns.push(relayer::SafeSubTx { to: usdc_addr, value: alloy_primitives::U256::ZERO, data: approve_data, operation: 0 });
}
txns.push(relayer::SafeSubTx { to: onramp_addr, value: alloy_primitives::U256::ZERO, data: wrap_data, operation: 0 });
return rc.execute_safe(signer.as_ref(), txns).await;
}
let rpc_url = &self.config.rpc_url;
let client = &self.http;
if allowance < amount_u256 {
let tx1 = 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);
}
self.send_raw_tx(signer.as_ref(), client, rpc_url, onramp_addr, alloy_primitives::U256::ZERO, &wrap_data).await
}
pub async fn unwrap_from_polyusd(&mut self, amount: u64) -> Result<String> {
let creds = self.get_stored_creds(None)?;
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 funder: Address = creds.funder_address.as_deref().unwrap_or(&creds.wallet_address).parse()
.map_err(|_| Error::Trading("bad funder address".into()))?;
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 unwrap_data = onboarding::encode_unwrap(usdc_addr, funder, amount_u256);
if matches!(creds.signature_type, SignatureType::PolyGnosisSafe | SignatureType::PolyProxy) {
let bc = self.config.builder_credentials.clone().ok_or_else(|| {
Error::Trading(
"unwrap_from_polyusd on a Safe/Proxy wallet requires builder_credentials in \
TraderConfig. Pass your Polymarket builder key/secret/passphrase.".into()
)
})?;
let client = reqwest::Client::new();
let allowance = onboarding::eth_call_u256(
&client, &self.config.rpc_url, polyusd_addr,
onboarding::encode_allowance(funder, offramp_addr),
).await.unwrap_or(alloy_primitives::U256::ZERO);
let rc = relayer::RelayClient::new(bc);
let mut txns = Vec::new();
if allowance < amount_u256 {
txns.push(relayer::SafeSubTx { to: polyusd_addr, value: alloy_primitives::U256::ZERO, data: approve_data, operation: 0 });
}
txns.push(relayer::SafeSubTx { to: offramp_addr, value: alloy_primitives::U256::ZERO, data: unwrap_data, operation: 0 });
return rc.execute_safe(signer.as_ref(), txns).await;
}
let rpc_url = &self.config.rpc_url;
let client = &self.http;
let tx1 = 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);
self.send_raw_tx(signer.as_ref(), client, rpc_url, offramp_addr, alloy_primitives::U256::ZERO, &unwrap_data).await
}
pub async fn get_polyusd_balance(&mut self) -> Result<u64> {
let creds = self.get_stored_creds(None)?;
let funder: Address = creds.funder_address.as_deref().unwrap_or(&creds.wallet_address).parse()
.map_err(|_| Error::Trading("bad funder address".into()))?;
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(&mut self) -> Result<u64> {
let creds = self.get_stored_creds(None)?;
let funder: Address = creds.funder_address.as_deref().unwrap_or(&creds.wallet_address).parse()
.map_err(|_| Error::Trading("bad funder address".into()))?;
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 ensure_v2_buy_collateral(
&mut self,
creds: &StoredCredentials,
maker_amount: u64,
) -> Result<bool> {
if maker_amount == 0 {
return Ok(false);
}
let funder: Address = creds.funder_address.as_deref().unwrap_or(&creds.wallet_address).parse()
.map_err(|_| Error::Trading("bad funder address".into()))?;
let polyusd_addr: Address = constants_v2::POLY_USD.parse().unwrap();
let usdce_addr: Address = constants::USDC.parse().unwrap();
let polyusd = onboarding::eth_call_u256(
&self.http,
&self.config.rpc_url,
polyusd_addr,
onboarding::encode_balance_of(funder),
).await.unwrap_or(alloy_primitives::U256::ZERO);
let needed = alloy_primitives::U256::from(maker_amount);
if polyusd >= needed {
let _ = self.refresh_balance_allowance("COLLATERAL", Some(&creds.wallet_address)).await;
return Ok(false);
}
let usdce = onboarding::eth_call_u256(
&self.http,
&self.config.rpc_url,
usdce_addr,
onboarding::encode_balance_of(funder),
).await.unwrap_or(alloy_primitives::U256::ZERO);
let shortfall = needed - polyusd;
let wrap_amount = if usdce < shortfall { usdce } else { shortfall };
if wrap_amount == alloy_primitives::U256::ZERO {
return Err(Error::Trading(format!(
"Insufficient pUSD / wrappable USDC.e balance for this trade. need_raw={} pUSD_raw={} usdce_raw={}",
maker_amount, polyusd, usdce
)));
}
let wrap_raw = u64::try_from(wrap_amount).unwrap_or(u64::MAX);
self.wrap_to_polyusd(wrap_raw).await?;
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
match self.refresh_balance_allowance("COLLATERAL", Some(&creds.wallet_address)).await {
Ok((true, _)) => {}
Ok((false, status)) => {
return Err(Error::Trading(format!("CLOB collateral cache refresh failed after pUSD wrap: status {}", status)));
}
Err(e) => return Err(e),
}
Ok(true)
}
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 mut 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)?,
};
if exchange_version == ExchangeVersion::V2 && params.side == OrderSide::Buy {
let maker_amount = v2_payload_maker_amount(&order_payload)?;
if self.ensure_v2_buy_collateral(&creds, maker_amount).await? {
order_payload = 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 = if creds.signature_type == SignatureType::Poly1271 && exchange_version == ExchangeVersion::V2 {
wrap_poly1271_signature(&signature, &order_payload)?
} else {
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 escrow_addr = escrow::fee_escrow_address_for(self.config.exchange_version);
let nonce = escrow::fetch_escrow_nonce(
&self.config.rpc_url,
&format!("{}", signer_addr),
escrow_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,
self.config.exchange_version,
).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)
}
}
fn deposit_wallet_trade_approvals_set(status: &ApprovalStatus) -> bool {
status.usdc.ctf_exchange
&& status.usdc.neg_risk_ctf_exchange
&& status.usdc.neg_risk_adapter
&& status.ctf.ctf_exchange
&& status.ctf.neg_risk_ctf_exchange
&& status.ctf.neg_risk_adapter
}
async fn deposit_wallet_trade_approvals_ready(
status: &ApprovalStatus,
funder_address: Address,
rpc_url: &str,
exchange_version: ExchangeVersion,
) -> bool {
if !deposit_wallet_trade_approvals_set(status) {
return false;
}
if exchange_version != ExchangeVersion::V2 {
return true;
}
onboarding::check_usdce_onramp_approval(funder_address, rpc_url)
.await
.unwrap_or(false)
}
#[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).round() as u64;
taking_amount = (raw_taker * 1_000_000.0).round() 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).round() as u64;
taking_amount = (raw_taker * 1_000_000.0).round() 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).round() as u64;
taking_amount = (raw_taker * 1_000_000.0).round() 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).round() as u64;
taking_amount = (raw_taker * 1_000_000.0).round() as u64;
}
}
let funder = creds.funder_address.as_deref().unwrap_or(&creds.wallet_address);
let order_signer = if creds.signature_type == SignatureType::Poly1271 {
funder.to_string()
} else {
format!("{}", signer_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 builder_hex = params.builder.as_deref().unwrap_or(zero_bytes32);
let domain = serde_json::json!({
"name": V2_DOMAIN_NAME,
"version": V2_DOMAIN_VERSION,
"chainId": CHAIN_ID,
"verifyingContract": exchange,
});
let order_type_fields = serde_json::json!([
{"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 order_message = serde_json::json!({
"salt": salt.to_string(),
"maker": funder,
"signer": order_signer,
"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": builder_hex,
});
if creds.signature_type == SignatureType::Poly1271 {
let types = serde_json::json!({
"EIP712Domain": [
{"name": "name", "type": "string"},
{"name": "version", "type": "string"},
{"name": "chainId", "type": "uint256"},
{"name": "verifyingContract", "type": "address"}
],
"TypedDataSign": [
{"name": "contents", "type": "Order"},
{"name": "name", "type": "string"},
{"name": "version", "type": "string"},
{"name": "chainId", "type": "uint256"},
{"name": "verifyingContract", "type": "address"},
{"name": "salt", "type": "bytes32"}
],
"Order": order_type_fields
});
let message = serde_json::json!({
"contents": order_message,
"name": "DepositWallet",
"version": "1",
"chainId": CHAIN_ID.to_string(),
"verifyingContract": funder,
"salt": zero_bytes32,
});
return Ok(Eip712Payload {
domain,
types,
primary_type: "TypedDataSign".into(),
message,
});
}
let types = serde_json::json!({
"EIP712Domain": [
{"name": "name", "type": "string"},
{"name": "version", "type": "string"},
{"name": "chainId", "type": "uint256"},
{"name": "verifyingContract", "type": "address"}
],
"Order": order_type_fields
});
Ok(Eip712Payload {
domain,
types,
primary_type: "Order".into(),
message: order_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 order_message = v2_order_message(payload);
let salt: u64 = order_message["salt"].as_str()
.and_then(|s| s.parse().ok())
.or_else(|| order_message["salt"].as_u64())
.unwrap_or(0);
let side_str = match params.side {
OrderSide::Buy => "BUY",
OrderSide::Sell => "SELL",
};
let sig_type = creds.signature_type.as_u8();
let expiration = v2_order_expiration(params);
let body = serde_json::json!({
"order": {
"salt": salt,
"maker": order_message["maker"],
"signer": order_message["signer"],
"tokenId": order_message["tokenId"],
"makerAmount": order_message["makerAmount"],
"takerAmount": order_message["takerAmount"],
"side": side_str,
"signatureType": sig_type,
"timestamp": order_message["timestamp"],
"expiration": expiration.to_string(),
"metadata": order_message["metadata"],
"builder": order_message["builder"],
"signature": signature,
},
"owner": owner,
"orderType": order_type,
"postOnly": params.post_only,
"deferExec": false,
});
Ok(body)
}
fn v2_order_expiration(params: &OrderParams) -> u64 {
if params.order_type != OrderType::GTD {
return 0;
}
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,
}
}
fn v2_order_message(payload: &Eip712Payload) -> &serde_json::Value {
if payload.primary_type == "TypedDataSign" {
&payload.message["contents"]
} else {
&payload.message
}
}
fn v2_payload_maker_amount(payload: &Eip712Payload) -> Result<u64> {
let value = &v2_order_message(payload)["makerAmount"];
if let Some(s) = value.as_str() {
return s.parse::<u64>()
.map_err(|e| Error::Trading(format!("invalid makerAmount: {}", e)));
}
if let Some(n) = value.as_u64() {
return Ok(n);
}
Err(Error::Trading("invalid makerAmount".into()))
}
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,
builder: None,
}
}
}
struct RoundingConfig {
price: u32,
size: u32,
amount: u32,
}
fn wrap_poly1271_signature(inner_sig: &[u8], payload: &Eip712Payload) -> Result<String> {
let order_type_string = b"Order(uint256 salt,address maker,address signer,uint256 tokenId,uint256 makerAmount,uint256 takerAmount,uint8 side,uint8 signatureType,uint256 timestamp,bytes32 metadata,bytes32 builder)";
let inner_sig = poly1271_inner_signature(inner_sig)?;
let domain_sep = {
let name = payload.domain["name"].as_str().unwrap_or("");
let version = payload.domain["version"].as_str().unwrap_or("");
let chain_id = payload.domain["chainId"].as_u64().unwrap_or(0);
let contract = payload.domain["verifyingContract"].as_str().unwrap_or("");
let type_str = b"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)";
let type_hash = keccak256(type_str);
let mut enc = Vec::new();
enc.extend_from_slice(type_hash.as_slice());
enc.extend_from_slice(keccak256(name.as_bytes()).as_slice());
enc.extend_from_slice(keccak256(version.as_bytes()).as_slice());
let mut word = [0u8; 32];
word[24..32].copy_from_slice(&chain_id.to_be_bytes());
enc.extend_from_slice(&word);
let addr = contract.strip_prefix("0x").unwrap_or(contract);
let addr_bytes = hex::decode(addr).map_err(|e| Error::Trading(format!("bad addr: {}", e)))?;
let mut addr_word = [0u8; 32];
addr_word[12..32].copy_from_slice(&addr_bytes);
enc.extend_from_slice(&addr_word);
keccak256(&enc)
};
let order_msg = if payload.primary_type == "TypedDataSign" {
&payload.message["contents"]
} else {
&payload.message
};
let contents_hash = {
let mut types_for_order = payload.types.clone();
if let Some(obj) = types_for_order.as_object_mut() {
obj.remove("EIP712Domain");
obj.remove("TypedDataSign");
}
let hash = crate::trading::signer::hash_eip712_struct("Order", &types_for_order, order_msg)
.map_err(|e| Error::Trading(format!("hash order struct: {}", e)))?;
hash
};
let mut out = Vec::with_capacity(65 + 32 + 32 + order_type_string.len() + 2);
out.extend_from_slice(&inner_sig);
out.extend_from_slice(domain_sep.as_slice());
out.extend_from_slice(contents_hash.as_slice());
out.extend_from_slice(order_type_string);
let len = order_type_string.len() as u16;
out.push((len >> 8) as u8);
out.push((len & 0xff) as u8);
Ok(format!("0x{}", hex::encode(&out)))
}
fn poly1271_inner_signature(inner_sig: &[u8]) -> Result<[u8; 65]> {
if inner_sig.len() != 65 {
return Err(Error::Trading(format!(
"POLY_1271 inner signature must be 65 bytes, got {}",
inner_sig.len()
)));
}
let mut sig = [0u8; 65];
sig.copy_from_slice(inner_sig);
match sig[64] {
0 | 1 => sig[64] += 27,
27 | 28 => {}
v => {
return Err(Error::Trading(format!(
"POLY_1271 inner signature has invalid recovery byte {}",
v
)));
}
}
Ok(sig)
}
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
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn v2_poly1271_order_uses_deposit_wallet_as_maker_and_signer() {
let signer_address: Address = "0x7A34f90642892d8f4cFcd67664e8aaE9F5626e76".parse().unwrap();
let deposit_wallet = "0x8E6741Bb93CA02D03d32da4fa0E4263EB9b32148".to_string();
let creds = StoredCredentials {
wallet_address: format!("{}", signer_address),
funder_address: Some(deposit_wallet.clone()),
api_key: "key".into(),
api_secret: "secret".into(),
api_passphrase: "pass".into(),
signature_type: SignatureType::Poly1271,
safe_deployed: true,
approvals_set: true,
created_at: 0.0,
updated_at: 0.0,
};
let params = OrderParams {
token_id: "123456789".into(),
side: OrderSide::Buy,
price: 0.5,
size: 10.0,
..Default::default()
};
let meta = MarketMeta {
token_id: params.token_id.clone(),
tick_size: "0.01".into(),
fee_rate_bps: 0,
neg_risk: false,
fetched_at: 0.0,
};
let payload = build_v2_order_payload(signer_address, &creds, ¶ms, &meta).unwrap();
let order = &payload.message["contents"];
assert_eq!(order["maker"].as_str().unwrap(), deposit_wallet);
assert_eq!(order["signer"].as_str().unwrap(), deposit_wallet);
assert_eq!(order["signatureType"].as_str().unwrap(), "3");
assert_eq!(v2_payload_maker_amount(&payload).unwrap(), 5_000_000);
}
#[test]
fn v2_poly1271_order_body_uses_wrapped_contents() {
let signer_address: Address = "0x7A34f90642892d8f4cFcd67664e8aaE9F5626e76".parse().unwrap();
let deposit_wallet = "0x8E6741Bb93CA02D03d32da4fa0E4263EB9b32148".to_string();
let creds = StoredCredentials {
wallet_address: format!("{}", signer_address),
funder_address: Some(deposit_wallet.clone()),
api_key: "key".into(),
api_secret: "secret".into(),
api_passphrase: "pass".into(),
signature_type: SignatureType::Poly1271,
safe_deployed: true,
approvals_set: true,
created_at: 0.0,
updated_at: 0.0,
};
let params = OrderParams {
token_id: "123456789".into(),
side: OrderSide::Buy,
price: 0.5,
size: 10.0,
..Default::default()
};
let meta = MarketMeta {
token_id: params.token_id.clone(),
tick_size: "0.01".into(),
fee_rate_bps: 0,
neg_risk: false,
fetched_at: 0.0,
};
let payload = build_v2_order_payload(signer_address, &creds, ¶ms, &meta).unwrap();
let body = build_v2_order_body(¶ms, &payload, "0xsig", &creds.api_key, &creds).unwrap();
let order = &body["order"];
assert_eq!(order["maker"].as_str().unwrap(), deposit_wallet);
assert_eq!(order["signer"].as_str().unwrap(), deposit_wallet);
assert_eq!(order["signatureType"].as_u64().unwrap(), 3);
assert_eq!(order["makerAmount"].as_str().unwrap(), "5000000");
assert_eq!(order["takerAmount"].as_str().unwrap(), "10000000");
assert_eq!(order["side"].as_str().unwrap(), "BUY");
assert_eq!(order["expiration"].as_str().unwrap(), "0");
assert!(order.get("taker").is_none());
assert!(order["salt"].as_u64().unwrap() > 0);
assert!(!order["maker"].is_null());
assert_eq!(body["owner"].as_str().unwrap(), "key");
}
#[test]
fn poly1271_inner_signature_uses_electrum_v() {
let mut sig = [0u8; 65];
sig[64] = 0;
assert_eq!(poly1271_inner_signature(&sig).unwrap()[64], 27);
sig[64] = 1;
assert_eq!(poly1271_inner_signature(&sig).unwrap()[64], 28);
sig[64] = 27;
assert_eq!(poly1271_inner_signature(&sig).unwrap()[64], 27);
sig[64] = 28;
assert_eq!(poly1271_inner_signature(&sig).unwrap()[64], 28);
sig[64] = 2;
assert!(poly1271_inner_signature(&sig).is_err());
}
}