Skip to main content

polyester/
client.rs

1//! Root Polyester SDK client.
2
3use crate::auth::{self, Credentials};
4use crate::catalogs::Manager as CatalogManager;
5use crate::errors::{Error, Result};
6use crate::services::{
7    AddressBookService, ApiKeysService, AuthService, BalancesService, ChainAnalyticsService,
8    DepositService, GuardSignerService, HeatmapService, InternalTransfersService, LayoutService,
9    LifecycleService, MarketDataService, MarketOverviewService, OrderbookService, OrdersService,
10    PoliciesService, PolychartService, ServiceContext, SocialVerificationService,
11    SubAccountsService, TradesService, TransfersService, TriggersService, WhiteboardService,
12    WithdrawService, ZipperService,
13};
14use crate::transport::{
15    Config as TransportConfig, DEFAULT_API_URL, DEFAULT_WS_URL, Factory, WireFormat,
16};
17use std::sync::Arc;
18use std::time::Duration;
19use tokio::sync::OnceCell;
20
21use crate::realtime::Client as RealtimeClient;
22
23/// Client configuration.
24#[derive(Clone)]
25pub struct Config {
26    pub api_key_id: Option<String>,
27    pub api_private_key: Option<String>,
28    pub api_url: String,
29    pub ws_url: String,
30    pub default_sub_account_id: Option<String>,
31    pub default_account_id: Option<String>,
32    pub timeout: Duration,
33    pub wire_format: WireFormat,
34    pub hydrate_catalogs: bool,
35}
36
37impl std::fmt::Debug for Config {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        f.debug_struct("Config")
40            .field("api_key_id", &self.api_key_id)
41            .field(
42                "api_private_key",
43                &self.api_private_key.as_ref().map(|_| "[REDACTED]"),
44            )
45            .field("api_url", &self.api_url)
46            .field("ws_url", &self.ws_url)
47            .field("default_sub_account_id", &self.default_sub_account_id)
48            .field("default_account_id", &self.default_account_id)
49            .field("timeout", &self.timeout)
50            .field("wire_format", &self.wire_format)
51            .field("hydrate_catalogs", &self.hydrate_catalogs)
52            .finish()
53    }
54}
55
56impl Default for Config {
57    fn default() -> Self {
58        Self {
59            api_key_id: None,
60            api_private_key: None,
61            api_url: DEFAULT_API_URL.to_owned(),
62            ws_url: DEFAULT_WS_URL.to_owned(),
63            default_sub_account_id: None,
64            default_account_id: None,
65            timeout: Duration::from_secs(10),
66            wire_format: WireFormat::Binary,
67            hydrate_catalogs: true,
68        }
69    }
70}
71
72/// Async Polyester SDK entrypoint.
73pub struct Client {
74    pub api_url: String,
75    pub ws_url: String,
76    pub default_sub_account_id: Option<String>,
77    pub default_account_id: Option<String>,
78    pub catalogs: Arc<CatalogManager>,
79    pub realtime: RealtimeClient,
80
81    pub auth: AuthService,
82    pub market_data: MarketDataService,
83    pub market_overview: MarketOverviewService,
84    pub zipper: ZipperService,
85    pub chain_analytics: ChainAnalyticsService,
86    pub heatmap: HeatmapService,
87    pub lifecycle: LifecycleService,
88    pub balances: BalancesService,
89    pub orderbook: OrderbookService,
90    pub orders: OrdersService,
91    pub trades: TradesService,
92    pub triggers: TriggersService,
93    pub transfers: TransfersService,
94    pub internal_transfers: InternalTransfersService,
95    pub deposit: DepositService,
96    pub api_keys: ApiKeysService,
97    pub policies: PoliciesService,
98    pub sub_accounts: SubAccountsService,
99    pub address_book: AddressBookService,
100    pub social_verification: SocialVerificationService,
101    pub whiteboard: WhiteboardService,
102    pub polychart: PolychartService,
103    pub layout: LayoutService,
104    pub guard_signer: GuardSignerService,
105    pub withdraw: WithdrawService,
106    catalog_ready: Arc<OnceCell<Result<()>>>,
107    catalog_hydrate_lock: Arc<tokio::sync::Mutex<()>>,
108    catalog_last_error: Arc<std::sync::Mutex<Option<Error>>>,
109    hydrate_catalogs_enabled: bool,
110}
111
112impl Client {
113    pub fn new(config: Config) -> Result<Self> {
114        let hydrate_catalogs_enabled = config.hydrate_catalogs;
115        let credentials = Credentials::load(
116            config.api_key_id.as_deref(),
117            config.api_private_key.as_deref(),
118            false,
119        )?;
120
121        let transport_cfg = TransportConfig {
122            api_url: config.api_url.clone(),
123            ws_url: config.ws_url.clone(),
124            timeout: config.timeout,
125            wire_format: config.wire_format,
126        };
127        let factory = Factory::new(transport_cfg, credentials.clone())?;
128        let catalogs = Arc::new(CatalogManager::new());
129
130        let realtime = RealtimeClient::with_timeout(
131            config.ws_url.clone(),
132            config.api_url.clone(),
133            credentials,
134            None,
135            config.timeout,
136        );
137
138        let catalog_ready = Arc::new(OnceCell::new());
139        let catalog_hydrate_lock = Arc::new(tokio::sync::Mutex::new(()));
140        let catalog_last_error = Arc::new(std::sync::Mutex::new(None));
141        let ctx = ServiceContext {
142            factory,
143            catalogs: catalogs.clone(),
144            default_sub_account_id: config.default_sub_account_id.clone(),
145            default_account_id: config.default_account_id.clone(),
146            realtime: realtime.clone(),
147            catalog_ready: catalog_ready.clone(),
148            hydrate_catalogs_enabled,
149        };
150
151        let client = Self {
152            api_url: config.api_url,
153            ws_url: config.ws_url,
154            default_sub_account_id: config.default_sub_account_id,
155            default_account_id: config.default_account_id,
156            catalogs,
157            realtime,
158            auth: AuthService::new(ctx.clone()),
159            market_data: MarketDataService::new(ctx.clone()),
160            market_overview: MarketOverviewService::new(ctx.clone()),
161            zipper: ZipperService::new(ctx.clone()),
162            chain_analytics: ChainAnalyticsService::new(ctx.clone()),
163            heatmap: HeatmapService::new(ctx.clone()),
164            lifecycle: LifecycleService::new(ctx.clone()),
165            balances: BalancesService::new(ctx.clone()),
166            orderbook: OrderbookService::new(ctx.clone()),
167            orders: OrdersService::new(ctx.clone()),
168            trades: TradesService::new(ctx.clone()),
169            triggers: TriggersService::new(ctx.clone()),
170            transfers: TransfersService::new(ctx.clone()),
171            internal_transfers: InternalTransfersService::new(ctx.clone()),
172            deposit: DepositService::new(ctx.clone()),
173            api_keys: ApiKeysService::new(ctx.clone()),
174            policies: PoliciesService::new(ctx.clone()),
175            sub_accounts: SubAccountsService::new(ctx.clone()),
176            address_book: AddressBookService::new(ctx.clone()),
177            social_verification: SocialVerificationService::new(ctx.clone()),
178            whiteboard: WhiteboardService::new(ctx.clone()),
179            polychart: PolychartService::new(ctx.clone()),
180            layout: LayoutService::new(ctx.clone()),
181            guard_signer: GuardSignerService::new(ctx.clone()),
182            withdraw: WithdrawService::new(ctx),
183            catalog_ready,
184            catalog_hydrate_lock,
185            catalog_last_error,
186            hydrate_catalogs_enabled,
187        };
188
189        client.start_catalog_hydration();
190        Ok(client)
191    }
192
193    /// Build from `POLYESTER_API_KEY_ID` / `POLYESTER_API_PRIVATE_KEY` / `POLYESTER_ACCOUNT_ID`.
194    pub fn from_env() -> Result<Self> {
195        let mut config = Config {
196            api_key_id: std::env::var(auth::API_KEY_ID_ENV).ok(),
197            api_private_key: std::env::var(auth::API_PRIVATE_KEY_ENV).ok(),
198            default_account_id: auth::account_id_from_env(),
199            ..Default::default()
200        };
201        if let Ok(url) = std::env::var("POLYESTER_API_URL")
202            && !url.trim().is_empty()
203        {
204            config.api_url = url;
205        }
206        if let Ok(url) = std::env::var("POLYESTER_WS_URL")
207            && !url.trim().is_empty()
208        {
209            config.ws_url = url;
210        }
211        // Force from_env credential loading
212        let credentials = Credentials::load(None, None, true)?;
213        let transport_cfg = TransportConfig {
214            api_url: config.api_url.clone(),
215            ws_url: config.ws_url.clone(),
216            timeout: config.timeout,
217            wire_format: config.wire_format,
218        };
219        let factory = Factory::new(transport_cfg, credentials.clone())?;
220        let catalogs = Arc::new(CatalogManager::new());
221        let realtime = RealtimeClient::with_timeout(
222            config.ws_url.clone(),
223            config.api_url.clone(),
224            credentials,
225            None,
226            config.timeout,
227        );
228        let catalog_ready = Arc::new(OnceCell::new());
229        let catalog_hydrate_lock = Arc::new(tokio::sync::Mutex::new(()));
230        let catalog_last_error = Arc::new(std::sync::Mutex::new(None));
231        let hydrate_catalogs_enabled = config.hydrate_catalogs;
232        let ctx = ServiceContext {
233            factory,
234            catalogs: catalogs.clone(),
235            default_sub_account_id: config.default_sub_account_id.clone(),
236            default_account_id: config.default_account_id.clone(),
237            realtime: realtime.clone(),
238            catalog_ready: catalog_ready.clone(),
239            hydrate_catalogs_enabled,
240        };
241        let client = Self {
242            api_url: config.api_url,
243            ws_url: config.ws_url,
244            default_sub_account_id: config.default_sub_account_id,
245            default_account_id: config.default_account_id,
246            catalogs,
247            realtime,
248            auth: AuthService::new(ctx.clone()),
249            market_data: MarketDataService::new(ctx.clone()),
250            market_overview: MarketOverviewService::new(ctx.clone()),
251            zipper: ZipperService::new(ctx.clone()),
252            chain_analytics: ChainAnalyticsService::new(ctx.clone()),
253            heatmap: HeatmapService::new(ctx.clone()),
254            lifecycle: LifecycleService::new(ctx.clone()),
255            balances: BalancesService::new(ctx.clone()),
256            orderbook: OrderbookService::new(ctx.clone()),
257            orders: OrdersService::new(ctx.clone()),
258            trades: TradesService::new(ctx.clone()),
259            triggers: TriggersService::new(ctx.clone()),
260            transfers: TransfersService::new(ctx.clone()),
261            internal_transfers: InternalTransfersService::new(ctx.clone()),
262            deposit: DepositService::new(ctx.clone()),
263            api_keys: ApiKeysService::new(ctx.clone()),
264            policies: PoliciesService::new(ctx.clone()),
265            sub_accounts: SubAccountsService::new(ctx.clone()),
266            address_book: AddressBookService::new(ctx.clone()),
267            social_verification: SocialVerificationService::new(ctx.clone()),
268            whiteboard: WhiteboardService::new(ctx.clone()),
269            polychart: PolychartService::new(ctx.clone()),
270            layout: LayoutService::new(ctx.clone()),
271            guard_signer: GuardSignerService::new(ctx.clone()),
272            withdraw: WithdrawService::new(ctx),
273            catalog_ready,
274            catalog_hydrate_lock,
275            catalog_last_error,
276            hydrate_catalogs_enabled,
277        };
278        client.start_catalog_hydration();
279        Ok(client)
280    }
281
282    fn start_catalog_hydration(&self) {
283        if !self.hydrate_catalogs_enabled {
284            return;
285        }
286        if tokio::runtime::Handle::try_current().is_err() {
287            let error = Error::validation(
288                "catalog hydration was not started because Client was constructed outside a \
289                 Tokio runtime; await client.wait_for_catalogs() before placing orders",
290            );
291            *crate::realtime::lock_unpoisoned(&self.catalog_last_error) = Some(error.clone());
292            let _ = self.catalog_ready.set(Err(error));
293            return;
294        }
295
296        let ready = self.catalog_ready.clone();
297        let hydrate_lock = self.catalog_hydrate_lock.clone();
298        let last_error = self.catalog_last_error.clone();
299        let market_data = self.market_data.clone();
300        let zipper = self.zipper.clone();
301        let catalogs = self.catalogs.clone();
302        tokio::spawn(async move {
303            ready
304                .get_or_init(|| async move {
305                    let _guard = hydrate_lock.lock().await;
306                    let result = Self::hydrate_catalogs_with(market_data, zipper, catalogs).await;
307                    *crate::realtime::lock_unpoisoned(&last_error) = result.clone().err();
308                    result
309                })
310                .await;
311        });
312    }
313
314    async fn hydrate_catalogs_with(
315        market_data: MarketDataService,
316        zipper: ZipperService,
317        catalogs: Arc<CatalogManager>,
318    ) -> Result<()> {
319        // Fetch both configs before mutating catalogs so a zipper failure cannot
320        // leave a partially installed spot catalog.
321        let spot = market_data.get_spot_config().await.map_err(|e| {
322            Error::validation(format!("catalog hydration failed (spot config): {e}"))
323        })?;
324        let zipper_cfg = zipper.get_deposit_withdraw_config().await.map_err(|e| {
325            Error::validation(format!("catalog hydration failed (zipper config): {e}"))
326        })?;
327        let zipper_json = serde_json::to_value(&zipper_cfg).map_err(|e| {
328            Error::validation(format!("catalog hydration failed (zipper encode): {e}"))
329        })?;
330        catalogs.hydrate_spot_and_zipper_json(spot.raw, zipper_json)?;
331        Ok(())
332    }
333
334    /// Hydrate spot + zipper catalogs. Returns an error when either fetch or
335    /// parse leaves catalogs unusable.
336    pub async fn hydrate_catalogs(&self) -> Result<()> {
337        let _guard = self.catalog_hydrate_lock.lock().await;
338        let result = Self::hydrate_catalogs_with(
339            self.market_data.clone(),
340            self.zipper.clone(),
341            self.catalogs.clone(),
342        )
343        .await;
344        *crate::realtime::lock_unpoisoned(&self.catalog_last_error) = result.clone().err();
345        let _ = self.catalog_ready.set(result.clone());
346        result
347    }
348
349    /// Wait until construction-time catalog hydration finishes.
350    ///
351    /// Returns [`Err`] when hydration failed (HTTP/transport error, malformed
352    /// config, or invalid scales). Concurrent waiters share one attempt.
353    ///
354    /// Returns immediately when hydration was disabled in [`Config`].
355    pub async fn wait_for_catalogs(&self) -> Result<()> {
356        if !self.hydrate_catalogs_enabled {
357            return Ok(());
358        }
359        if self.catalogs.is_ready() {
360            return Ok(());
361        }
362        if matches!(self.catalog_ready.get(), Some(Err(_))) {
363            let _guard = self.catalog_hydrate_lock.lock().await;
364            if self.catalogs.is_ready() {
365                return Ok(());
366            }
367            let result = Self::hydrate_catalogs_with(
368                self.market_data.clone(),
369                self.zipper.clone(),
370                self.catalogs.clone(),
371            )
372            .await;
373            *crate::realtime::lock_unpoisoned(&self.catalog_last_error) = result.clone().err();
374            return result;
375        }
376        let last_error = self.catalog_last_error.clone();
377        let hydrate_lock = self.catalog_hydrate_lock.clone();
378        let market_data = self.market_data.clone();
379        let zipper = self.zipper.clone();
380        let catalogs = self.catalogs.clone();
381        self.catalog_ready
382            .get_or_init(|| async move {
383                let _guard = hydrate_lock.lock().await;
384                let result = Self::hydrate_catalogs_with(market_data, zipper, catalogs).await;
385                *crate::realtime::lock_unpoisoned(&last_error) = result.clone().err();
386                result
387            })
388            .await
389            .clone()
390    }
391
392    /// Most recent catalog hydration error, if any.
393    pub fn catalogs_last_error(&self) -> Option<Error> {
394        crate::realtime::lock_unpoisoned(&self.catalog_last_error).clone()
395    }
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401    use crate::transport::DEFAULT_API_URL;
402
403    #[test]
404    fn config_defaults_point_at_devnet() {
405        let cfg = Config::default();
406        assert_eq!(cfg.api_url, DEFAULT_API_URL);
407        assert!(cfg.hydrate_catalogs);
408        assert!(cfg.api_key_id.is_none());
409    }
410
411    #[test]
412    fn config_debug_redacts_private_key() {
413        let config = Config {
414            api_key_id: Some("ak_test".into()),
415            api_private_key: Some("super-secret-private-key".into()),
416            ..Default::default()
417        };
418        let rendered = format!("{config:?}");
419        assert!(rendered.contains("ak_test"));
420        assert!(rendered.contains("[REDACTED]"));
421        assert!(!rendered.contains("super-secret-private-key"));
422    }
423
424    #[test]
425    fn ed25519_keypair_debug_redacts_secret() {
426        let keypair = crate::models::Ed25519Keypair {
427            public_key_hex: "abcd".into(),
428            secret_key_hex: "super-secret-seed-hex".into(),
429            public_key: vec![1, 2, 3],
430            secret_key: b"super-secret-seed-bytes".to_vec(),
431        };
432        let rendered = format!("{keypair:?}");
433        assert!(rendered.contains("abcd"));
434        assert!(rendered.contains("[REDACTED]"));
435        assert!(!rendered.contains("super-secret-seed-hex"));
436        assert!(!rendered.contains("super-secret-seed-bytes"));
437    }
438
439    #[test]
440    fn catalog_error_state_recovers_from_a_poisoned_mutex() {
441        let client = Client::new(Config {
442            hydrate_catalogs: false,
443            ..Default::default()
444        })
445        .unwrap();
446        let state = Arc::clone(&client.catalog_last_error);
447        let _ = std::thread::spawn(move || {
448            let _guard = state.lock().unwrap();
449            panic!("poison catalog error state");
450        })
451        .join();
452
453        assert!(client.catalogs_last_error().is_none());
454        *crate::realtime::lock_unpoisoned(&client.catalog_last_error) =
455            Some(Error::transport("recovered"));
456        assert_eq!(
457            client.catalogs_last_error().map(|error| error.to_string()),
458            Some("recovered".to_owned())
459        );
460    }
461
462    #[test]
463    fn client_new_without_creds_exposes_service_tree() {
464        let client = Client::new(Config::default()).expect("client");
465        assert!(!client.api_url.is_empty());
466        let catalog_start = client
467            .catalog_ready
468            .get()
469            .expect("construction outside Tokio records catalog state")
470            .as_ref()
471            .expect_err("catalog hydration cannot start without a runtime");
472        assert!(
473            catalog_start
474                .to_string()
475                .contains("outside a Tokio runtime")
476        );
477        assert_eq!(
478            client.catalogs.base_quantity_scale_for_symbol("BTC-USDT"),
479            None
480        );
481        // Touch service handles so the surface stays wired.
482        let _ = (
483            &client.auth,
484            &client.market_data,
485            &client.market_overview,
486            &client.orders,
487            &client.trades,
488            &client.triggers,
489            &client.balances,
490            &client.orderbook,
491            &client.zipper,
492            &client.transfers,
493            &client.api_keys,
494            &client.policies,
495            &client.sub_accounts,
496            &client.deposit,
497            &client.withdraw,
498        );
499    }
500
501    #[tokio::test]
502    async fn wait_for_catalogs_errors_when_hydration_fails() {
503        let client = Client::new(Config {
504            api_url: "http://127.0.0.1:9".into(),
505            timeout: Duration::from_millis(50),
506            hydrate_catalogs: true,
507            ..Default::default()
508        })
509        .expect("client");
510
511        let err = client
512            .wait_for_catalogs()
513            .await
514            .expect_err("unreachable API must fail closed");
515        assert!(
516            err.to_string().contains("catalog hydration failed"),
517            "unexpected error: {err}"
518        );
519        assert!(client.catalogs_last_error().is_some());
520        assert!(client.catalog_ready.get().is_some());
521    }
522
523    #[tokio::test]
524    async fn wait_for_catalogs_returns_immediately_when_disabled() {
525        let client = Client::new(Config {
526            api_url: "http://127.0.0.1:9".into(),
527            timeout: Duration::from_millis(50),
528            hydrate_catalogs: false,
529            ..Default::default()
530        })
531        .expect("client");
532
533        client.wait_for_catalogs().await.expect("disabled");
534        assert!(client.catalog_ready.get().is_none());
535    }
536}