pub mod constants;
pub mod constants_v2;
pub mod cosigner;
pub mod escrow;
pub mod onboarding;
pub mod position_management;
pub mod relayer;
pub mod signer;
pub mod sqlite_backend;
pub mod types;
#[cfg(feature = "privy")]
pub mod privy;
use self::constants::{CHAIN_ID, CLOB_HOST, CTF_EXCHANGE, NEG_RISK_CTF_EXCHANGE};
use self::constants_v2::{
V2_CLOB_HOST, V2_CTF_EXCHANGE, V2_DOMAIN_NAME, V2_DOMAIN_VERSION, V2_NEG_RISK_EXCHANGE_A,
};
use self::cosigner::{send_via_cosigner, CosignerConfig};
use self::onboarding::create_clob_credentials;
use self::sqlite_backend::OrderHistoryInsert;
use crate::error::{Error, Result};
use alloy_primitives::keccak256;
pub use self::cosigner::build_l2_headers;
pub use self::onboarding::{
derive_funder_address, derive_proxy_address, derive_safe_address, detect_wallet_type,
is_safe_deployed,
};
pub use self::signer::{PrivateKeySigner, TradingSigner};
pub use self::sqlite_backend::TradingSqliteBackend;
pub use self::types::*;
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(),
}
}
fn relayer_client(&self) -> Result<relayer::RelayClient> {
match self.config.relayer_mode {
RelayerMode::Auto => {
if !self.config.cosigner_url.is_empty() && !self.config.polynode_key.is_empty() {
Ok(relayer::RelayClient::managed(
self.config.cosigner_url.clone(),
self.config.polynode_key.clone(),
self.config.builder_credentials.clone(),
))
} else if let Some(ref bc) = self.config.builder_credentials {
Ok(relayer::RelayClient::with_builder_credentials(bc.clone()))
} else {
Err(Error::Trading(
"smart-wallet relayer operations require either polynode_key + \
cosigner_url for managed relay, or builder_credentials for direct \
Polymarket relayer auth"
.into(),
))
}
}
RelayerMode::Managed => {
if self.config.cosigner_url.is_empty() || self.config.polynode_key.is_empty() {
return Err(Error::Trading(
"RelayerMode::Managed requires TraderConfig.polynode_key and \
TraderConfig.cosigner_url"
.into(),
));
}
Ok(relayer::RelayClient::managed(
self.config.cosigner_url.clone(),
self.config.polynode_key.clone(),
self.config.builder_credentials.clone(),
))
}
RelayerMode::BuilderCredentials => {
let bc = self.config.builder_credentials.clone().ok_or_else(|| {
Error::Trading(
"RelayerMode::BuilderCredentials requires TraderConfig.builder_credentials"
.into(),
)
})?;
Ok(relayer::RelayClient::with_builder_credentials(bc))
}
RelayerMode::DirectRpc => Err(Error::Trading(
"RelayerMode::DirectRpc bypasses the relayer for Safe/proxy calls only; \
deposit-wallet factory calls require RelayerMode::Managed or \
RelayerMode::BuilderCredentials"
.into(),
)),
}
}
fn managed_relayer_available(&self) -> bool {
matches!(
self.config.relayer_mode,
RelayerMode::Auto | RelayerMode::Managed
) && !self.config.cosigner_url.is_empty()
&& !self.config.polynode_key.is_empty()
}
async fn provision_relayer_key(&self, signer: &dyn TradingSigner) -> Result<()> {
let nonce_resp = self
.http
.get("https://gamma-api.polymarket.com/nonce")
.send()
.await
.map_err(|e| Error::Trading(format!("relayer-key nonce request failed: {}", e)))?;
if !nonce_resp.status().is_success() {
return Err(Error::Trading(format!(
"relayer-key nonce status {}",
nonce_resp.status()
)));
}
let nonce_json: serde_json::Value = nonce_resp
.json()
.await
.map_err(|e| Error::Trading(format!("relayer-key nonce parse failed: {}", e)))?;
let nonce = nonce_json
.get("nonce")
.and_then(|v| v.as_str())
.ok_or_else(|| Error::Trading("relayer-key nonce missing".into()))?;
let now = chrono::Utc::now();
let expires = now + chrono::Duration::days(7);
let address = format!("{}", signer.address());
let fields = serde_json::json!({
"domain": "polymarket.com",
"address": address,
"statement": "Welcome to Polymarket! Sign to connect.",
"uri": "https://polymarket.com",
"version": "1",
"chainId": CHAIN_ID,
"nonce": nonce,
"issuedAt": now.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
"expirationTime": expires.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
});
let message = [
format!(
"{} wants you to sign in with your Ethereum account:",
fields["domain"].as_str().unwrap_or("polymarket.com")
),
fields["address"].as_str().unwrap_or("").to_string(),
String::new(),
fields["statement"].as_str().unwrap_or("").to_string(),
String::new(),
format!(
"URI: {}",
fields["uri"].as_str().unwrap_or("https://polymarket.com")
),
format!("Version: {}", fields["version"].as_str().unwrap_or("1")),
format!("Chain ID: {}", CHAIN_ID),
format!("Nonce: {}", nonce),
format!("Issued At: {}", fields["issuedAt"].as_str().unwrap_or("")),
format!(
"Expiration Time: {}",
fields["expirationTime"].as_str().unwrap_or("")
),
]
.join("\n");
let sig = signer.sign_message(message.as_bytes()).await?;
let signature = signature_hex_27_28(&sig)?;
let resp = self
.http
.post(format!(
"{}/relayer-key",
self.config.cosigner_url.trim_end_matches('/')
))
.header("Content-Type", "application/json")
.header("X-PolyNode-Key", &self.config.polynode_key)
.json(&serde_json::json!({
"fields": fields,
"signature": signature,
}))
.send()
.await
.map_err(|e| Error::Trading(format!("relayer-key request failed: {}", e)))?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(Error::Trading(format!(
"relayer-key status {}: {}",
status, text
)));
}
Ok(())
}
async fn execute_safe_for_configured(
&self,
signer: &dyn TradingSigner,
safe_addr: Address,
txns: Vec<relayer::SafeSubTx>,
) -> Result<String> {
if self.config.relayer_mode == RelayerMode::DirectRpc {
self.execute_safe_direct_for(signer, safe_addr, txns).await
} else {
let rc = self.relayer_client()?;
rc.execute_safe_for(signer, safe_addr, txns).await
}
}
async fn execute_safe_direct_for(
&self,
signer: &dyn TradingSigner,
safe_addr: Address,
txns: Vec<relayer::SafeSubTx>,
) -> Result<String> {
let nonce_selector = keccak256(b"nonce()").as_slice()[..4].to_vec();
let nonce_u256 =
onboarding::eth_call_u256(&self.http, &self.config.rpc_url, safe_addr, nonce_selector)
.await?;
let nonce = u64::try_from(nonce_u256)
.map_err(|_| Error::Trading("Safe nonce does not fit in u64".into()))?;
let data = relayer::build_safe_exec_transaction_calldata(
signer,
safe_addr,
txns,
nonce,
CHAIN_ID as u64,
)
.await?;
self.send_raw_tx_with_gas_limit(
signer,
&self.http,
&self.config.rpc_url,
safe_addr,
alloy_primitives::U256::ZERO,
&data,
800_000,
)
.await
}
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 existing = {
let db = self.get_db_mut()?;
db.get_credentials(&format!("{}", eoa))?
};
let (sig_type, funder_address, resolve_action) = resolve_wallet_selection(
eoa,
opts.as_ref().and_then(|o| o.signature_type),
opts.as_ref().and_then(|o| o.funder_address.as_deref()),
existing.as_ref(),
self.config.default_signature_type,
&self.config.rpc_url,
)
.await?;
if let Some(action) = resolve_action {
actions.push(action);
}
if self.managed_relayer_available() {
match self.provision_relayer_key(signer.as_ref()).await {
Ok(()) => actions.push("relayer_key_provisioned".into()),
Err(e) => actions.push(format!("relayer_key_skipped: {}", e)),
}
}
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::PolyGnosisSafe && !approvals_set {
let approvals_ready = match onboarding::check_approvals(
funder_address,
&self.config.rpc_url,
self.config.exchange_version,
)
.await
{
Ok(status)
if smart_wallet_trade_approvals_ready(
&status,
funder_address,
&self.config.rpc_url,
self.config.exchange_version,
self.config.fee_config.is_some(),
)
.await =>
{
true
}
_ => false,
};
if approvals_ready {
approvals_set = true;
actions.push("approvals_already_set".into());
} else {
match self
.execute_safe_for_configured(
signer.as_ref(),
funder_address,
build_smart_wallet_approval_txs(
self.config.exchange_version,
self.config.fee_config.is_some(),
),
)
.await
{
Ok(tx) => {
actions.push(format!("safe_approvals_submitted: {}", tx));
safe_deployed = true;
match onboarding::check_approvals(
funder_address,
&self.config.rpc_url,
self.config.exchange_version,
)
.await
{
Ok(status)
if smart_wallet_trade_approvals_ready(
&status,
funder_address,
&self.config.rpc_url,
self.config.exchange_version,
self.config.fee_config.is_some(),
)
.await =>
{
approvals_set = true;
actions.push("safe_approvals_set".into());
}
_ => {
actions.push("safe_approvals_failed: not confirmed on-chain".into())
}
}
}
Err(e) => actions.push(format!("safe_approvals_failed: {}", e)),
}
}
}
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());
}
}
let mut deposit_create_submitted = false;
if !safe_deployed {
deposit_create_submitted = match self.relayer_client() {
Ok(rc) => match rc.submit_deposit_wallet_create(signer.as_ref()).await {
Ok(tx_id) => {
actions.push(format!("deposit_wallet_create_submitted: {}", tx_id));
true
}
Err(e) => {
actions.push(format!("deposit_wallet_create_relayer_error: {}", e));
false
}
},
Err(e) => {
actions.push(format!("deposit_wallet_needs_deployment_no_relayer: {}", e));
false
}
};
}
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 && deposit_create_submitted {
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 {
match self.relayer_client() {
Ok(rc) => 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));
}
},
Err(e) => {
actions.push(format!("deposit_wallet_approvals_needed_no_relayer: {}", e))
}
}
}
}
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 existing = {
let db = self.get_db_mut()?;
db.get_credentials(&format!("{}", eoa))?
};
let (sig_type, funder_address, _) = resolve_wallet_selection(
eoa,
opts.as_ref().and_then(|o| o.signature_type),
opts.as_ref().and_then(|o| o.funder_address.as_deref()),
existing.as_ref(),
self.config.default_signature_type,
&self.config.rpc_url,
)
.await?;
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 rc = self.relayer_client()?;
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 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 self
.execute_safe_for_configured(signer.as_ref(), funder, 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);
let allowance = onboarding::eth_call_u256(
&self.http,
&self.config.rpc_url,
polyusd_addr,
onboarding::encode_allowance(funder, offramp_addr),
)
.await
.unwrap_or(alloy_primitives::U256::ZERO);
if creds.signature_type == SignatureType::Poly1271 {
let rc = self.relayer_client()?;
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_deposit_wallet_calls(signer.as_ref(), funder, txns)
.await;
}
if matches!(
creds.signature_type,
SignatureType::PolyGnosisSafe | SignatureType::PolyProxy
) {
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 self
.execute_safe_for_configured(signer.as_ref(), funder, 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> {
self.send_raw_tx_with_gas_limit(signer, client, rpc_url, to, value, data, 150_000)
.await
}
async fn send_raw_tx_with_gas_limit(
&self,
signer: &dyn TradingSigner,
client: &reqwest::Client,
rpc_url: &str,
to: Address,
value: alloy_primitives::U256,
data: &[u8],
gas_limit: u64,
) -> 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 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, mut params: OrderParams) -> Result<OrderResult> {
apply_order_config_defaults(&mut params, &self.config);
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)
}
fn smart_wallet_trade_approvals_set(status: &ApprovalStatus, require_fee_escrow: bool) -> 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
&& (!require_fee_escrow || status.usdc.fee_escrow)
}
async fn smart_wallet_trade_approvals_ready(
status: &ApprovalStatus,
funder_address: Address,
rpc_url: &str,
exchange_version: ExchangeVersion,
require_fee_escrow: bool,
) -> bool {
if !smart_wallet_trade_approvals_set(status, require_fee_escrow) {
return false;
}
if exchange_version != ExchangeVersion::V2 {
return true;
}
onboarding::check_usdce_onramp_approval(funder_address, rpc_url)
.await
.unwrap_or(false)
}
fn build_smart_wallet_approval_txs(
exchange_version: ExchangeVersion,
include_fee_escrow: bool,
) -> Vec<relayer::SafeSubTx> {
let max_u256 = alloy_primitives::U256::MAX;
let zero = alloy_primitives::U256::ZERO;
let collateral: Address = match exchange_version {
ExchangeVersion::V1 => constants::USDC,
ExchangeVersion::V2 => constants_v2::POLY_USD,
}
.parse()
.unwrap();
let ctf: Address = constants::CTF.parse().unwrap();
let spenders: &[&str] = match exchange_version {
ExchangeVersion::V1 => &constants::SPENDERS,
ExchangeVersion::V2 => &constants_v2::V2_SPENDERS[..3],
};
let mut txns = Vec::new();
if exchange_version == ExchangeVersion::V2 {
let usdc: Address = constants::USDC.parse().unwrap();
let onramp: Address = constants_v2::COLLATERAL_ONRAMP.parse().unwrap();
txns.push(relayer::SafeSubTx {
to: usdc,
value: zero,
data: onboarding::encode_approve(onramp, max_u256),
operation: 0,
});
}
for spender_str in spenders {
let spender: Address = spender_str.parse().unwrap();
txns.push(relayer::SafeSubTx {
to: collateral,
value: zero,
data: onboarding::encode_approve(spender, max_u256),
operation: 0,
});
txns.push(relayer::SafeSubTx {
to: ctf,
value: zero,
data: onboarding::encode_set_approval_for_all(spender, true),
operation: 0,
});
}
if include_fee_escrow {
let escrow: Address = match exchange_version {
ExchangeVersion::V1 => constants::FEE_ESCROW_ADDRESS,
ExchangeVersion::V2 => constants::FEE_ESCROW_ADDRESS_V2,
}
.parse()
.unwrap();
txns.push(relayer::SafeSubTx {
to: collateral,
value: zero,
data: onboarding::encode_approve(escrow, max_u256),
operation: 0,
});
}
txns
}
async fn resolve_wallet_selection(
eoa: Address,
explicit_signature_type: Option<SignatureType>,
explicit_funder_address: Option<&str>,
existing: Option<&StoredCredentials>,
default_signature_type: SignatureType,
rpc_url: &str,
) -> Result<(SignatureType, Address, Option<String>)> {
if let Some(sig_type) = explicit_signature_type {
let funder = match explicit_funder_address {
Some(addr) => parse_wallet_address(addr, "funder_address")?,
None => derive_funder_address(eoa, sig_type),
};
return Ok((
sig_type,
funder,
Some(format!("explicit_type_{}", sig_type.as_u8())),
));
}
if explicit_funder_address.is_some() {
return Err(Error::Trading(
"funder_address requires signature_type so wallet identity is unambiguous".into(),
));
}
if let Some(existing) = existing {
let funder = match existing.funder_address.as_deref() {
Some(addr) => parse_wallet_address(addr, "stored funder_address")?,
None => derive_funder_address(eoa, existing.signature_type),
};
return Ok((
existing.signature_type,
funder,
Some(format!("stored_type_{}", existing.signature_type.as_u8())),
));
}
if default_signature_type == SignatureType::Eoa {
return Ok((SignatureType::Eoa, eoa, Some("default_type_0".into())));
}
let safe_addr = derive_safe_address(eoa);
let proxy_addr = derive_proxy_address(eoa);
let deposit_wallet_addr = onboarding::derive_deposit_wallet_address(eoa);
let (safe_deployed, proxy_deployed, deposit_wallet_deployed) = tokio::join!(
is_safe_deployed(safe_addr),
is_safe_deployed(proxy_addr),
onboarding::is_contract_deployed(deposit_wallet_addr, rpc_url),
);
let safe_deployed = safe_deployed.unwrap_or(false);
let proxy_deployed = proxy_deployed.unwrap_or(false);
let deposit_wallet_deployed = deposit_wallet_deployed.unwrap_or(false);
let deployed = |sig_type: SignatureType| -> bool {
match sig_type {
SignatureType::Eoa => true,
SignatureType::PolyGnosisSafe => safe_deployed,
SignatureType::PolyProxy => proxy_deployed,
SignatureType::Poly1271 => deposit_wallet_deployed,
}
};
if deployed(default_signature_type) {
return Ok((
default_signature_type,
derive_funder_address(eoa, default_signature_type),
Some(format!(
"auto_detected_type_{}",
default_signature_type.as_u8()
)),
));
}
if safe_deployed {
return Ok((
SignatureType::PolyGnosisSafe,
safe_addr,
Some("auto_detected_type_2".into()),
));
}
if proxy_deployed {
return Ok((
SignatureType::PolyProxy,
proxy_addr,
Some("auto_detected_type_1".into()),
));
}
if deposit_wallet_deployed {
return Ok((
SignatureType::Poly1271,
deposit_wallet_addr,
Some("auto_detected_type_3".into()),
));
}
Ok((
default_signature_type,
derive_funder_address(eoa, default_signature_type),
Some(format!("default_type_{}", default_signature_type.as_u8())),
))
}
fn parse_wallet_address(addr: &str, label: &str) -> Result<Address> {
addr.parse::<Address>()
.map_err(|_| Error::Trading(format!("invalid {}: {}", label, addr)))
}
#[derive(Debug, Clone, Default)]
pub struct EnsureReadyOpts {
pub signature_type: Option<SignatureType>,
pub funder_address: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct LinkOpts {
pub signature_type: Option<SignatureType>,
pub funder_address: Option<String>,
}
#[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()))
}
fn apply_order_config_defaults(params: &mut OrderParams, config: &TraderConfig) {
if config.exchange_version == ExchangeVersion::V2 && params.builder.is_none() {
params.builder = config.builder_code.clone();
}
}
fn signature_hex_27_28(sig_bytes: &[u8]) -> Result<String> {
if sig_bytes.len() != 65 {
return Err(Error::Trading(format!(
"expected 65-byte signature, got {}",
sig_bytes.len()
)));
}
let mut out = sig_bytes.to_vec();
out[64] = match out[64] {
0 | 1 => out[64] + 27,
27 | 28 => out[64],
v => return Err(Error::Trading(format!("invalid signature v: {}", v))),
};
Ok(format!("0x{}", hex::encode(out)))
}
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());
}
#[test]
fn signature_hex_27_28_normalizes_recovery_byte() {
let mut sig = [0u8; 65];
sig[64] = 0;
assert!(signature_hex_27_28(&sig).unwrap().ends_with("1b"));
sig[64] = 1;
assert!(signature_hex_27_28(&sig).unwrap().ends_with("1c"));
sig[64] = 29;
assert!(signature_hex_27_28(&sig).is_err());
}
#[test]
fn v2_order_uses_config_builder_code_when_omitted() {
let mut params = OrderParams {
token_id: "123456789".into(),
side: OrderSide::Buy,
price: 0.5,
size: 10.0,
..Default::default()
};
let builder_code = format!("0x{}", "11".repeat(32));
let config = TraderConfig {
exchange_version: ExchangeVersion::V2,
builder_code: Some(builder_code.clone()),
..Default::default()
};
apply_order_config_defaults(&mut params, &config);
assert_eq!(params.builder.as_deref(), Some(builder_code.as_str()));
}
#[test]
fn explicit_order_builder_overrides_config_builder_code() {
let explicit = format!("0x{}", "22".repeat(32));
let mut params = OrderParams {
token_id: "123456789".into(),
side: OrderSide::Buy,
price: 0.5,
size: 10.0,
builder: Some(explicit.clone()),
..Default::default()
};
let config = TraderConfig {
exchange_version: ExchangeVersion::V2,
builder_code: Some(format!("0x{}", "11".repeat(32))),
..Default::default()
};
apply_order_config_defaults(&mut params, &config);
assert_eq!(params.builder.as_deref(), Some(explicit.as_str()));
}
#[test]
fn relayer_client_auto_accepts_managed_config_without_builder_credentials() {
let trader = PolyNodeTrader::new(TraderConfig {
polynode_key: "pn_live_test".into(),
cosigner_url: "https://trade.polynode.dev".into(),
builder_credentials: None,
relayer_mode: RelayerMode::Auto,
..Default::default()
})
.unwrap();
assert!(trader.relayer_client().is_ok());
}
#[test]
fn relayer_client_auto_accepts_local_builder_credentials() {
let trader = PolyNodeTrader::new(TraderConfig {
polynode_key: String::new(),
cosigner_url: String::new(),
builder_credentials: Some(BuilderCredentials {
key: "builder-key".into(),
secret: "c2VjcmV0".into(),
passphrase: "pass".into(),
}),
relayer_mode: RelayerMode::Auto,
..Default::default()
})
.unwrap();
assert!(trader.relayer_client().is_ok());
}
#[test]
fn relayer_client_auto_errors_without_managed_or_builder_auth() {
let trader = PolyNodeTrader::new(TraderConfig {
polynode_key: String::new(),
cosigner_url: String::new(),
builder_credentials: None,
relayer_mode: RelayerMode::Auto,
..Default::default()
})
.unwrap();
assert!(trader.relayer_client().is_err());
}
#[test]
fn relayer_client_direct_rpc_is_not_a_relayer_client() {
let trader = PolyNodeTrader::new(TraderConfig {
relayer_mode: RelayerMode::DirectRpc,
..Default::default()
})
.unwrap();
let err = match trader.relayer_client() {
Ok(_) => panic!("DirectRpc should not build a relayer client"),
Err(e) => e,
};
assert!(format!("{:?}", err).contains("DirectRpc"));
}
#[tokio::test]
async fn wallet_selection_uses_explicit_funder() {
let eoa: Address = "0x7A34f90642892d8f4cFcd67664e8aaE9F5626e76"
.parse()
.unwrap();
let safe: Address = "0x8E6741Bb93CA02D03d32da4fa0E4263EB9b32148"
.parse()
.unwrap();
let (sig_type, funder, action) = resolve_wallet_selection(
eoa,
Some(SignatureType::PolyGnosisSafe),
Some("0x8E6741Bb93CA02D03d32da4fa0E4263EB9b32148"),
None,
SignatureType::Poly1271,
constants::DEFAULT_RPC,
)
.await
.unwrap();
assert_eq!(sig_type, SignatureType::PolyGnosisSafe);
assert_eq!(funder, safe);
assert_eq!(action.as_deref(), Some("explicit_type_2"));
}
#[tokio::test]
async fn wallet_selection_prefers_stored_credentials_without_explicit_opts() {
let eoa: Address = "0x7A34f90642892d8f4cFcd67664e8aaE9F5626e76"
.parse()
.unwrap();
let deposit_wallet = "0x8E6741Bb93CA02D03d32da4fa0E4263EB9b32148".to_string();
let existing = StoredCredentials {
wallet_address: format!("{}", eoa),
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 (sig_type, funder, action) = resolve_wallet_selection(
eoa,
None,
None,
Some(&existing),
SignatureType::PolyGnosisSafe,
constants::DEFAULT_RPC,
)
.await
.unwrap();
assert_eq!(sig_type, SignatureType::Poly1271);
assert_eq!(format!("{}", funder), deposit_wallet);
assert_eq!(action.as_deref(), Some("stored_type_3"));
}
#[tokio::test]
async fn wallet_selection_rejects_funder_without_type() {
let eoa: Address = "0x7A34f90642892d8f4cFcd67664e8aaE9F5626e76"
.parse()
.unwrap();
let result = resolve_wallet_selection(
eoa,
None,
Some("0x8E6741Bb93CA02D03d32da4fa0E4263EB9b32148"),
None,
SignatureType::PolyGnosisSafe,
constants::DEFAULT_RPC,
)
.await;
assert!(result.is_err());
}
}