1use 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#[derive(Debug, Clone, Copy)]
26pub enum MetadataPolicy {
27 OptimisticTtl(Duration),
29 StrictFresh,
31}
32
33impl Default for MetadataPolicy {
34 fn default() -> Self {
35 Self::OptimisticTtl(Duration::from_secs(45))
36 }
37}
38
39fn 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
51pub 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#[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 pub fn settle_balance(mut self) -> Self {
89 self.actions.push(Action::SettleBalance);
90 self
91 }
92
93 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 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 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 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 if self.config.faucet_url.is_none() {
254 debug!("client.retry_mint_to_contract skipped (no faucet url)");
255 return true;
256 }
257
258 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 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 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 pub fn set_metadata_policy(&mut self, policy: MetadataPolicy) {
373 self.metadata_policy = policy;
374 }
375
376 pub fn generate_wallet(&self) -> Result<Wallet, O2Error> {
382 debug!("client.generate_wallet");
383 generate_keypair()
384 }
385
386 pub fn generate_evm_wallet(&self) -> Result<EvmWallet, O2Error> {
388 debug!("client.generate_evm_wallet");
389 generate_evm_keypair()
390 }
391
392 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 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 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 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 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 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 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 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 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 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 let created = self.api.create_account(&owner_hex).await?;
513 created.trade_account_id
514 };
515
516 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 let _ = self
528 .retry_whitelist_account(trade_account_id.as_str())
529 .await;
530
531 self.api.get_account_by_id(trade_account_id.as_str()).await
533 }
534
535 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 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 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 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 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 let session_wallet = generate_keypair()?;
622
623 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 let signature = owner.personal_sign(&signing_bytes)?;
634 let sig_hex = to_hex_string(&signature);
635
636 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 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 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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let signing_bytes = build_withdraw_signing_bytes(
1264 nonce,
1265 chain_id,
1266 0, &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 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 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 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 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 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 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 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 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 #[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 #[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}