1use 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#[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
72pub 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 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 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 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 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 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 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 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}