Skip to main content

bullet_rust_sdk/
client.rs

1use std::ops::Deref;
2use std::sync::Mutex;
3
4use bon::bon;
5use bullet_exchange_interface::message::UserActionDiscriminants;
6use bullet_exchange_interface::transaction::{Amount, Gas, PriorityFeeBips};
7use bullet_exchange_interface::types::MarketId;
8use url::Url;
9
10use crate::generated::Client as GeneratedClient;
11use crate::metadata::{ExchangeMetadata, SymbolInfo};
12use crate::types::CallMessage;
13use crate::{Keypair, SDKError, SDKResult};
14
15/// The main trading API client for REST operations.
16///
17/// Provides methods for building, signing, and submitting transactions,
18/// as well as access to all generated API methods via `Deref`.
19///
20/// # WebSocket Support
21///
22/// For real-time market data and WebSocket order submission, use `WsClient`
23/// separately. See the `ws` module documentation for details.
24///
25/// # Example
26///
27/// ```ignore
28/// use bullet_rust_sdk::{Network, Client};
29///
30/// // Connect to REST API
31/// let api = Client::mainnet().await?;
32///
33/// // Query via REST
34/// let info = api.exchange_info().await?;
35/// ```
36pub struct Client {
37    rest_url: String,
38    ws_url: String,
39    solana_offchain_url: String,
40    generated_client: GeneratedClient,
41    pub(crate) http_client: reqwest::Client,
42    pub(crate) ws_client: reqwest::Client,
43    chain_id: u64,
44    chain_hash: Mutex<[u8; 32]>,
45    chain_name: String,
46    user_actions: Option<Vec<UserActionDiscriminants>>,
47
48    keypair: Option<Keypair>,
49
50    // Exchange metadata (symbol lookups)
51    metadata: ExchangeMetadata,
52
53    // Transaction Options
54    max_priority_fee_bips: PriorityFeeBips,
55    /// The max fee one is willing to pay for this transaction.
56    max_fee: Amount,
57    /// Optionally limit the number of gas to be used.
58    gas_limit: Option<Gas>,
59}
60
61/// Known network environments.
62///
63/// Use with the Client builder to connect to a known network by name,
64/// or provide a custom URL.
65///
66/// # Example
67///
68/// ```ignore
69/// use bullet_rust_sdk::{Client, Network};
70///
71/// // Known network
72/// let client = Client::builder().network(Network::Testnet).build().await?;
73///
74/// // Custom URL (auto-converts via From<&str>)
75/// let client = Client::builder().network("https://custom.example.com").build().await?;
76/// ```
77#[derive(Debug, Clone)]
78pub enum Network {
79    Mainnet,
80    Testnet,
81    Custom(String),
82}
83
84impl Network {
85    /// Get the REST API URL for this network.
86    pub fn url(&self) -> &str {
87        match self {
88            Network::Mainnet => "https://tradingapi.bullet.xyz",
89            Network::Testnet => "https://tradingapi.testnet.bullet.xyz",
90            Network::Custom(url) => url,
91        }
92    }
93}
94
95impl From<&str> for Network {
96    fn from(s: &str) -> Self {
97        match s.to_lowercase().as_str() {
98            "mainnet" => Network::Mainnet,
99            "testnet" => Network::Testnet,
100            _ => Network::Custom(s.to_string()),
101        }
102    }
103}
104
105impl From<String> for Network {
106    fn from(s: String) -> Self {
107        Network::from(s.as_str())
108    }
109}
110
111pub struct ChainData {
112    pub chain_hash: [u8; 32],
113    pub chain_id: u64,
114    pub chain_name: String,
115}
116
117pub const MAX_FEE: &Amount = &Amount(10000000000_u128);
118pub const MAX_PRIORITY_FEE_BIPS: &PriorityFeeBips = &PriorityFeeBips(0);
119
120#[bon]
121impl Client {
122    /// Create a new Client connected to a network.
123    #[builder]
124    pub async fn new(
125        #[builder(into)] network: Network,
126        /// Custom reqwest client for REST requests.
127        ///
128        /// **Note:** WebSocket connections use a separate HTTP/1.1 client that does
129        /// not inherit settings from this client (e.g. proxy, TLS roots). This is a
130        /// reqwest limitation — existing clients can't be reconfigured after construction.
131        reqwest_client: Option<reqwest::Client>,
132        max_priority_fee_bips: Option<PriorityFeeBips>,
133        max_fee: Option<Amount>,
134        gas_limit: Option<Gas>,
135        keypair: Option<Keypair>,
136        /// Override the Solana offchain sequencer endpoint.
137        ///
138        /// By default this is derived from the selected Bullet network and used
139        /// by `Client::send_offchain_transaction`.
140        #[builder(into)]
141        solana_offchain_url: Option<String>,
142        /// Restrict schema validation to specific `UserAction` variants.
143        ///
144        /// By default (`None`), the client validates every exchange `CallMessage`
145        /// branch (`User`, `Vault`, `Keeper`, `Public`, and `Admin`) against the remote
146        /// schema and returns `SDKError::SchemaOutdated` if any differ. If you only use
147        /// a subset of user actions (e.g. `PlaceOrders`), you can pass them here to
148        /// intentionally prune validation down to those `UserAction` variants.
149        ///
150        /// **Warning:** When this is set, non-`User` call messages and unlisted
151        /// `UserAction` variants are rejected before signing because their schema branch
152        /// was not validated at connect time.
153        user_actions: Option<Vec<UserActionDiscriminants>>,
154    ) -> SDKResult<Self> {
155        let url = network.url();
156        let parsed = Url::parse(url).map_err(|_| SDKError::InvalidNetworkUrl)?;
157
158        let (rest_url, ws_url) = match parsed.scheme() {
159            "https" => (url.to_string(), format!("wss://{}/ws", parsed.authority())),
160            "http" => (url.to_string(), format!("ws://{}/ws", parsed.authority())),
161            _ => return Err(SDKError::InvalidNetworkUrl),
162        };
163        let http_client = reqwest_client.unwrap_or_default();
164        let generated_client = GeneratedClient::new_with_client(&rest_url, http_client.clone());
165        let solana_offchain_url =
166            solana_offchain_url.unwrap_or_else(|| Self::default_solana_offchain_url(&rest_url));
167
168        // WebSocket requires HTTP/1.1 (HTTP/2 does not support the Upgrade mechanism).
169        // We always build a dedicated HTTP/1.1 client for WS, regardless of whether
170        // the caller supplied a custom reqwest client for REST.
171        #[cfg(not(target_arch = "wasm32"))]
172        let ws_client = reqwest::Client::builder().http1_only().build()?;
173        #[cfg(target_arch = "wasm32")]
174        let ws_client = reqwest::Client::new();
175
176        // fetch schema
177        let chain_data = Self::fetch_schema(&generated_client, &user_actions).await?;
178
179        let max_priority_fee_bips = max_priority_fee_bips.unwrap_or(*MAX_PRIORITY_FEE_BIPS);
180        let max_fee = max_fee.unwrap_or(*MAX_FEE);
181
182        let exchange_info = generated_client.exchange_info().await?;
183        let metadata = ExchangeMetadata::from_symbols(&exchange_info.into_inner().symbols);
184
185        Ok(Self {
186            rest_url,
187            ws_url,
188            solana_offchain_url,
189            generated_client,
190            http_client,
191            ws_client,
192            chain_id: chain_data.chain_id,
193            chain_hash: Mutex::new(chain_data.chain_hash),
194            chain_name: chain_data.chain_name,
195            user_actions,
196            gas_limit,
197            max_priority_fee_bips,
198            max_fee,
199            keypair,
200            metadata,
201        })
202    }
203
204    async fn fetch_schema(
205        generated_client: &GeneratedClient,
206        user_actions: &Option<Vec<UserActionDiscriminants>>,
207    ) -> SDKResult<ChainData> {
208        use bullet_exchange_interface::schema::{Schema, SchemaFile, trim};
209        use bullet_exchange_interface::transaction::Transaction;
210
211        let schema_obj = generated_client.schema().await?;
212
213        // validate the remote schema
214        let obj = schema_obj.into_inner();
215        let sobj = serde_json::to_string(&obj)
216            .map_err(|_| SDKError::InvalidSchemaResponse("failed to serialize schema"))?;
217        let schema_file = serde_json::from_str::<SchemaFile>(&sobj)
218            .map_err(|_| SDKError::InvalidSchemaResponse("failed to parse SchemaFile"))?;
219        let our_schema = Schema::of_single_type::<Transaction>()
220            .map_err(|_| SDKError::InvalidSchemaResponse("failed to derive local schema"))?;
221        let filter = |name: &str, variant: &str| {
222            Self::filter_variants(name, variant, user_actions.as_deref())
223        };
224        let left = trim(&our_schema, &filter);
225        let right = trim(&schema_file.schema, &filter);
226        if left != right {
227            return Err(SDKError::SchemaOutdated);
228        }
229
230        // get chain_hash
231        let chain_hash_bytes = hex::decode(schema_file.chain_hash.replace("0x", ""))
232            .map_err(|e| SDKError::InvalidChainHash(e.to_string()))?;
233
234        let chain_hash = chain_hash_bytes.try_into().map_err(|v: Vec<u8>| {
235            SDKError::InvalidChainHash(format!("expected 32 bytes, got {}", v.len()))
236        })?;
237        let chain_id = schema_file.schema.chain_data().chain_id;
238        let chain_name = schema_file.schema.chain_data().chain_name.clone();
239        Ok(ChainData { chain_hash, chain_id, chain_name })
240    }
241
242    pub async fn update_schema(&self) -> SDKResult<()> {
243        let chain_data = Self::fetch_schema(self.client(), self.user_actions()).await?;
244
245        // The expect is fine here as we just read and write the
246        // object. We never hold a lock in code that can panic.
247        *self.chain_hash.lock().expect("Taking the chain-hash lock can never fail.") =
248            chain_data.chain_hash;
249        Ok(())
250    }
251
252    fn default_solana_offchain_url(rest_url: &str) -> String {
253        let Ok(parsed) = Url::parse(rest_url) else {
254            return rest_url.to_string();
255        };
256
257        let path = "/sequencer/solana_offchain_txs";
258        match parsed.host_str() {
259            Some("tradingapi.bullet.xyz") => {
260                "https://rollup.mainnet.bullet.xyz/sequencer/solana_offchain_txs".to_string()
261            }
262            Some("tradingapi.testnet.bullet.xyz") => {
263                "https://rollup.testnet.bullet.xyz/sequencer/solana_offchain_txs".to_string()
264            }
265            Some(_) => format!("{}://{}{}", parsed.scheme(), parsed.authority(), path),
266            None => rest_url.to_string(),
267        }
268    }
269
270    /// Decides whether a given enum variant should be included in the schema
271    /// comparison between our compiled types and the remote API.
272    ///
273    /// Called by [`trim`] for every `(enum_name, variant_name)` pair in the
274    /// schema tree. Returning `true` keeps the variant; `false` prunes it
275    /// from both sides before diffing so that changes to pruned variants
276    /// don't trigger [`SDKError::SchemaOutdated`].
277    ///
278    /// The fixed rules pin the transaction envelope the SDK serializes:
279    ///   `Transaction::V0 → RuntimeCall::Exchange → CallMessage::*`
280    ///
281    /// By default, every exchange `CallMessage` group is kept. When `user_actions`
282    /// is set, validation is intentionally pruned to:
283    ///   `CallMessage::User → selected UserAction::*`
284    ///
285    /// For `UserAction`, the behaviour depends on `user_actions`:
286    /// - `None` — include every variant (full exchange schema check).
287    /// - `Some(&[PlaceOrders, CancelOrders])` — only include those two; schema changes to other
288    ///   actions (e.g. `Withdraw`) are ignored.
289    ///
290    /// Unknown enum names default to `true` so any new enums in the schema
291    /// are kept, ensuring the diff still catches unexpected additions.
292    fn filter_variants(
293        name: &str,
294        variant: &str,
295        user_actions: Option<&[UserActionDiscriminants]>,
296    ) -> bool {
297        match name {
298            "Transaction" => variant == "V0",
299            "RuntimeCall" => variant == "Exchange",
300            "CallMessage" => user_actions.is_none() || variant == "User",
301            "UserAction" => match user_actions {
302                Some(actions) => UserActionDiscriminants::try_from(variant)
303                    .map(|v| actions.contains(&v))
304                    .unwrap_or(false),
305                None => true,
306            },
307            "UniquenessData" => variant == "Generation",
308            _ => {
309                // include the variant - to be sure we fail afterwards
310                true
311            }
312        }
313    }
314
315    pub(crate) fn call_message_was_validated(
316        call_message: &CallMessage,
317        user_actions: Option<&[UserActionDiscriminants]>,
318    ) -> bool {
319        let Some(user_actions) = user_actions else {
320            return true;
321        };
322
323        match call_message {
324            CallMessage::User(action) => user_actions.contains(&action.into()),
325            _ => false,
326        }
327    }
328
329    /// Connect to the mainnet environment.
330    ///
331    /// # Example
332    ///
333    /// ```no_run
334    /// use bullet_rust_sdk::Client;
335    ///
336    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
337    /// let api = Client::mainnet().await?;
338    /// let info = api.exchange_info().await?;
339    /// # Ok(())
340    /// # }
341    /// ```
342    pub async fn mainnet() -> SDKResult<Self> {
343        Self::builder().network(Network::Mainnet).build().await
344    }
345
346    /// Get a reference to the underlying generated client.
347    ///
348    /// Prefer using `Deref` (calling methods directly on `Client`)
349    /// instead of this method.
350    pub fn client(&self) -> &GeneratedClient {
351        &self.generated_client
352    }
353
354    /// Get the chain ID for this network.
355    pub fn chain_id(&self) -> u64 {
356        self.chain_id
357    }
358
359    /// Get the current chain hash.
360    pub fn chain_hash(&self) -> [u8; 32] {
361        // The expect is fine here as we just read and write the
362        // object. We never hold a lock in code that can panic.
363        *self.chain_hash.lock().expect("Taking the chain-hash lock can never fail.")
364    }
365
366    /// Get the chain name for this network.
367    pub fn chain_name(&self) -> String {
368        self.chain_name.clone()
369    }
370
371    pub fn user_actions(&self) -> &Option<Vec<UserActionDiscriminants>> {
372        &self.user_actions
373    }
374
375    /// The REST API URL.
376    pub fn url(&self) -> &str {
377        &self.rest_url
378    }
379    /// The websocket URL.
380    pub fn ws_url(&self) -> &str {
381        &self.ws_url
382    }
383
384    /// The Solana offchain sequencer URL.
385    pub fn solana_offchain_url(&self) -> &str {
386        &self.solana_offchain_url
387    }
388
389    /// Get the default keypair for signing transactions.
390    pub fn keypair(&self) -> Option<&Keypair> {
391        self.keypair.as_ref()
392    }
393
394    /// Get the default max fee for transactions.
395    pub fn max_fee(&self) -> Amount {
396        self.max_fee
397    }
398
399    /// Get the default max priority fee in basis points.
400    pub fn max_priority_fee_bips(&self) -> PriorityFeeBips {
401        self.max_priority_fee_bips
402    }
403
404    /// Get the default gas limit for transactions.
405    pub fn gas_limit(&self) -> Option<Gas> {
406        self.gas_limit.clone()
407    }
408
409    // ── Symbol / Market Lookups ─────────────────────────────────────────
410
411    /// Resolve a symbol string to its [`MarketId`].
412    ///
413    /// Returns `None` if the symbol is not found in the cached metadata.
414    ///
415    /// # Example
416    ///
417    /// ```ignore
418    /// let market_id = client.market_id("BTC-USD").expect("unknown symbol");
419    /// client.place_orders(market_id, orders, false, None).await?;
420    /// ```
421    pub fn market_id(&self, symbol: &str) -> Option<MarketId> {
422        self.metadata.market_id(symbol)
423    }
424
425    /// Get all available symbols and their metadata.
426    pub fn symbols(&self) -> &[SymbolInfo] {
427        self.metadata.symbols()
428    }
429
430    /// Look up symbol info by [`MarketId`].
431    pub fn symbol_info(&self, market_id: MarketId) -> Option<&SymbolInfo> {
432        self.metadata.symbol_info_by_id(market_id)
433    }
434
435    /// Look up symbol info by name.
436    pub fn symbol_info_by_name(&self, symbol: &str) -> Option<&SymbolInfo> {
437        self.metadata.symbol_info_by_name(symbol)
438    }
439
440    /// Re-fetch exchange metadata from the server.
441    ///
442    /// Call this in long-running bots to pick up newly listed markets.
443    pub async fn refresh_metadata(&mut self) -> SDKResult<()> {
444        let info = self.generated_client.exchange_info().await?;
445        self.metadata = ExchangeMetadata::from_symbols(&info.into_inner().symbols);
446        Ok(())
447    }
448}
449
450/// Implement Deref to allow calling generated client methods directly.
451///
452/// This enables ergonomic access to all API methods:
453///
454/// ```ignore
455/// let api = Client::new(url, None).await?;
456/// let info = api.exchange_info().await?;
457/// ```
458impl Deref for Client {
459    type Target = GeneratedClient;
460
461    fn deref(&self) -> &Self::Target {
462        self.client()
463    }
464}
465
466#[cfg(test)]
467mod tests {
468    use bullet_exchange_interface::message::PublicAction;
469
470    use super::*;
471    use crate::types::UserAction;
472
473    #[test]
474    fn default_solana_offchain_url_derives_non_public_bullet_hosts_from_rest_url() {
475        let environment = ["stag", "ing"].concat();
476        let rest_url = format!("https://tradingapi.{environment}.bullet.xyz");
477
478        assert_eq!(
479            Client::default_solana_offchain_url(&rest_url),
480            format!("{rest_url}/sequencer/solana_offchain_txs")
481        );
482    }
483
484    #[test]
485    fn default_solana_offchain_url_maps_known_public_networks_to_rollup() {
486        assert_eq!(
487            Client::default_solana_offchain_url("https://tradingapi.bullet.xyz"),
488            "https://rollup.mainnet.bullet.xyz/sequencer/solana_offchain_txs"
489        );
490        assert_eq!(
491            Client::default_solana_offchain_url("https://tradingapi.testnet.bullet.xyz"),
492            "https://rollup.testnet.bullet.xyz/sequencer/solana_offchain_txs"
493        );
494    }
495
496    #[test]
497    fn default_schema_filter_keeps_all_call_message_groups() {
498        for variant in ["User", "Vault", "Keeper", "Public", "Admin"] {
499            assert!(Client::filter_variants("CallMessage", variant, None));
500        }
501    }
502
503    #[test]
504    fn selective_schema_filter_keeps_only_user_call_messages() {
505        let selected = [UserActionDiscriminants::PlaceOrders];
506
507        assert!(Client::filter_variants("CallMessage", "User", Some(&selected)));
508        for variant in ["Vault", "Keeper", "Public", "Admin"] {
509            assert!(!Client::filter_variants("CallMessage", variant, Some(&selected)));
510        }
511    }
512
513    #[test]
514    fn selective_schema_filter_keeps_only_selected_user_actions() {
515        let selected = [UserActionDiscriminants::PlaceOrders];
516
517        assert!(Client::filter_variants("UserAction", "PlaceOrders", Some(&selected)));
518        assert!(!Client::filter_variants("UserAction", "CancelOrders", Some(&selected)));
519    }
520
521    #[test]
522    fn selective_mode_rejects_unvalidated_call_messages() {
523        let selected = [UserActionDiscriminants::PlaceOrders];
524        let public_call = CallMessage::Public(PublicAction::ApplyFunding { addresses: vec![] });
525        let unselected_user_call =
526            CallMessage::User(UserAction::CancelAllOrders { sub_account_index: None });
527
528        assert!(!Client::call_message_was_validated(&public_call, Some(&selected)));
529        assert!(!Client::call_message_was_validated(&unselected_user_call, Some(&selected),));
530    }
531}