Skip to main content

bulk_client/api/
bulk_http.rs

1//! Bulk Labs HTTP REST API Client
2//!
3//! Provides complete HTTP REST API access to the Bulk Labs exchange:
4//! - Market data endpoints (unsigned)
5//! - Account query endpoints (unsigned)
6//! - Trading endpoints (signed)
7//! - Private endpoints (signed — faucet, etc.)
8//!
9//! # Example
10//!
11//! ```rust,no_run
12//! use bulk_client::*;
13//! use bulk_client::common::side::Side;
14//! use bulk_client::common::tif::TimeInForce;
15//!
16//! #[tokio::main]
17//! async fn main() -> eyre::Result<()> {
18//!     // Read-only client (market data + account queries)
19//!     let client = BulkHttpClient::with_url(
20//!         "https://exchange-api.bulk.trade/api/v1", None,
21//!     )?;
22//!
23//!     let ticker = client.get_ticker("BTC-USD").await?;
24//!     println!("BTC mark: {}", ticker.mark_price);
25//!
26//!     // Authenticated client (trading)
27//!     let client = BulkHttpClient::with_url(
28//!         "https://exchange-api.bulk.trade/api/v1",
29//!         Some("your_base58_private_key"),
30//!     )?;
31//!     let resp = client.place_limit_order(
32//!         "BTC-USD", Side::Buy, 95_000.0, 0.001,
33//!         TimeInForce::GTC, false, None, None,
34//!     ).await?;
35//!     Ok(())
36//! }
37//! ```
38
39use std::collections::HashMap;
40use std::str::FromStr;
41use std::sync::Arc;
42use std::time::Duration;
43use reqwest::{Client, Url};
44use serde::Deserialize;
45use serde_json::{json, Value};
46use solana_pubkey::Pubkey;
47use solana_hash::Hash;
48use crate::api::parts::{make_nonce, HttpConfig};
49use crate::common::side::Side;
50use crate::common::tif::TimeInForce;
51use crate::msgs::*;
52use crate::transaction::{Action, ActionMeta, Transaction, TransactionSigner};
53
54/// HTTP REST API client for Bulk Labs exchange.
55///
56/// Supports both public (unsigned) and private (signed) endpoints.
57/// Construct with `None` for read-only access or provide a private key
58/// for trading operations.
59#[derive(Clone)]
60#[allow(unused)]
61pub struct BulkHttpClient {
62    config: HttpConfig,
63    client: Client,
64    is_localhost: bool,
65}
66
67#[allow(unused)]
68impl BulkHttpClient {
69    /// Create bulk HTTP client
70    ///
71    /// # Arguments
72    /// - `config`: http client config
73    pub fn new (config: &HttpConfig) -> eyre::Result<Self> {
74        let client = Client::builder()
75            .timeout(config.default_timeout)
76            .build()?;
77
78        let is_localhost = Self::is_localhost(&config.base_url);
79
80        Ok(Self {
81            config: config.clone(),
82            client,
83            is_localhost,
84        })
85    }
86
87    /// Create bulk HTTP client with url, private key
88    ///
89    /// # Arguments
90    /// - `base_url`: http client url
91    /// - `private_key`: optional private key
92    pub fn with_url (base_url: &str, private_key: Option<&str>) -> eyre::Result<Self> {
93        if let Some(private_key) = private_key {
94            let signer = TransactionSigner::from_private_key(private_key)?;
95            let config = HttpConfig {
96                base_url: base_url.to_string(),
97                signer: Some(signer),
98                default_timeout: Duration::from_secs(10)
99            };
100            Self::new(&config)
101        } else {
102            let config = HttpConfig {
103                base_url: base_url.to_string(),
104                signer: None,
105                default_timeout: Duration::from_secs(10)
106            };
107            Self::new(&config)
108        }
109    }
110
111    /// Create bulk HTTP client with a pre-built signer (software key or Ledger).
112    ///
113    /// # Example
114    /// ```text
115    /// let signer = TransactionSigner::from_ledger("usb://ledger", None)?;
116    /// let client = BulkHttpClient::with_signer("https://exchange-api.bulk.trade/api/v1", signer)?;
117    /// let resp = client.request_faucet(None, None, None).await?;
118    /// ```
119    pub fn with_signer(base_url: &str, signer: TransactionSigner) -> eyre::Result<Self> {
120        let config = HttpConfig {
121            base_url: base_url.to_string(),
122            signer: Some(signer),
123            default_timeout: Duration::from_secs(10),
124        };
125        Self::new(&config)
126    }
127
128    /// Channel configuration
129    pub fn config(&self) -> &HttpConfig {
130        &self.config
131    }
132    
133    /// Pubkeyt associated with this channel
134    pub fn public_key(&self) -> Option<Pubkey> {
135        self.config.signer.as_ref()
136            .map(|x| x.public_key())
137    }
138
139    // =====================================================================
140    // MARKET DATA ENDPOINTS (PUBLIC, UNSIGNED)
141    // =====================================================================
142
143    /// Get exchange information including all available markets.
144    pub async fn get_exchange_info(&self) -> eyre::Result<Vec<MarketInfo>> {
145        let resp = self
146            .client
147            .get(format!("{}/exchangeInfo", self.config.base_url))
148            .send()
149            .await?
150            .error_for_status()?;
151        Ok(resp.json().await?)
152    }
153
154    /// Get market ticker/statistics for a symbol.
155    pub async fn get_ticker(&self, symbol: &str) -> eyre::Result<Ticker> {
156        let resp = self
157            .client
158            .get(format!("{}/ticker/{}", self.config.base_url, symbol))
159            .send()
160            .await?
161            .error_for_status()?;
162        Ok(resp.json().await?)
163    }
164
165    /// Get historical candlestick/OHLCV data.
166    ///
167    /// # Arguments
168    /// - `symbol`: Market symbol (e.g. "BTC-USD")
169    /// - `interval`: Candle interval ("1m", "5m", "15m", "30m", "1h", "4h", "1d", "1w")
170    /// - `start_time`: Optional start timestamp in milliseconds
171    /// - `end_time`: Optional end timestamp in milliseconds
172    /// - `limit`: Maximum candles to return (default 500, max 1000)
173    pub async fn get_klines(
174        &self,
175        symbol: &str,
176        interval: &str,
177        start_time: Option<u64>,
178        end_time: Option<u64>,
179        limit: Option<u32>,
180    ) -> eyre::Result<Vec<Candle>> {
181        let mut params = vec![
182            ("symbol".to_string(), symbol.to_string()),
183            ("interval".to_string(), interval.to_string()),
184            ("limit".to_string(), limit.unwrap_or(500).to_string()),
185        ];
186        if let Some(st) = start_time {
187            params.push(("startTime".to_string(), st.to_string()));
188        }
189        if let Some(et) = end_time {
190            params.push(("endTime".to_string(), et.to_string()));
191        }
192
193        let resp = self
194            .client
195            .get(format!("{}/klines", self.config.base_url))
196            .query(&params)
197            .send()
198            .await?
199            .error_for_status()?;
200        Ok(resp.json().await?)
201    }
202
203    /// Get L2 order book snapshot.
204    ///
205    /// # Arguments
206    /// - `symbol`: Market symbol
207    /// - `nlevels`: Number of price levels per side (default 20, max 1000)
208    /// - `aggregation`: Optional price aggregation/grouping
209    pub async fn get_orderbook(
210        &self,
211        symbol: &str,
212        nlevels: Option<u32>,
213        aggregation: Option<f64>,
214    ) -> eyre::Result<L2Snapshot> {
215        let mut params = vec![
216            ("type".to_string(), "l2Book".to_string()),
217            ("coin".to_string(), symbol.to_string()),
218            ("nlevels".to_string(), nlevels.unwrap_or(20).to_string()),
219        ];
220        if let Some(agg) = aggregation {
221            params.push(("aggregation".to_string(), agg.to_string()));
222        }
223
224        let resp = self
225            .client
226            .get(format!("{}/l2book", self.config.base_url))
227            .query(&params)
228            .send()
229            .await?
230            .error_for_status()?;
231        Ok(resp.json().await?)
232    }
233
234    // =====================================================================
235    // ACCOUNT ENDPOINTS (PUBLIC, UNSIGNED)
236    // =====================================================================
237
238    /// Get complete account state including positions, orders, and margin.
239    ///
240    /// # Arguments
241    /// - `user`: user pubkey to query
242    pub async fn get_account(&self, user: Pubkey) -> eyre::Result<AccountData> {
243        #[derive(Debug, Clone, Deserialize)]
244        #[serde(rename_all = "camelCase")]
245        pub struct FullAccountResponse {
246            pub full_account: AccountData,
247        }
248
249        let user: String = user.to_string();
250
251        let resp = self
252            .client
253            .post(format!("{}/account", self.config.base_url))
254            .json(&json!({ "type": "fullAccount", "user": user }))
255            .send()
256            .await?
257            .error_for_status()?;
258
259        let arr: Vec<FullAccountResponse> = resp.json().await?;
260        arr.into_iter()
261            .next()
262            .map(|r| r.full_account)
263            .ok_or_else(|| eyre::eyre!("empty fullAccount response"))
264    }
265
266    /// Get resting orders for an account.
267    ///
268    /// # Arguments
269    /// - `user`: user pubkey to query
270    pub async fn get_open_orders(&self, user: &str) -> eyre::Result<Vec<OrderState>> {
271        let resp = self
272            .client
273            .post(format!("{}/account", self.config.base_url))
274            .json(&json!({ "type": "openOrders", "user": user }))
275            .send()
276            .await?
277            .error_for_status()?;
278        Ok(resp.json().await?)
279    }
280
281    /// Get trade history (up to 5000 recent fills).
282    ///
283    /// # Arguments
284    /// - `user`: user pubkey to query
285    pub async fn get_fills(&self, user: &str) -> eyre::Result<Vec<Fill>> {
286        let resp = self
287            .client
288            .post(format!("{}/account", self.config.base_url))
289            .json(&json!({ "type": "fills", "user": user }))
290            .send()
291            .await?
292            .error_for_status()?;
293        Ok(resp.json().await?)
294    }
295
296    /// Get closed position history (up to 5000 positions).
297    ///
298    /// # Arguments
299    /// - `user`: user pubkey to query
300    pub async fn get_position_history(&self, user: &str) -> eyre::Result<Vec<PositionInfo>> {
301        let resp = self
302            .client
303            .post(format!("{}/account", self.config.base_url))
304            .json(&json!({ "type": "positions", "user": user }))
305            .send()
306            .await?
307            .error_for_status()?;
308        Ok(resp.json().await?)
309    }
310
311    // =====================================================================
312    // Trading (SIGNED)
313    // =====================================================================
314
315    /// Place multiple order actions in a single signed transaction.
316    ///
317    /// Accepts any mix of limit orders, market orders, cancels, and cancel-alls.
318    ///
319    /// # Example
320    /// ```text
321    /// let resp = client.place_tx(vec![
322    ///     Action::LimitOrder(LimitOrder { .. }),
323    ///     Action::CancelAll(CancelAll { .. }),
324    /// ], None, None).await?;
325    /// ```
326    pub async fn place_tx(
327        &self,
328        actions: Vec<Action>,
329        account: Option<Pubkey>,
330        nonce: Option<u64>,
331    ) -> eyre::Result<Vec<Response>> {
332        let signer = self
333            .config
334            .signer
335            .as_ref()
336            .ok_or_else(|| eyre::eyre!("Private key required for trading operations"))?;
337
338        let account = if let Some(account) = account {
339            account
340        } else {
341            signer.public_key()
342        };
343
344        let nonce = nonce.unwrap_or_else(make_nonce);
345        let pk = signer.public_key();
346
347        // Build + sign the transaction
348        let mut tx = Transaction {
349            actions,
350            nonce,
351            account: account,
352            signer: signer.public_key(),
353            signature: Default::default(),
354        };
355        tx.sign(signer)?;
356
357        // Build JSON body via tx serialization
358        let body = serde_json::to_string(&tx)?;
359
360        let mut request = self
361            .client
362            .post(format!("{}/order", self.config.base_url))
363            .header("content-type", "application/json");
364        if let Some(mode) = signer.tx_signature_mode_hint_header_value() {
365            request = request.header("X-Bulk-Sig-Mode", mode);
366        }
367        let resp = request.body(body).send().await?.error_for_status()?;
368
369        let data: Value = resp.json().await?;
370        Ok(Response::parse_responses(&data))
371    }
372
373    /// Place a single limit order.
374    pub async fn place_limit_order(
375        &self,
376        symbol: &str,
377        side: Side,
378        price: f64,
379        size: f64,
380        tif: TimeInForce,
381        reduce_only: bool,
382        account: Option<Pubkey>,
383        nonce: Option<u64>,
384    ) -> eyre::Result<Response> {
385        let signer = self
386            .config
387            .signer
388            .as_ref()
389            .ok_or_else(|| eyre::eyre!("Private key required for trading operations"))?;
390
391        let account = if let Some(account) = account {
392            account
393        } else {
394            signer.public_key()
395        };
396
397        let nonce = nonce.unwrap_or_else(make_nonce);
398
399        let order = LimitOrder {
400            symbol: Arc::from(symbol),
401            is_buy: side == Side::Buy,
402            price,
403            size,
404            tif,
405            reduce_only,
406            iso: false,
407            meta: ActionMeta {
408                account,
409                nonce,
410                seqno: 0,
411                hash: None,
412            }
413        };
414
415        let results = self.place_tx(vec![order.into()], None, None).await?;
416        Ok(results[0].clone())
417    }
418
419    /// Place a single market order.
420    pub async fn place_market_order(
421        &self,
422        symbol: &str,
423        side: Side,
424        size: f64,
425        reduce_only: bool,
426        account: Option<Pubkey>,
427        nonce: Option<u64>,
428    ) -> eyre::Result<Response> {
429        let signer = self
430            .config
431            .signer
432            .as_ref()
433            .ok_or_else(|| eyre::eyre!("Private key required for trading operations"))?;
434
435        let account = if let Some(account) = account {
436            account
437        } else {
438            signer.public_key()
439        };
440
441        let nonce = nonce.unwrap_or_else(make_nonce);
442
443        let order = MarketOrder {
444            symbol: Arc::from(symbol),
445            is_buy: side == Side::Buy,
446            size,
447            reduce_only,
448            iso: false,
449            meta: ActionMeta {
450                account,
451                nonce,
452                seqno: 0,
453                hash: None,
454            }
455        };
456
457        let results = self.place_tx(vec![order.into()], None, None).await?;
458        Ok(results[0].clone())
459    }
460
461    /// Cancel a single order by ID.
462    pub async fn cancel_order(
463        &self,
464        symbol: &str,
465        order_id: &str,
466        account: Option<Pubkey>,
467        nonce: Option<u64>,
468    ) -> eyre::Result<Response> {
469        let signer = self
470            .config
471            .signer
472            .as_ref()
473            .ok_or_else(|| eyre::eyre!("Private key required for trading operations"))?;
474
475        let account = if let Some(account) = account {
476            account
477        } else {
478            signer.public_key()
479        };
480
481        let nonce = nonce.unwrap_or_else(make_nonce);
482        let cancel = CancelOrder {
483            symbol: symbol.to_string(),
484            oid: Hash::from_str(&order_id)?,
485            meta: ActionMeta {
486                account,
487                nonce,
488                seqno: 0,
489                hash: None,
490            }
491        };
492
493        let results = self.place_tx(vec![cancel.into()], None, None).await?;
494        Ok(results[0].clone())
495    }
496
497    /// Cancel all orders, optionally filtered by symbols.
498    pub async fn cancel_all(
499        &self,
500        symbols: Vec<String>,
501        account: Option<Pubkey>,
502        nonce: Option<u64>,
503    ) -> eyre::Result<Response> {
504        let signer = self
505            .config
506            .signer
507            .as_ref()
508            .ok_or_else(|| eyre::eyre!("Private key required for trading operations"))?;
509
510        let account = if let Some(account) = account {
511            account
512        } else {
513            signer.public_key()
514        };
515
516        let nonce = nonce.unwrap_or_else(make_nonce);
517        let cancel = CancelAll {
518            symbols,
519            meta: ActionMeta {
520                account,
521                nonce,
522                seqno: 0,
523                hash: None,
524            }
525        };
526
527        let results = self.place_tx(vec![cancel.into()], None, None).await?;
528        Ok(results[0].clone())
529    }
530
531    // =====================================================================
532    // Meta (SIGNED)
533    // =====================================================================
534
535    /// Update maximum leverage settings for markets.
536    ///
537    /// # Arguments
538    /// - `settings`: Map of (symbol, max_leverage) pairs
539    pub async fn update_leverage(
540        &self,
541        settings: HashMap<String, f64>,
542        account: Option<Pubkey>,
543        nonce: Option<u64>,
544    ) -> eyre::Result<Response> {
545        let signer = self
546            .config
547            .signer
548            .as_ref()
549            .ok_or_else(|| eyre::eyre!("Private key required for trading operations"))?;
550
551        let account = if let Some(account) = account {
552            account
553        } else {
554            signer.public_key()
555        };
556
557        let nonce = nonce.unwrap_or_else(make_nonce);
558        let settings = UpdateUserSettings {
559            max_leverage: settings,
560            meta: ActionMeta {
561                account,
562                nonce,
563                seqno: 0,
564                hash: None,
565            }
566        };
567
568        let results = self.place_tx(vec![settings.into()], None, None).await?;
569        Ok(results[0].clone())
570    }
571
572    /// Create or delete an agent wallet authorization.
573    ///
574    /// # Arguments
575    /// - `agent_pubkey`: Agent's public key (base58)
576    /// - `delete`: `true` to remove the agent, `false` to add
577    pub async fn manage_agent_wallet(
578        &self,
579        agent_pubkey: Pubkey,
580        delete: bool,
581        account: Option<Pubkey>,
582        nonce: Option<u64>,
583    ) -> eyre::Result<Response> {
584        let signer = self
585            .config
586            .signer
587            .as_ref()
588            .ok_or_else(|| eyre::eyre!("Private key required for trading operations"))?;
589
590        let account = if let Some(account) = account {
591            account
592        } else {
593            signer.public_key()
594        };
595
596        let nonce = nonce.unwrap_or_else(make_nonce);
597        let settings = AgentWalletCreation {
598            agent: agent_pubkey,
599            delete,
600            meta: ActionMeta {
601                account,
602                nonce,
603                seqno: 0,
604                hash: None,
605            }
606        };
607
608        let results = self.place_tx(vec![Action::AgentWalletCreation(settings)], None, None).await?;
609        Ok(results[0].clone())
610    }
611
612    // =====================================================================
613    // Testnet-only (SIGNED)
614    // =====================================================================
615
616    /// Whitelist or unwhitelist an account for testnet faucet access.
617    ///
618    /// **Testnet admin only.**
619    ///
620    /// # Arguments
621    /// - `target_account`: account to be whitelisted
622    /// - `whitelist`: if true is added whitelisted, if false removed
623    /// - `nonce`: tx nonce
624    pub async fn whitelist_faucet(
625        &self,
626        target_account: Pubkey,
627        whitelist: bool,
628        account: Option<Pubkey>,
629        nonce: Option<u64>,
630    ) -> eyre::Result<Response> {
631        let signer = self
632            .config
633            .signer
634            .as_ref()
635            .ok_or_else(|| eyre::eyre!("Private key required for trading operations"))?;
636
637        let account = if let Some(account) = account {
638            account
639        } else {
640            signer.public_key()
641        };
642
643        let nonce = nonce.unwrap_or_else(make_nonce);
644        let settings = WhitelistFaucet {
645            target: target_account,
646            whitelist,
647            meta: ActionMeta {
648                account,
649                nonce,
650                seqno: 0,
651                hash: None,
652            }
653        };
654        let results = self.place_tx(vec![Action::WhitelistFaucet(settings)], None, None).await?;
655        Ok(results[0].clone())
656    }
657
658    /// Request testnet faucet funds.
659    ///
660    /// **Testnet only.**
661    ///
662    /// # Arguments
663    /// - `user`: Optional target user public key (defaults to signer's key)
664    /// - `amount`: Optional specific amount (only for whitelisted accounts)
665    /// - `nonce`: Optional nonce
666    pub async fn request_faucet(
667        &self,
668        user: Option<Pubkey>,
669        amount: Option<f64>,
670        nonce: Option<u64>,
671    ) -> eyre::Result<Response> {
672        let signer = self
673            .config
674            .signer
675            .as_ref()
676            .ok_or_else(|| eyre::eyre!("Private key required for trading operations"))?;
677
678        let user = if let Some(user) = user {
679            user
680        } else {
681            signer.public_key()
682        };
683        let nonce = nonce.unwrap_or_else(make_nonce);
684
685        let req = Faucet {
686            user,
687            amount,
688            meta: ActionMeta {
689                account: user,
690                nonce,
691                seqno: 0,
692                hash: None,
693            }
694        };
695
696        let results = self.place_tx(vec![Action::Faucet(req)], None, None).await?;
697        Ok(results[0].clone())
698    }
699
700
701    // =====================================================================
702    // Internal helpers
703    // =====================================================================
704
705    /// determine if is localhost URL
706    fn is_localhost(url_str: &str) -> bool {
707        let Ok(url) = Url::parse(url_str) else { return false };
708        match url.host_str() {
709            Some("localhost" | "127.0.0.1" | "::1") => true,
710            _ => false,
711        }
712    }
713
714    /// Build a signed transaction envelope from a partial JSON body.
715    ///
716    /// Adds `account`, `signer`, and `signature` fields.
717    fn sign_generic_transaction(&self, mut body: Value) -> eyre::Result<Value> {
718        let signer = self
719            .config
720            .signer
721            .as_ref()
722            .ok_or_else(|| eyre::eyre!("Private key required"))?;
723
724        let pk_b58 = signer.public_key_b58();
725        body["account"] = json!(pk_b58);
726        body["signer"] = json!(pk_b58);
727
728        let sig = self.sign_action_payload(&body["action"])?;
729        body["signature"] = json!(sig);
730
731        Ok(body)
732    }
733
734    /// Sign the `action` portion of a transaction and return the base58 signature.
735    ///
736    /// This serializes the action JSON to a canonical string and signs it,
737    /// matching the Python SDK's `sign_transaction` behavior for generic
738    /// (non-order) payloads.
739    fn sign_action_payload(&self, action: &Value) -> eyre::Result<String> {
740        let signer = self
741            .config
742            .signer
743            .as_ref()
744            .ok_or_else(|| eyre::eyre!("Private key required"))?;
745
746        // The Python SDK signs the JSON-serialized action string.
747        // For order transactions we use the binary Signable path instead,
748        // but for generic endpoints (leverage, faucet, agent wallet, etc.)
749        // the exchange expects a signature over the canonical JSON.
750        let message = serde_json::to_string(action)?;
751        let sig = signer.sign_bytes(message.as_bytes())?;
752        Ok(bs58::encode(sig).into_string())
753    }
754}