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