o2_sdk/
client.rs

1/// High-level O2Client that orchestrates the full trading workflow.
2///
3/// This is the primary entry point for SDK users. It handles wallet management,
4/// account lifecycle, session management, order placement, and WebSocket streaming.
5use std::collections::HashMap;
6use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
7
8use log::debug;
9
10use crate::api::O2Api;
11use crate::config::{Network, NetworkConfig};
12use crate::crypto::SignableWallet;
13use crate::crypto::{
14    generate_evm_keypair, generate_keypair, load_evm_wallet, load_wallet, parse_hex_32, raw_sign,
15    to_hex_string, EvmWallet, Wallet,
16};
17use crate::encoding::{
18    build_actions_signing_bytes, build_session_signing_bytes, build_withdraw_signing_bytes, CallArg,
19};
20use crate::errors::O2Error;
21use crate::models::*;
22use crate::websocket::{DepthPrecision, TypedStream};
23
24/// Strategy for refreshing market metadata.
25#[derive(Debug, Clone, Copy)]
26pub enum MetadataPolicy {
27    /// Reuse cached metadata and refresh only when cache age exceeds `ttl`.
28    OptimisticTtl(Duration),
29    /// Always refresh metadata before reads that depend on market config.
30    StrictFresh,
31}
32
33impl Default for MetadataPolicy {
34    fn default() -> Self {
35        Self::OptimisticTtl(Duration::from_secs(45))
36    }
37}
38
39/// Validate that a REST depth precision value is within the supported range (1–18).
40fn validate_depth_precision(precision: u64) -> Result<(), O2Error> {
41    if !(1..=18).contains(&precision) {
42        return Err(O2Error::InvalidRequest(format!(
43            "Invalid depth precision {}. Valid range: 1-18 (powers of 10). \
44             Precision 0 is not supported — use get_depth() via REST for exact prices.",
45            precision
46        )));
47    }
48    Ok(())
49}
50
51/// The high-level O2 Exchange client.
52pub struct O2Client {
53    pub api: O2Api,
54    pub config: NetworkConfig,
55    markets_cache: Option<MarketsResponse>,
56    markets_cache_at: Option<Instant>,
57    metadata_policy: MetadataPolicy,
58    ws: tokio::sync::Mutex<Option<crate::websocket::O2WebSocket>>,
59}
60
61/// Builder for composing a batch of actions against a single market.
62///
63/// Construct via [`O2Client::actions_for`]. Builder methods are infallible and
64/// defer validation errors until [`MarketActionsBuilder::build`].
65#[derive(Debug)]
66pub struct MarketActionsBuilder {
67    market: Market,
68    actions: Vec<Action>,
69    first_error: Option<O2Error>,
70}
71
72impl MarketActionsBuilder {
73    fn new(market: Market) -> Self {
74        Self {
75            market,
76            actions: Vec::new(),
77            first_error: None,
78        }
79    }
80
81    fn record_error_once(&mut self, err: O2Error) {
82        if self.first_error.is_none() {
83            self.first_error = Some(err);
84        }
85    }
86
87    /// Add a settle-balance action.
88    pub fn settle_balance(mut self) -> Self {
89        self.actions.push(Action::SettleBalance);
90        self
91    }
92
93    /// Add a cancel-order action.
94    pub fn cancel_order(mut self, order_id: impl IntoValidId<OrderId>) -> Self {
95        match order_id.into_valid() {
96            Ok(id) => self.actions.push(Action::CancelOrder { order_id: id }),
97            Err(e) => self.record_error_once(e),
98        }
99        self
100    }
101
102    /// Add a create-order action.
103    ///
104    /// Accepts the same flexible price/quantity inputs as [`O2Client::create_order`]:
105    /// typed wrappers, `UnsignedDecimal`, and decimal strings.
106    pub fn create_order<P, Q>(
107        mut self,
108        side: Side,
109        price: P,
110        quantity: Q,
111        order_type: OrderType,
112    ) -> Self
113    where
114        P: TryInto<OrderPriceInput, Error = O2Error>,
115        Q: TryInto<OrderQuantityInput, Error = O2Error>,
116    {
117        if self.first_error.is_some() {
118            return self;
119        }
120
121        let price = match price.try_into() {
122            Ok(OrderPriceInput::Unchecked(v)) => v,
123            Ok(OrderPriceInput::Checked(v)) => match self.market.validate_price_binding(&v) {
124                Ok(()) => v.value(),
125                Err(e) => {
126                    self.record_error_once(e);
127                    return self;
128                }
129            },
130            Err(e) => {
131                self.record_error_once(e);
132                return self;
133            }
134        };
135
136        let quantity = match quantity.try_into() {
137            Ok(OrderQuantityInput::Unchecked(v)) => v,
138            Ok(OrderQuantityInput::Checked(v)) => match self.market.validate_quantity_binding(&v) {
139                Ok(()) => v.value(),
140                Err(e) => {
141                    self.record_error_once(e);
142                    return self;
143                }
144            },
145            Err(e) => {
146                self.record_error_once(e);
147                return self;
148            }
149        };
150
151        self.actions.push(Action::CreateOrder {
152            side,
153            price,
154            quantity,
155            order_type,
156        });
157        self
158    }
159
160    /// Finalize and return the action list.
161    ///
162    /// Returns the first validation/conversion error encountered while building.
163    pub fn build(self) -> Result<Vec<Action>, O2Error> {
164        if let Some(err) = self.first_error {
165            Err(err)
166        } else {
167            Ok(self.actions)
168        }
169    }
170}
171
172impl O2Client {
173    fn should_whitelist_account(&self) -> bool {
174        self.config.whitelist_required
175    }
176
177    #[cfg(test)]
178    fn parse_nonce_value(value: &str, context: &str) -> Result<u64, O2Error> {
179        if let Some(hex) = value
180            .strip_prefix("0x")
181            .or_else(|| value.strip_prefix("0X"))
182        {
183            return u64::from_str_radix(hex, 16).map_err(|e| {
184                O2Error::ParseError(format!("Invalid hex nonce in {context}: '{value}' ({e})"))
185            });
186        }
187
188        value.parse::<u64>().map_err(|e| {
189            O2Error::ParseError(format!(
190                "Invalid decimal nonce in {context}: '{value}' ({e})"
191            ))
192        })
193    }
194
195    fn parse_account_nonce(raw_nonce: Option<u64>, _context: &str) -> Result<u64, O2Error> {
196        match raw_nonce {
197            Some(v) => Ok(v),
198            None => Ok(0),
199        }
200    }
201
202    async fn retry_whitelist_account(&self, trade_account_id: &str) -> bool {
203        debug!("client.retry_whitelist_account trade_account_id={trade_account_id}");
204        // Whitelist is network-gated, not hostname-gated.
205        if !self.should_whitelist_account() {
206            debug!("client.retry_whitelist_account skipped (non-testnet)");
207            return true;
208        }
209
210        let delays_secs = [0u64, 2, 5];
211        let mut last_error = String::new();
212
213        for (idx, delay) in delays_secs.iter().enumerate() {
214            if *delay > 0 {
215                tokio::time::sleep(std::time::Duration::from_secs(*delay)).await;
216            }
217
218            match self.api.whitelist_account(trade_account_id).await {
219                Ok(_) => {
220                    debug!(
221                        "client.retry_whitelist_account success attempt={} trade_account_id={}",
222                        idx + 1,
223                        trade_account_id
224                    );
225                    return true;
226                }
227                Err(e) => {
228                    last_error = e.to_string();
229                    if idx < delays_secs.len() - 1 {
230                        eprintln!(
231                            "whitelist_account attempt {} failed for {}: {} (retrying)",
232                            idx + 1,
233                            trade_account_id,
234                            last_error
235                        );
236                    }
237                }
238            }
239        }
240
241        eprintln!(
242            "whitelist_account failed after {} attempts for {}: {}",
243            delays_secs.len(),
244            trade_account_id,
245            last_error
246        );
247        false
248    }
249
250    async fn retry_mint_to_contract(&self, trade_account_id: &str) -> bool {
251        debug!("client.retry_mint_to_contract trade_account_id={trade_account_id}");
252        // Faucet currently exists only on non-mainnet configs.
253        if self.config.faucet_url.is_none() {
254            debug!("client.retry_mint_to_contract skipped (no faucet url)");
255            return true;
256        }
257
258        // Attempt immediately, then retry with cooldown-aware waits.
259        let attempts = 4usize;
260        let mut last_error = String::new();
261
262        for idx in 0..attempts {
263            if idx > 0 {
264                let lower = last_error.to_ascii_lowercase();
265                let wait_secs = if lower.contains("cooldown")
266                    || lower.contains("rate limit")
267                    || lower.contains("too many")
268                {
269                    65
270                } else {
271                    5
272                };
273                tokio::time::sleep(std::time::Duration::from_secs(wait_secs)).await;
274            }
275
276            match self.api.mint_to_contract(trade_account_id).await {
277                Ok(resp) if resp.error.is_none() => {
278                    debug!(
279                        "client.retry_mint_to_contract success attempt={} trade_account_id={}",
280                        idx + 1,
281                        trade_account_id
282                    );
283                    return true;
284                }
285                Ok(resp) => {
286                    last_error = resp
287                        .error
288                        .unwrap_or_else(|| "faucet returned an unknown error".to_string());
289                    if idx < attempts - 1 {
290                        eprintln!(
291                            "mint_to_contract attempt {} returned error for {}: {} (retrying)",
292                            idx + 1,
293                            trade_account_id,
294                            last_error
295                        );
296                    }
297                }
298                Err(e) => {
299                    last_error = e.to_string();
300                    if idx < attempts - 1 {
301                        eprintln!(
302                            "mint_to_contract attempt {} failed for {}: {} (retrying)",
303                            idx + 1,
304                            trade_account_id,
305                            last_error
306                        );
307                    }
308                }
309            }
310        }
311
312        eprintln!(
313            "mint_to_contract failed after {} attempts for {}: {}",
314            attempts, trade_account_id, last_error
315        );
316        false
317    }
318
319    async fn should_faucet_account(&mut self, trade_account_id: &str) -> bool {
320        let account_id = TradeAccountId::new(trade_account_id);
321        match self.get_balances(&account_id).await {
322            Ok(balances) => {
323                let has_non_zero_balance = balances.values().any(|balance| {
324                    balance.trading_account_balance > 0
325                        || balance.total_locked > 0
326                        || balance.total_unlocked > 0
327                });
328                debug!(
329                    "client.should_faucet_account trade_account_id={} assets={} has_non_zero_balance={}",
330                    trade_account_id,
331                    balances.len(),
332                    has_non_zero_balance
333                );
334                !has_non_zero_balance
335            }
336            Err(e) => {
337                debug!(
338                    "client.should_faucet_account balance_check_failed trade_account_id={} error={} fallback_should_faucet=true",
339                    trade_account_id, e
340                );
341                true
342            }
343        }
344    }
345
346    /// Create a new O2Client for the given network.
347    pub fn new(network: Network) -> Self {
348        let config = NetworkConfig::from_network(network);
349        Self {
350            api: O2Api::new(config.clone()),
351            config,
352            markets_cache: None,
353            markets_cache_at: None,
354            metadata_policy: MetadataPolicy::default(),
355            ws: tokio::sync::Mutex::new(None),
356        }
357    }
358
359    /// Create a new O2Client with a custom configuration.
360    pub fn with_config(config: NetworkConfig) -> Self {
361        Self {
362            api: O2Api::new(config.clone()),
363            config,
364            markets_cache: None,
365            markets_cache_at: None,
366            metadata_policy: MetadataPolicy::default(),
367            ws: tokio::sync::Mutex::new(None),
368        }
369    }
370
371    /// Configure how market metadata should be refreshed.
372    pub fn set_metadata_policy(&mut self, policy: MetadataPolicy) {
373        self.metadata_policy = policy;
374    }
375
376    // -----------------------------------------------------------------------
377    // Wallet Management
378    // -----------------------------------------------------------------------
379
380    /// Generate a new Fuel-native wallet.
381    pub fn generate_wallet(&self) -> Result<Wallet, O2Error> {
382        debug!("client.generate_wallet");
383        generate_keypair()
384    }
385
386    /// Generate a new EVM-compatible wallet.
387    pub fn generate_evm_wallet(&self) -> Result<EvmWallet, O2Error> {
388        debug!("client.generate_evm_wallet");
389        generate_evm_keypair()
390    }
391
392    /// Load a Fuel-native wallet from a private key hex string.
393    pub fn load_wallet(&self, private_key_hex: &str) -> Result<Wallet, O2Error> {
394        debug!("client.load_wallet");
395        let key = parse_hex_32(private_key_hex)?;
396        load_wallet(&key)
397    }
398
399    /// Load an EVM wallet from a private key hex string.
400    pub fn load_evm_wallet(&self, private_key_hex: &str) -> Result<EvmWallet, O2Error> {
401        debug!("client.load_evm_wallet");
402        let key = parse_hex_32(private_key_hex)?;
403        load_evm_wallet(&key)
404    }
405
406    // -----------------------------------------------------------------------
407    // Market Resolution
408    // -----------------------------------------------------------------------
409
410    /// Fetch and cache markets.
411    pub async fn fetch_markets(&mut self) -> Result<&MarketsResponse, O2Error> {
412        debug!("client.fetch_markets");
413        let resp = self.api.get_markets().await?;
414        self.markets_cache = Some(resp);
415        self.markets_cache_at = Some(Instant::now());
416        Ok(self.markets_cache.as_ref().unwrap())
417    }
418
419    /// Get cached markets, fetching if needed.
420    async fn ensure_markets(&mut self) -> Result<&MarketsResponse, O2Error> {
421        if self.should_refresh_markets() {
422            debug!("client.ensure_markets refreshing cache");
423            self.fetch_markets().await?;
424        }
425        Ok(self.markets_cache.as_ref().unwrap())
426    }
427
428    fn should_refresh_markets(&self) -> bool {
429        if self.markets_cache.is_none() {
430            return true;
431        }
432
433        match self.metadata_policy {
434            MetadataPolicy::StrictFresh => true,
435            MetadataPolicy::OptimisticTtl(ttl) => match self.markets_cache_at {
436                None => true,
437                Some(fetched_at) => fetched_at.elapsed() >= ttl,
438            },
439        }
440    }
441
442    /// Get all markets.
443    pub async fn get_markets(&mut self) -> Result<Vec<Market>, O2Error> {
444        debug!("client.get_markets");
445        let resp = self.ensure_markets().await?;
446        Ok(resp.markets.clone())
447    }
448
449    /// Get a market by symbol pair (e.g., "FUEL/USDC").
450    pub async fn get_market<M>(&mut self, symbol: M) -> Result<Market, O2Error>
451    where
452        M: IntoMarketSymbol,
453    {
454        let symbol = symbol.into_market_symbol()?;
455        debug!("client.get_market symbol={symbol}");
456        let resp = self.ensure_markets().await?;
457        for market in &resp.markets {
458            if market.symbol_pair() == symbol {
459                return Ok(market.clone());
460            }
461        }
462        Err(O2Error::MarketNotFound(format!(
463            "No market found for pair: {}",
464            symbol
465        )))
466    }
467
468    /// Get a market by hex market ID.
469    pub async fn get_market_by_id(&mut self, market_id: &MarketId) -> Result<Market, O2Error> {
470        debug!("client.get_market_by_id market_id={market_id}");
471        let resp = self.ensure_markets().await?;
472        for market in &resp.markets {
473            if market.market_id == *market_id {
474                return Ok(market.clone());
475            }
476        }
477        Err(O2Error::MarketNotFound(format!(
478            "No market found for id: {}",
479            market_id
480        )))
481    }
482
483    /// Get the chain_id from cached markets.
484    async fn get_chain_id(&mut self) -> Result<u64, O2Error> {
485        let resp = self.ensure_markets().await?;
486        let chain_id_hex = resp.chain_id.as_str();
487        let stripped = chain_id_hex.strip_prefix("0x").unwrap_or(chain_id_hex);
488        u64::from_str_radix(stripped, 16)
489            .map_err(|e| O2Error::Other(format!("Failed to parse chain_id: {e}")))
490    }
491
492    // -----------------------------------------------------------------------
493    // Account Lifecycle
494    // -----------------------------------------------------------------------
495
496    /// Idempotent account setup: creates account, funds via faucet, whitelists.
497    /// Safe to call on every bot startup.
498    /// Works with both [`Wallet`] and [`EvmWallet`].
499    pub async fn setup_account<W: SignableWallet>(
500        &mut self,
501        wallet: &W,
502    ) -> Result<AccountResponse, O2Error> {
503        debug!("client.setup_account");
504        let owner_hex = to_hex_string(wallet.b256_address());
505
506        // 1. Check if account already exists
507        let existing = self.api.get_account_by_owner(&owner_hex).await?;
508        let trade_account_id = if existing.trade_account_id.is_some() {
509            existing.trade_account_id.clone().unwrap()
510        } else {
511            // 2. Create account
512            let created = self.api.create_account(&owner_hex).await?;
513            created.trade_account_id
514        };
515
516        // 3. Mint via faucet only when the account currently has no balances.
517        if self.should_faucet_account(trade_account_id.as_str()).await {
518            let _ = self.retry_mint_to_contract(trade_account_id.as_str()).await;
519        } else {
520            debug!(
521                "client.setup_account skipping_faucet trade_account_id={} (non-zero balance detected)",
522                trade_account_id
523            );
524        }
525
526        // 4. Whitelist account (testnet-only, non-fatal; retry for transient failures)
527        let _ = self
528            .retry_whitelist_account(trade_account_id.as_str())
529            .await;
530
531        // 5. Return current account state
532        self.api.get_account_by_id(trade_account_id.as_str()).await
533    }
534
535    /// Mint test assets from faucet directly to the owner's trading account contract.
536    ///
537    /// Useful for explicit testnet/devnet top-ups after account setup.
538    pub async fn top_up_from_faucet<W: SignableWallet>(
539        &self,
540        owner: &W,
541    ) -> Result<FaucetResponse, O2Error> {
542        let owner_hex = to_hex_string(owner.b256_address());
543        let account = self.api.get_account_by_owner(&owner_hex).await?;
544        let trade_account_id = account.trade_account_id.ok_or_else(|| {
545            O2Error::AccountNotFound("No trade account found. Call setup_account() first.".into())
546        })?;
547        self.api.mint_to_contract(trade_account_id.as_str()).await
548    }
549
550    // -----------------------------------------------------------------------
551    // Session Management
552    // -----------------------------------------------------------------------
553
554    /// Create a trading session with a relative TTL.
555    ///
556    /// Works with both [`Wallet`] (Fuel-native) and [`EvmWallet`].
557    pub async fn create_session<W: SignableWallet, S: AsRef<str>>(
558        &mut self,
559        owner: &W,
560        market_names: &[S],
561        ttl: Duration,
562    ) -> Result<Session, O2Error> {
563        let ttl_secs = ttl.as_secs();
564        if ttl_secs == 0 {
565            return Err(O2Error::InvalidSession(
566                "Session TTL must be greater than zero seconds".into(),
567            ));
568        }
569
570        let now = SystemTime::now()
571            .duration_since(UNIX_EPOCH)
572            .unwrap()
573            .as_secs();
574        let expiry = now
575            .checked_add(ttl_secs)
576            .ok_or_else(|| O2Error::InvalidSession("Session TTL overflow".into()))?;
577
578        self.create_session_until(owner, market_names, expiry).await
579    }
580
581    /// Create a trading session that expires at an absolute UNIX timestamp.
582    ///
583    /// Works with both [`Wallet`] (Fuel-native) and [`EvmWallet`].
584    pub async fn create_session_until<W: SignableWallet, S: AsRef<str>>(
585        &mut self,
586        owner: &W,
587        market_names: &[S],
588        expiry_unix_secs: u64,
589    ) -> Result<Session, O2Error> {
590        debug!(
591            "client.create_session_until markets={} expiry_unix_secs={}",
592            market_names.len(),
593            expiry_unix_secs
594        );
595        let owner_hex = to_hex_string(owner.b256_address());
596
597        // Resolve market names to contract_ids
598        let mut contract_ids_hex = Vec::new();
599        let mut contract_ids_bytes = Vec::new();
600        for name in market_names {
601            let market = self.get_market(name.as_ref()).await?;
602            contract_ids_hex.push(market.contract_id.clone());
603            contract_ids_bytes.push(parse_hex_32(&market.contract_id)?);
604        }
605
606        let chain_id = self.get_chain_id().await?;
607
608        // Get current nonce
609        let account = self.api.get_account_by_owner(&owner_hex).await?;
610        let trade_account_id = account
611            .trade_account_id
612            .clone()
613            .ok_or_else(|| O2Error::AccountNotFound("No trade_account_id found".into()))?;
614
615        let nonce = Self::parse_account_nonce(
616            account.trade_account.as_ref().map(|ta| ta.nonce),
617            "create_session account response",
618        )?;
619
620        // Generate session keypair
621        let session_wallet = generate_keypair()?;
622
623        // Build signing bytes
624        let signing_bytes = build_session_signing_bytes(
625            nonce,
626            chain_id,
627            &session_wallet.b256_address,
628            &contract_ids_bytes,
629            expiry_unix_secs,
630        );
631
632        // Sign with owner wallet (dispatches to Fuel or EVM personal_sign)
633        let signature = owner.personal_sign(&signing_bytes)?;
634        let sig_hex = to_hex_string(&signature);
635
636        // Submit session
637        let request = SessionRequest {
638            contract_id: trade_account_id.clone(),
639            session_id: Identity::Address(to_hex_string(&session_wallet.b256_address)),
640            signature: Signature::Secp256k1(sig_hex),
641            contract_ids: contract_ids_hex.clone(),
642            nonce: nonce.to_string(),
643            expiry: expiry_unix_secs.to_string(),
644        };
645
646        let _resp = self.api.create_session(&owner_hex, &request).await?;
647
648        Ok(Session {
649            owner_address: *owner.b256_address(),
650            session_private_key: session_wallet.private_key,
651            session_address: session_wallet.b256_address,
652            trade_account_id,
653            contract_ids: contract_ids_hex,
654            expiry: expiry_unix_secs,
655            nonce: nonce + 1,
656        })
657    }
658
659    // -----------------------------------------------------------------------
660    // Trading
661    // -----------------------------------------------------------------------
662
663    /// Create a single-market action builder with normalized market context.
664    pub async fn actions_for<M>(&mut self, market_name: M) -> Result<MarketActionsBuilder, O2Error>
665    where
666        M: IntoMarketSymbol,
667    {
668        let market_name = market_name.into_market_symbol()?;
669        debug!("client.actions_for market={}", market_name);
670        let market = self.get_market(&market_name).await?;
671        Ok(MarketActionsBuilder::new(market))
672    }
673
674    /// Check if a session has expired and return an error if so.
675    fn check_session_expiry(session: &Session) -> Result<(), O2Error> {
676        let now = SystemTime::now()
677            .duration_since(UNIX_EPOCH)
678            .unwrap()
679            .as_secs();
680        if session.expiry > 0 && now >= session.expiry {
681            return Err(O2Error::SessionExpired(
682                "Session has expired. Create a new session before submitting actions.".into(),
683            ));
684        }
685        Ok(())
686    }
687
688    /// Place a new order.
689    ///
690    /// `price` and `quantity` accept flexible inputs:
691    /// - typed market-bound wrappers: [`Price`], [`Quantity`]
692    /// - raw decimals: [`crate::UnsignedDecimal`]
693    /// - decimal strings: `&str` / `String`
694    ///
695    /// If `settle_first` is true, a SettleBalance action is prepended.
696    #[allow(clippy::too_many_arguments)]
697    pub async fn create_order<M, P, Q>(
698        &mut self,
699        session: &mut Session,
700        market_name: M,
701        side: Side,
702        price: P,
703        quantity: Q,
704        order_type: OrderType,
705        settle_first: bool,
706        collect_orders: bool,
707    ) -> Result<SessionActionsResponse, O2Error>
708    where
709        M: IntoMarketSymbol,
710        P: TryInto<OrderPriceInput, Error = O2Error>,
711        Q: TryInto<OrderQuantityInput, Error = O2Error>,
712    {
713        let market_name = market_name.into_market_symbol()?;
714        debug!(
715            "client.create_order market={} settle_first={} collect_orders={}",
716            market_name, settle_first, collect_orders
717        );
718        let market = self.get_market(&market_name).await?;
719
720        let price = match price.try_into()? {
721            OrderPriceInput::Unchecked(v) => v,
722            OrderPriceInput::Checked(v) => {
723                market.validate_price_binding(&v)?;
724                v.value()
725            }
726        };
727
728        let quantity = match quantity.try_into()? {
729            OrderQuantityInput::Unchecked(v) => v,
730            OrderQuantityInput::Checked(v) => {
731                market.validate_quantity_binding(&v)?;
732                v.value()
733            }
734        };
735
736        let mut actions = Vec::new();
737        if settle_first {
738            actions.push(Action::SettleBalance);
739        }
740        actions.push(Action::CreateOrder {
741            side,
742            price,
743            quantity,
744            order_type,
745        });
746        self.batch_actions(session, market.symbol_pair(), actions, collect_orders)
747            .await
748    }
749
750    /// Cancel an order by order_id.
751    pub async fn cancel_order<M>(
752        &mut self,
753        session: &mut Session,
754        order_id: &OrderId,
755        market_name: M,
756    ) -> Result<SessionActionsResponse, O2Error>
757    where
758        M: IntoMarketSymbol,
759    {
760        let market_name = market_name.into_market_symbol()?;
761        debug!(
762            "client.cancel_order market={} order_id={}",
763            market_name, order_id
764        );
765        self.batch_actions(
766            session,
767            market_name,
768            vec![Action::CancelOrder {
769                order_id: order_id.clone(),
770            }],
771            false,
772        )
773        .await
774    }
775
776    /// Cancel all open orders for a market.
777    pub async fn cancel_all_orders<M>(
778        &mut self,
779        session: &mut Session,
780        market_name: M,
781    ) -> Result<Vec<SessionActionsResponse>, O2Error>
782    where
783        M: IntoMarketSymbol,
784    {
785        let market_name = market_name.into_market_symbol()?;
786        debug!("client.cancel_all_orders market={}", market_name);
787        Self::check_session_expiry(session)?;
788        let market = self.get_market(&market_name).await?;
789        let orders_resp = self
790            .api
791            .get_orders(
792                market.market_id.as_str(),
793                session.trade_account_id.as_str(),
794                "desc",
795                200,
796                Some(true),
797                None,
798                None,
799            )
800            .await?;
801
802        let orders = orders_resp.orders;
803        let mut results = Vec::new();
804
805        // Cancel up to 5 orders per batch
806        for chunk in orders.chunks(5) {
807            let actions = Self::build_cancel_actions(chunk.iter().map(|order| &order.order_id));
808
809            if actions.is_empty() {
810                continue;
811            }
812
813            let resp = self
814                .batch_actions(session, &market_name, actions, false)
815                .await?;
816            results.push(resp);
817        }
818
819        Ok(results)
820    }
821
822    fn build_cancel_actions<'a, I>(order_ids: I) -> Vec<Action>
823    where
824        I: IntoIterator<Item = &'a OrderId>,
825    {
826        order_ids
827            .into_iter()
828            .filter_map(|order_id| {
829                if order_id.as_str().trim().is_empty() {
830                    None
831                } else {
832                    Some(Action::CancelOrder {
833                        order_id: order_id.clone(),
834                    })
835                }
836            })
837            .collect()
838    }
839
840    /// Submit a batch of typed actions for a single market.
841    ///
842    /// Handles price/quantity scaling, encoding, signing, and nonce management.
843    pub async fn batch_actions<M>(
844        &mut self,
845        session: &mut Session,
846        market_name: M,
847        actions: Vec<Action>,
848        collect_orders: bool,
849    ) -> Result<SessionActionsResponse, O2Error>
850    where
851        M: IntoMarketSymbol,
852    {
853        let market_name = market_name.into_market_symbol()?;
854        debug!(
855            "client.batch_actions market={} actions={} collect_orders={}",
856            market_name,
857            actions.len(),
858            collect_orders
859        );
860        self.batch_actions_multi(session, &[(market_name, actions)], collect_orders)
861            .await
862    }
863
864    /// Submit a batch of typed actions across one or more markets.
865    pub async fn batch_actions_multi<M>(
866        &mut self,
867        session: &mut Session,
868        market_actions: &[(M, Vec<Action>)],
869        collect_orders: bool,
870    ) -> Result<SessionActionsResponse, O2Error>
871    where
872        M: IntoMarketSymbol + Clone,
873    {
874        let total_actions: usize = market_actions
875            .iter()
876            .map(|(_, actions)| actions.len())
877            .sum();
878        debug!(
879            "client.batch_actions_multi markets={} actions={} collect_orders={}",
880            market_actions.len(),
881            total_actions,
882            collect_orders
883        );
884        Self::check_session_expiry(session)?;
885
886        // Extract accounts_registry_id in a block so the borrow on self ends
887        let accounts_registry_id = {
888            let markets_resp = self.ensure_markets().await?;
889            Some(parse_hex_32(markets_resp.accounts_registry_id.as_str())?)
890        };
891
892        let mut all_calls: Vec<CallArg> = Vec::new();
893        let mut all_market_actions: Vec<MarketActions> = Vec::new();
894
895        for (market_name, actions) in market_actions {
896            let market_name = market_name.clone().into_market_symbol()?;
897            let market = self.get_market(&market_name).await?;
898            let mut actions_json: Vec<serde_json::Value> = Vec::new();
899
900            for action in actions {
901                let (call, json) = crate::encoding::action_to_call(
902                    action,
903                    &market,
904                    session.trade_account_id.as_str(),
905                    accounts_registry_id.as_ref(),
906                )?;
907                all_calls.push(call);
908                actions_json.push(json);
909            }
910
911            all_market_actions.push(MarketActions {
912                market_id: market.market_id.clone(),
913                actions: actions_json,
914            });
915        }
916
917        // Sign, submit, manage nonce
918        let signing_bytes = build_actions_signing_bytes(session.nonce, &all_calls);
919        let signature = raw_sign(&session.session_private_key, &signing_bytes)?;
920        let sig_hex = to_hex_string(&signature);
921        let owner_hex = to_hex_string(&session.owner_address);
922
923        let request = SessionActionsRequest {
924            actions: all_market_actions,
925            signature: Signature::Secp256k1(sig_hex),
926            nonce: session.nonce.to_string(),
927            trade_account_id: session.trade_account_id.clone(),
928            session_id: Identity::Address(to_hex_string(&session.session_address)),
929            collect_orders: Some(collect_orders),
930            variable_outputs: None,
931        };
932
933        match self.api.submit_actions(&owner_hex, &request).await {
934            Ok(resp) => {
935                session.nonce += 1;
936                Ok(resp)
937            }
938            Err(e) => {
939                session.nonce += 1;
940                let _ = self.refresh_nonce(session).await;
941                Err(e)
942            }
943        }
944    }
945
946    /// Settle balance for a market.
947    pub async fn settle_balance<M>(
948        &mut self,
949        session: &mut Session,
950        market_name: M,
951    ) -> Result<SessionActionsResponse, O2Error>
952    where
953        M: IntoMarketSymbol,
954    {
955        let market_name = market_name.into_market_symbol()?;
956        debug!("client.settle_balance market={}", market_name);
957        self.batch_actions(session, market_name, vec![Action::SettleBalance], false)
958            .await
959    }
960
961    // -----------------------------------------------------------------------
962    // Market Data
963    // -----------------------------------------------------------------------
964
965    /// Get order book depth.
966    ///
967    /// # Arguments
968    /// * `market_name` - Market pair string (e.g. `"ETH/USDC"`) or market ID.
969    /// * `precision` - Price grouping level, from `1` (most precise) to `18`
970    ///   (most grouped). At level 1, prices are at or near their exact values.
971    ///   Higher levels round prices into larger buckets — useful for a visual
972    ///   depth chart but too coarse for trading. Same scale as
973    ///   [`stream_depth`][O2Client::stream_depth].
974    /// * `limit` - Maximum number of price levels per side.
975    ///
976    /// # Errors
977    /// Returns [`O2Error::InvalidRequest`] if `precision` is outside 1–18.
978    pub async fn get_depth<M>(
979        &mut self,
980        market_name: M,
981        precision: u64,
982        limit: Option<usize>,
983    ) -> Result<DepthSnapshot, O2Error>
984    where
985        M: IntoMarketSymbol,
986    {
987        validate_depth_precision(precision)?;
988        let wire_precision = 10u64.pow(precision as u32);
989        let market_name = market_name.into_market_symbol()?;
990        debug!(
991            "client.get_depth market={} precision={}",
992            market_name, wire_precision
993        );
994        let market = self.get_market(&market_name).await?;
995        self.api
996            .get_depth(market.market_id.as_str(), wire_precision, limit)
997            .await
998    }
999
1000    /// Get recent trades for a market.
1001    ///
1002    /// Use `start_timestamp` + `start_trade_id` for cursor pagination
1003    /// (both must be provided together or omitted).
1004    pub async fn get_trades<M>(
1005        &mut self,
1006        market_name: M,
1007        count: u32,
1008        start_timestamp: Option<u64>,
1009        start_trade_id: Option<&TradeId>,
1010    ) -> Result<TradesResponse, O2Error>
1011    where
1012        M: IntoMarketSymbol,
1013    {
1014        let market_name = market_name.into_market_symbol()?;
1015        debug!("client.get_trades market={} count={}", market_name, count);
1016        let market = self.get_market(&market_name).await?;
1017        self.api
1018            .get_trades(
1019                market.market_id.as_str(),
1020                "desc",
1021                count,
1022                start_timestamp,
1023                start_trade_id.map(|t| t.as_str()),
1024                None,
1025            )
1026            .await
1027    }
1028
1029    /// Get trades for a specific account on a market.
1030    ///
1031    /// Use `start_timestamp` + `start_trade_id` for cursor pagination
1032    /// (both must be provided together or omitted).
1033    pub async fn get_account_trades<M>(
1034        &mut self,
1035        market_name: M,
1036        account: impl IntoValidId<TradeAccountId>,
1037        count: u32,
1038        start_timestamp: Option<u64>,
1039        start_trade_id: Option<&TradeId>,
1040    ) -> Result<TradesResponse, O2Error>
1041    where
1042        M: IntoMarketSymbol,
1043    {
1044        let account = account.into_valid()?;
1045        let market_name = market_name.into_market_symbol()?;
1046        debug!(
1047            "client.get_account_trades market={} account={} count={}",
1048            market_name, account, count
1049        );
1050        let market = self.get_market(&market_name).await?;
1051        self.api
1052            .get_trades_by_account(
1053                market.market_id.as_str(),
1054                account.as_str(),
1055                "desc",
1056                count,
1057                start_timestamp,
1058                start_trade_id.map(|t| t.as_str()),
1059            )
1060            .await
1061    }
1062
1063    /// Get OHLCV bars.
1064    ///
1065    /// `from_ts` and `to_ts` are in **milliseconds** (not seconds).
1066    pub async fn get_bars<M>(
1067        &mut self,
1068        market_name: M,
1069        resolution: &str,
1070        from_ts: u64,
1071        to_ts: u64,
1072    ) -> Result<Vec<Bar>, O2Error>
1073    where
1074        M: IntoMarketSymbol,
1075    {
1076        let market_name = market_name.into_market_symbol()?;
1077        debug!(
1078            "client.get_bars market={} resolution={} from_ts={} to_ts={}",
1079            market_name, resolution, from_ts, to_ts
1080        );
1081        let market = self.get_market(&market_name).await?;
1082        self.api
1083            .get_bars(market.market_id.as_str(), from_ts, to_ts, resolution)
1084            .await
1085    }
1086
1087    /// Get market ticker.
1088    pub async fn get_ticker<M>(&mut self, market_name: M) -> Result<MarketTicker, O2Error>
1089    where
1090        M: IntoMarketSymbol,
1091    {
1092        let market_name = market_name.into_market_symbol()?;
1093        debug!("client.get_ticker market={}", market_name);
1094        let market = self.get_market(&market_name).await?;
1095        let tickers = self
1096            .api
1097            .get_market_ticker(market.market_id.as_str())
1098            .await?;
1099        tickers
1100            .into_iter()
1101            .next()
1102            .ok_or_else(|| O2Error::Other("No ticker returned for requested market".into()))
1103    }
1104
1105    // -----------------------------------------------------------------------
1106    // Account Data
1107    // -----------------------------------------------------------------------
1108
1109    /// Get balances for a trading account, keyed by asset symbol.
1110    pub async fn get_balances(
1111        &mut self,
1112        trade_account_id: impl IntoValidId<TradeAccountId>,
1113    ) -> Result<HashMap<String, BalanceResponse>, O2Error> {
1114        let trade_account_id = trade_account_id.into_valid()?;
1115        debug!("client.get_balances trade_account_id={}", trade_account_id);
1116        let markets = self.get_markets().await?;
1117        let mut balances = HashMap::new();
1118        let mut seen_assets = std::collections::HashSet::new();
1119
1120        for market in &markets {
1121            for (symbol, asset_id) in [
1122                (&market.base.symbol, &market.base.asset),
1123                (&market.quote.symbol, &market.quote.asset),
1124            ] {
1125                if seen_assets.insert(asset_id.clone()) {
1126                    let bal = self
1127                        .api
1128                        .get_balance(asset_id.as_str(), Some(trade_account_id.as_str()), None)
1129                        .await
1130                        .map_err(|e| {
1131                            O2Error::Other(format!(
1132                                "Failed to fetch balance for asset {} ({}) on account {}: {}",
1133                                symbol, asset_id, trade_account_id, e
1134                            ))
1135                        })?;
1136                    balances.insert(symbol.clone(), bal);
1137                }
1138            }
1139        }
1140
1141        Ok(balances)
1142    }
1143
1144    /// Get orders for a trading account in a market.
1145    ///
1146    /// Use `start_timestamp` + `start_order_id` for cursor pagination
1147    /// (both must be provided together or omitted).
1148    pub async fn get_orders<M>(
1149        &mut self,
1150        market_name: M,
1151        trade_account_id: impl IntoValidId<TradeAccountId>,
1152        is_open: Option<bool>,
1153        count: u32,
1154        start_timestamp: Option<u64>,
1155        start_order_id: Option<&OrderId>,
1156    ) -> Result<OrdersResponse, O2Error>
1157    where
1158        M: IntoMarketSymbol,
1159    {
1160        let trade_account_id = trade_account_id.into_valid()?;
1161        let market_name = market_name.into_market_symbol()?;
1162        debug!(
1163            "client.get_orders trade_account_id={} market={} is_open={:?} count={}",
1164            trade_account_id, market_name, is_open, count
1165        );
1166        let market = self.get_market(&market_name).await?;
1167        self.api
1168            .get_orders(
1169                market.market_id.as_str(),
1170                trade_account_id.as_str(),
1171                "desc",
1172                count,
1173                is_open,
1174                start_timestamp,
1175                start_order_id.map(|o| o.as_str()),
1176            )
1177            .await
1178    }
1179
1180    /// Get a single order.
1181    pub async fn get_order<M>(
1182        &mut self,
1183        market_name: M,
1184        order_id: impl IntoValidId<OrderId>,
1185    ) -> Result<Order, O2Error>
1186    where
1187        M: IntoMarketSymbol,
1188    {
1189        let order_id = order_id.into_valid()?;
1190        let market_name = market_name.into_market_symbol()?;
1191        debug!(
1192            "client.get_order market={} order_id={}",
1193            market_name, order_id
1194        );
1195        let market = self.get_market(&market_name).await?;
1196        self.api
1197            .get_order(market.market_id.as_str(), order_id.as_str())
1198            .await
1199    }
1200
1201    // -----------------------------------------------------------------------
1202    // Nonce Management
1203    // -----------------------------------------------------------------------
1204
1205    /// Get the current nonce for a trading account.
1206    pub async fn get_nonce(
1207        &self,
1208        trade_account_id: impl IntoValidId<TradeAccountId>,
1209    ) -> Result<u64, O2Error> {
1210        let trade_account_id = trade_account_id.into_valid()?;
1211        debug!("client.get_nonce trade_account_id={}", trade_account_id);
1212        let account = self
1213            .api
1214            .get_account_by_id(trade_account_id.as_str())
1215            .await?;
1216        Self::parse_account_nonce(
1217            account.trade_account.as_ref().map(|ta| ta.nonce),
1218            "get_nonce account response",
1219        )
1220    }
1221
1222    /// Refresh the nonce on a session from the API.
1223    pub async fn refresh_nonce(&self, session: &mut Session) -> Result<u64, O2Error> {
1224        debug!(
1225            "client.refresh_nonce trade_account_id={}",
1226            session.trade_account_id
1227        );
1228        let nonce = self.get_nonce(session.trade_account_id.as_str()).await?;
1229        session.nonce = nonce;
1230        Ok(nonce)
1231    }
1232
1233    // -----------------------------------------------------------------------
1234    // Withdrawals
1235    // -----------------------------------------------------------------------
1236
1237    /// Withdraw assets from the trading account to the owner wallet.
1238    /// Works with both [`Wallet`] (Fuel-native) and [`EvmWallet`].
1239    pub async fn withdraw<W: SignableWallet>(
1240        &mut self,
1241        owner: &W,
1242        session: &Session,
1243        asset_id: &AssetId,
1244        amount: &str,
1245        to: Option<&str>,
1246    ) -> Result<WithdrawResponse, O2Error> {
1247        debug!(
1248            "client.withdraw trade_account_id={} asset_id={} amount={} to={:?}",
1249            session.trade_account_id, asset_id, amount, to
1250        );
1251        let owner_hex = to_hex_string(owner.b256_address());
1252        let to_address_hex = to.unwrap_or(&owner_hex);
1253        let to_address_bytes = parse_hex_32(to_address_hex)?;
1254        let asset_id_bytes = parse_hex_32(asset_id.as_str())?;
1255        let amount_u64: u64 = amount
1256            .parse()
1257            .map_err(|e| O2Error::Other(format!("Invalid amount: {e}")))?;
1258
1259        let nonce = self.get_nonce(session.trade_account_id.as_str()).await?;
1260        let chain_id = self.get_chain_id().await?;
1261
1262        // Build withdraw signing bytes and sign with owner wallet
1263        let signing_bytes = build_withdraw_signing_bytes(
1264            nonce,
1265            chain_id,
1266            0, // Address discriminant
1267            &to_address_bytes,
1268            &asset_id_bytes,
1269            amount_u64,
1270        );
1271        let signature = owner.personal_sign(&signing_bytes)?;
1272        let sig_hex = to_hex_string(&signature);
1273
1274        let request = WithdrawRequest {
1275            trade_account_id: session.trade_account_id.clone(),
1276            signature: Signature::Secp256k1(sig_hex),
1277            nonce: nonce.to_string(),
1278            to: Identity::Address(to_address_hex.to_string()),
1279            asset_id: asset_id.clone(),
1280            amount: amount.to_string(),
1281        };
1282
1283        self.api.withdraw(&owner_hex, &request).await
1284    }
1285
1286    // -----------------------------------------------------------------------
1287    // WebSocket Streaming (shared connection)
1288    // -----------------------------------------------------------------------
1289
1290    /// Ensure the shared WebSocket is connected, creating or replacing as needed.
1291    async fn ensure_ws(
1292        ws_slot: &mut Option<crate::websocket::O2WebSocket>,
1293        ws_url: &str,
1294    ) -> Result<(), O2Error> {
1295        debug!("client.ensure_ws url={}", ws_url);
1296        if ws_slot.as_ref().is_some_and(|ws| ws.is_terminated()) {
1297            *ws_slot = None;
1298        }
1299        if ws_slot.is_none() {
1300            *ws_slot = Some(crate::websocket::O2WebSocket::connect(ws_url).await?);
1301        }
1302        Ok(())
1303    }
1304
1305    /// Stream depth updates over a shared WebSocket connection.
1306    ///
1307    /// # Arguments
1308    /// * `market_id` - The market ID (hex string).
1309    /// * `precision` - Price grouping level, from `"1"` (most precise) to `"18"`
1310    ///   (most grouped). Default `"1"`. At level 1, prices are at or near their
1311    ///   exact values. Higher levels round prices into larger buckets. Same
1312    ///   scale as [`get_depth`][O2Client::get_depth].
1313    ///
1314    /// # Errors
1315    /// Returns [`O2Error::InvalidRequest`] if `precision` is outside 1–18.
1316    pub async fn stream_depth(
1317        &self,
1318        market_id: impl IntoValidId<MarketId>,
1319        precision: u64,
1320    ) -> Result<TypedStream<DepthUpdate>, O2Error> {
1321        let dp = DepthPrecision::new(precision)?;
1322        let market_id = market_id.into_valid()?;
1323        debug!(
1324            "client.stream_depth market_id={} precision={}",
1325            market_id,
1326            dp.as_str()
1327        );
1328        let mut guard = self.ws.lock().await;
1329        Self::ensure_ws(&mut guard, &self.config.ws_url).await?;
1330        guard
1331            .as_ref()
1332            .unwrap()
1333            .stream_depth(market_id.as_str(), &dp)
1334            .await
1335    }
1336
1337    /// Stream order updates over a shared WebSocket connection.
1338    pub async fn stream_orders(
1339        &self,
1340        identities: &[Identity],
1341    ) -> Result<TypedStream<OrderUpdate>, O2Error> {
1342        debug!("client.stream_orders identities={}", identities.len());
1343        let mut guard = self.ws.lock().await;
1344        Self::ensure_ws(&mut guard, &self.config.ws_url).await?;
1345        guard.as_ref().unwrap().stream_orders(identities).await
1346    }
1347
1348    /// Stream trade updates over a shared WebSocket connection.
1349    pub async fn stream_trades(
1350        &self,
1351        market_id: impl IntoValidId<MarketId>,
1352    ) -> Result<TypedStream<TradeUpdate>, O2Error> {
1353        let market_id = market_id.into_valid()?;
1354        debug!("client.stream_trades market_id={}", market_id);
1355        let mut guard = self.ws.lock().await;
1356        Self::ensure_ws(&mut guard, &self.config.ws_url).await?;
1357        guard
1358            .as_ref()
1359            .unwrap()
1360            .stream_trades(market_id.as_str())
1361            .await
1362    }
1363
1364    /// Stream balance updates over a shared WebSocket connection.
1365    pub async fn stream_balances(
1366        &self,
1367        identities: &[Identity],
1368    ) -> Result<TypedStream<BalanceUpdate>, O2Error> {
1369        debug!("client.stream_balances identities={}", identities.len());
1370        let mut guard = self.ws.lock().await;
1371        Self::ensure_ws(&mut guard, &self.config.ws_url).await?;
1372        guard.as_ref().unwrap().stream_balances(identities).await
1373    }
1374
1375    /// Stream nonce updates over a shared WebSocket connection.
1376    pub async fn stream_nonce(
1377        &self,
1378        identities: &[Identity],
1379    ) -> Result<TypedStream<NonceUpdate>, O2Error> {
1380        debug!("client.stream_nonce identities={}", identities.len());
1381        let mut guard = self.ws.lock().await;
1382        Self::ensure_ws(&mut guard, &self.config.ws_url).await?;
1383        guard.as_ref().unwrap().stream_nonce(identities).await
1384    }
1385
1386    /// Subscribe to shared WebSocket lifecycle events (reconnect/disconnect).
1387    pub async fn subscribe_ws_lifecycle(
1388        &self,
1389    ) -> Result<tokio::sync::broadcast::Receiver<crate::websocket::WsLifecycleEvent>, O2Error> {
1390        let mut guard = self.ws.lock().await;
1391        Self::ensure_ws(&mut guard, &self.config.ws_url).await?;
1392        Ok(guard.as_ref().unwrap().subscribe_lifecycle())
1393    }
1394
1395    /// Disconnect the shared WebSocket connection and release resources.
1396    pub async fn disconnect_ws(&self) -> Result<(), O2Error> {
1397        debug!("client.disconnect_ws");
1398        let mut guard = self.ws.lock().await;
1399        if let Some(ws) = guard.take() {
1400            ws.disconnect().await?;
1401        }
1402        Ok(())
1403    }
1404}
1405
1406#[cfg(test)]
1407mod tests {
1408    use std::time::{Duration, Instant};
1409
1410    use crate::{
1411        config::{Network, NetworkConfig},
1412        models::{
1413            Action, AssetId, ContractId, Market, MarketAsset, MarketId, MarketsResponse, OrderId,
1414            OrderType, Side,
1415        },
1416    };
1417
1418    use super::{MarketActionsBuilder, MetadataPolicy, O2Client};
1419
1420    fn dummy_markets_response() -> MarketsResponse {
1421        MarketsResponse {
1422            books_registry_id: ContractId::new("0x1"),
1423            books_whitelist_id: None,
1424            books_blacklist_id: None,
1425            accounts_registry_id: ContractId::new("0x2"),
1426            trade_account_oracle_id: ContractId::new("0x3"),
1427            fast_bridge_asset_registry_contract_id: None,
1428            chain_id: "0x0".to_string(),
1429            base_asset_id: AssetId::new("0x4"),
1430            markets: Vec::new(),
1431        }
1432    }
1433
1434    fn dummy_market(market_id: &str) -> Market {
1435        Market {
1436            contract_id: ContractId::new("0x01"),
1437            market_id: MarketId::new(market_id),
1438            whitelist_id: None,
1439            blacklist_id: None,
1440            maker_fee: 0,
1441            taker_fee: 0,
1442            min_order: 0,
1443            dust: 0,
1444            price_window: 0,
1445            base: MarketAsset {
1446                symbol: "fETH".to_string(),
1447                asset: AssetId::new("0xbase"),
1448                decimals: 9,
1449                max_precision: 6,
1450            },
1451            quote: MarketAsset {
1452                symbol: "fUSDC".to_string(),
1453                asset: AssetId::new("0xquote"),
1454                decimals: 9,
1455                max_precision: 6,
1456            },
1457        }
1458    }
1459
1460    #[test]
1461    fn parse_nonce_decimal() {
1462        assert_eq!(
1463            O2Client::parse_nonce_value("42", "test").expect("decimal nonce should parse"),
1464            42
1465        );
1466    }
1467
1468    #[test]
1469    fn parse_nonce_hex_lowercase() {
1470        assert_eq!(
1471            O2Client::parse_nonce_value("0x2a", "test").expect("hex nonce should parse"),
1472            42
1473        );
1474    }
1475
1476    #[test]
1477    fn parse_nonce_hex_uppercase_prefix() {
1478        assert_eq!(
1479            O2Client::parse_nonce_value("0X2A", "test").expect("hex nonce should parse"),
1480            42
1481        );
1482    }
1483
1484    #[test]
1485    fn parse_nonce_missing_defaults_zero() {
1486        assert_eq!(
1487            O2Client::parse_account_nonce(None, "test").expect("missing nonce should default"),
1488            0
1489        );
1490    }
1491
1492    #[test]
1493    fn parse_nonce_invalid_is_error() {
1494        let err = O2Client::parse_nonce_value("not-a-nonce", "test")
1495            .expect_err("invalid nonce should return parse error");
1496        assert!(format!("{err}").contains("Parse error"));
1497    }
1498
1499    #[test]
1500    fn metadata_policy_refreshes_when_cache_empty() {
1501        let client = O2Client::new(Network::Testnet);
1502        assert!(client.should_refresh_markets());
1503    }
1504
1505    #[test]
1506    fn metadata_policy_optimistic_ttl_respects_recent_cache() {
1507        let mut client = O2Client::new(Network::Testnet);
1508        client.metadata_policy = MetadataPolicy::OptimisticTtl(Duration::from_secs(60));
1509        client.markets_cache = Some(dummy_markets_response());
1510        client.markets_cache_at = Some(Instant::now());
1511        assert!(!client.should_refresh_markets());
1512    }
1513
1514    #[test]
1515    fn metadata_policy_optimistic_ttl_refreshes_expired_cache() {
1516        let mut client = O2Client::new(Network::Testnet);
1517        client.metadata_policy = MetadataPolicy::OptimisticTtl(Duration::from_millis(10));
1518        client.markets_cache = Some(dummy_markets_response());
1519        client.markets_cache_at = Some(Instant::now() - Duration::from_secs(1));
1520        assert!(client.should_refresh_markets());
1521    }
1522
1523    #[test]
1524    fn metadata_policy_strict_fresh_always_refreshes() {
1525        let mut client = O2Client::new(Network::Testnet);
1526        client.metadata_policy = MetadataPolicy::StrictFresh;
1527        client.markets_cache = Some(dummy_markets_response());
1528        client.markets_cache_at = Some(Instant::now());
1529        assert!(client.should_refresh_markets());
1530    }
1531
1532    #[test]
1533    fn market_actions_builder_builds_valid_actions() {
1534        let market = dummy_market("0xmarket_a");
1535        let actions = MarketActionsBuilder::new(market)
1536            .settle_balance()
1537            .create_order(Side::Buy, "1.25", "10", OrderType::Spot)
1538            .cancel_order("0xdeadbeef")
1539            .build()
1540            .expect("builder should produce actions");
1541
1542        assert_eq!(actions.len(), 3);
1543        assert!(matches!(actions[0], Action::SettleBalance));
1544        assert!(matches!(actions[1], Action::CreateOrder { .. }));
1545        assert!(matches!(actions[2], Action::CancelOrder { .. }));
1546    }
1547
1548    #[test]
1549    fn market_actions_builder_defers_parse_error_until_build() {
1550        let market = dummy_market("0xmarket_a");
1551        let result = MarketActionsBuilder::new(market)
1552            .create_order(Side::Buy, "bad-price", "10", OrderType::Spot)
1553            .cancel_order("0xwill-not-be-added")
1554            .build();
1555
1556        assert!(result.is_err());
1557    }
1558
1559    #[test]
1560    fn market_actions_builder_rejects_stale_typed_inputs_on_build() {
1561        let market_a = dummy_market("0xmarket_a");
1562        let market_b = dummy_market("0xmarket_b");
1563
1564        let typed_price = market_a.price("1.0").expect("price should parse");
1565        let typed_quantity = market_a.quantity("2.0").expect("qty should parse");
1566
1567        let result = MarketActionsBuilder::new(market_b)
1568            .create_order(Side::Buy, typed_price, typed_quantity, OrderType::PostOnly)
1569            .build();
1570
1571        assert!(result.is_err());
1572    }
1573
1574    #[test]
1575    fn whitelist_is_enabled_only_for_testnet() {
1576        let testnet = O2Client::new(Network::Testnet);
1577        let devnet = O2Client::new(Network::Devnet);
1578        let mainnet = O2Client::new(Network::Mainnet);
1579
1580        assert!(testnet.should_whitelist_account());
1581        assert!(!devnet.should_whitelist_account());
1582        assert!(!mainnet.should_whitelist_account());
1583    }
1584
1585    #[test]
1586    fn whitelist_behavior_can_be_overridden_in_custom_config() {
1587        let mut config = NetworkConfig::from_network(Network::Mainnet);
1588        config.whitelist_required = true;
1589        let custom = O2Client::with_config(config);
1590        assert!(custom.should_whitelist_account());
1591    }
1592
1593    #[test]
1594    fn build_cancel_actions_skips_empty_order_ids() {
1595        let empty = OrderId::default();
1596        let valid = OrderId::new("0xabc123");
1597
1598        let actions = O2Client::build_cancel_actions([&empty, &valid]);
1599        assert_eq!(actions.len(), 1);
1600        match &actions[0] {
1601            Action::CancelOrder { order_id } => assert_eq!(order_id.as_str(), valid.as_str()),
1602            _ => panic!("expected cancel action"),
1603        }
1604    }
1605
1606    // REST depth precision (1-18)
1607    #[test]
1608    fn validate_depth_precision_rejects_0() {
1609        let err = super::validate_depth_precision(0).unwrap_err();
1610        assert!(err.to_string().contains("Invalid depth precision 0"));
1611    }
1612
1613    #[test]
1614    fn validate_depth_precision_rejects_19() {
1615        let err = super::validate_depth_precision(19).unwrap_err();
1616        assert!(err.to_string().contains("Invalid depth precision 19"));
1617    }
1618
1619    #[test]
1620    fn validate_depth_precision_accepts_1() {
1621        assert!(super::validate_depth_precision(1).is_ok());
1622    }
1623
1624    #[test]
1625    fn validate_depth_precision_accepts_18() {
1626        assert!(super::validate_depth_precision(18).is_ok());
1627    }
1628
1629    // stream_depth shares the same 1-18 validator as get_depth
1630    #[test]
1631    fn validate_depth_precision_accepts_1_for_stream() {
1632        assert!(super::validate_depth_precision(1).is_ok());
1633    }
1634
1635    #[test]
1636    fn validate_depth_precision_accepts_9_for_stream() {
1637        assert!(super::validate_depth_precision(9).is_ok());
1638    }
1639
1640    #[test]
1641    fn validate_depth_precision_accepts_10_for_stream() {
1642        assert!(super::validate_depth_precision(10).is_ok());
1643    }
1644}