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::{Keypair, SDKError, SDKResult};
13
14/// The main trading API client for REST operations.
15///
16/// Provides methods for building, signing, and submitting transactions,
17/// as well as access to all generated API methods via `Deref`.
18///
19/// # WebSocket Support
20///
21/// For real-time market data and WebSocket order submission, use `WsClient`
22/// separately. See the `ws` module documentation for details.
23///
24/// # Example
25///
26/// ```ignore
27/// use bullet_rust_sdk::{Network, Client};
28///
29/// // Connect to REST API
30/// let api = Client::mainnet().await?;
31///
32/// // Query via REST
33/// let info = api.exchange_info().await?;
34/// ```
35pub struct Client {
36    rest_url: String,
37    ws_url: String,
38    generated_client: GeneratedClient,
39    pub(crate) ws_client: reqwest::Client,
40    chain_id: u64,
41    chain_hash: Mutex<[u8; 32]>,
42    user_actions: Option<Vec<UserActionDiscriminants>>,
43
44    keypair: Option<Keypair>,
45
46    // Exchange metadata (symbol lookups)
47    metadata: ExchangeMetadata,
48
49    // Transaction Options
50    max_priority_fee_bips: PriorityFeeBips,
51    /// The max fee one is willing to pay for this transaction.
52    max_fee: Amount,
53    /// Optionally limit the number of gas to be used.
54    gas_limit: Option<Gas>,
55}
56
57/// Known network environments.
58///
59/// Use with the Client builder to connect to a known network by name,
60/// or provide a custom URL.
61///
62/// # Example
63///
64/// ```ignore
65/// use bullet_rust_sdk::{Client, Network};
66///
67/// // Known network
68/// let client = Client::builder().network(Network::Testnet).build().await?;
69///
70/// // Custom URL (auto-converts via From<&str>)
71/// let client = Client::builder().network("https://custom.example.com").build().await?;
72/// ```
73#[derive(Debug, Clone)]
74pub enum Network {
75    Mainnet,
76    Testnet,
77    Custom(String),
78}
79
80impl Network {
81    /// Get the REST API URL for this network.
82    pub fn url(&self) -> &str {
83        match self {
84            Network::Mainnet => "https://tradingapi.bullet.xyz",
85            Network::Testnet => "https://tradingapi.testnet.bullet.xyz",
86            Network::Custom(url) => url,
87        }
88    }
89}
90
91impl From<&str> for Network {
92    fn from(s: &str) -> Self {
93        match s.to_lowercase().as_str() {
94            "mainnet" => Network::Mainnet,
95            "testnet" => Network::Testnet,
96            _ => Network::Custom(s.to_string()),
97        }
98    }
99}
100
101impl From<String> for Network {
102    fn from(s: String) -> Self {
103        Network::from(s.as_str())
104    }
105}
106
107pub struct ChainData {
108    pub chain_hash: [u8; 32],
109    pub chain_id: u64,
110}
111
112pub const MAX_FEE: &Amount = &Amount(10000000000_u128);
113pub const MAX_PRIORITY_FEE_BIPS: &PriorityFeeBips = &PriorityFeeBips(0);
114
115#[bon]
116impl Client {
117    /// Create a new Client connected to a network.
118    #[builder]
119    pub async fn new(
120        #[builder(into)] network: Network,
121        /// Custom reqwest client for REST requests.
122        ///
123        /// **Note:** WebSocket connections use a separate HTTP/1.1 client that does
124        /// not inherit settings from this client (e.g. proxy, TLS roots). This is a
125        /// reqwest limitation — existing clients can't be reconfigured after construction.
126        reqwest_client: Option<reqwest::Client>,
127        max_priority_fee_bips: Option<PriorityFeeBips>,
128        max_fee: Option<Amount>,
129        gas_limit: Option<Gas>,
130        keypair: Option<Keypair>,
131        /// Restrict schema validation to specific `UserAction` variants.
132        ///
133        /// By default (`None`), the client validates that **all** `UserAction` variants
134        /// match the remote schema and returns `SDKError::SchemaOutdated` if any differ.
135        /// If you only use a subset of actions (e.g. `PlaceOrders`), you can pass them
136        /// here to avoid false negatives when unrelated variants change server-side.
137        ///
138        /// **Warning:** If you use an action not listed here, the client will silently
139        /// skip its schema check — a breaking change to that action's schema won't be
140        /// caught at connect time and may cause runtime serialization failures.
141        user_actions: Option<Vec<UserActionDiscriminants>>,
142    ) -> SDKResult<Self> {
143        let url = network.url();
144        let parsed = Url::parse(url).map_err(|_| SDKError::InvalidNetworkUrl)?;
145
146        let (rest_url, ws_url) = match parsed.scheme() {
147            "https" => (url.to_string(), format!("wss://{}/ws", parsed.authority())),
148            "http" => (url.to_string(), format!("ws://{}/ws", parsed.authority())),
149            _ => return Err(SDKError::InvalidNetworkUrl),
150        };
151        let generated_client = match reqwest_client {
152            Some(client) => GeneratedClient::new_with_client(&rest_url, client),
153            None => GeneratedClient::new(&rest_url),
154        };
155
156        // WebSocket requires HTTP/1.1 (HTTP/2 does not support the Upgrade mechanism).
157        // We always build a dedicated HTTP/1.1 client for WS, regardless of whether
158        // the caller supplied a custom reqwest client for REST.
159        #[cfg(not(target_arch = "wasm32"))]
160        let ws_client = reqwest::Client::builder().http1_only().build()?;
161        #[cfg(target_arch = "wasm32")]
162        let ws_client = reqwest::Client::new();
163
164        // fetch schema
165        let chain_data = Self::fetch_schema(&generated_client, &user_actions).await?;
166
167        let max_priority_fee_bips = max_priority_fee_bips.unwrap_or(*MAX_PRIORITY_FEE_BIPS);
168        let max_fee = max_fee.unwrap_or(*MAX_FEE);
169
170        let exchange_info = generated_client.exchange_info().await?;
171        let metadata = ExchangeMetadata::from_symbols(&exchange_info.into_inner().symbols);
172
173        Ok(Self {
174            rest_url,
175            ws_url,
176            generated_client,
177            ws_client,
178            chain_id: chain_data.chain_id,
179            chain_hash: Mutex::new(chain_data.chain_hash),
180            user_actions,
181            gas_limit,
182            max_priority_fee_bips,
183            max_fee,
184            keypair,
185            metadata,
186        })
187    }
188
189    async fn fetch_schema(
190        generated_client: &GeneratedClient,
191        user_actions: &Option<Vec<UserActionDiscriminants>>,
192    ) -> SDKResult<ChainData> {
193        use bullet_exchange_interface::schema::{Schema, SchemaFile, trim};
194        use bullet_exchange_interface::transaction::Transaction;
195
196        let schema_obj = generated_client.schema().await?;
197
198        // validate the remote schema
199        let obj = schema_obj.into_inner();
200        let sobj = serde_json::to_string(&obj)
201            .map_err(|_| SDKError::InvalidSchemaResponse("failed to serialize schema"))?;
202        let schema_file = serde_json::from_str::<SchemaFile>(&sobj)
203            .map_err(|_| SDKError::InvalidSchemaResponse("failed to parse SchemaFile"))?;
204        let our_schema = Schema::of_single_type::<Transaction>()
205            .map_err(|_| SDKError::InvalidSchemaResponse("failed to derive local schema"))?;
206        let filter = |name: &str, variant: &str| {
207            Self::filter_variants(name, variant, user_actions.as_deref())
208        };
209        let left = trim(&our_schema, &filter);
210        let right = trim(&schema_file.schema, &filter);
211        if left != right {
212            return Err(SDKError::SchemaOutdated);
213        }
214
215        // get chain_hash
216        let chain_hash_bytes = hex::decode(schema_file.chain_hash.replace("0x", ""))
217            .map_err(|e| SDKError::InvalidChainHash(e.to_string()))?;
218
219        let chain_hash = chain_hash_bytes.try_into().map_err(|v: Vec<u8>| {
220            SDKError::InvalidChainHash(format!("expected 32 bytes, got {}", v.len()))
221        })?;
222        let chain_id = schema_file.schema.chain_data().chain_id;
223        Ok(ChainData { chain_hash, chain_id })
224    }
225
226    pub async fn update_schema(&self) -> SDKResult<()> {
227        let chain_data = Self::fetch_schema(self.client(), self.user_actions()).await?;
228
229        // The expect is fine here as we just read and write the
230        // object. We never hold a lock in code that can panic.
231        *self.chain_hash.lock().expect("Taking the chain-hash lock can never fail.") =
232            chain_data.chain_hash;
233        Ok(())
234    }
235
236    /// Decides whether a given enum variant should be included in the schema
237    /// comparison between our compiled types and the remote API.
238    ///
239    /// Called by [`trim`] for every `(enum_name, variant_name)` pair in the
240    /// schema tree. Returning `true` keeps the variant; `false` prunes it
241    /// from both sides before diffing so that changes to pruned variants
242    /// don't trigger [`SDKError::SchemaOutdated`].
243    ///
244    /// The fixed rules pin the path the SDK actually serializes:
245    ///   `Transaction::V0 → RuntimeCall::Exchange → CallMessage::User → UserAction::*`
246    ///
247    /// For `UserAction`, the behaviour depends on `user_actions`:
248    /// - `None` — include every variant known to this binary (full check).
249    /// - `Some(&[PlaceOrders, CancelOrders])` — only include those two; schema changes to other
250    ///   actions (e.g. `Withdraw`) are ignored.
251    ///
252    /// Unknown enum names default to `true` so any new enums in the schema
253    /// are kept, ensuring the diff still catches unexpected additions.
254    fn filter_variants(
255        name: &str,
256        variant: &str,
257        user_actions: Option<&[UserActionDiscriminants]>,
258    ) -> bool {
259        match name {
260            "Transaction" => variant == "V0",
261            "RuntimeCall" => variant == "Exchange",
262            "CallMessage" => variant == "User",
263            "UserAction" => match user_actions {
264                Some(actions) => UserActionDiscriminants::try_from(variant)
265                    .map(|v| actions.contains(&v))
266                    .unwrap_or(false),
267                None => UserActionDiscriminants::try_from(variant).is_ok(),
268            },
269            "UniquenessData" => variant == "Generation",
270            _ => {
271                // include the variant - to be sure we fail afterwards
272                true
273            }
274        }
275    }
276
277    /// Connect to the mainnet environment.
278    ///
279    /// # Example
280    ///
281    /// ```no_run
282    /// use bullet_rust_sdk::Client;
283    ///
284    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
285    /// let api = Client::mainnet().await?;
286    /// let info = api.exchange_info().await?;
287    /// # Ok(())
288    /// # }
289    /// ```
290    pub async fn mainnet() -> SDKResult<Self> {
291        Self::builder().network(Network::Mainnet).build().await
292    }
293
294    /// Get a reference to the underlying generated client.
295    ///
296    /// Prefer using `Deref` (calling methods directly on `Client`)
297    /// instead of this method.
298    pub fn client(&self) -> &GeneratedClient {
299        &self.generated_client
300    }
301
302    /// Get the chain ID for this network.
303    pub fn chain_id(&self) -> u64 {
304        self.chain_id
305    }
306
307    /// Get the current chain hash.
308    pub fn chain_hash(&self) -> [u8; 32] {
309        // The expect is fine here as we just read and write the
310        // object. We never hold a lock in code that can panic.
311        *self.chain_hash.lock().expect("Taking the chain-hash lock can never fail.")
312    }
313
314    pub fn user_actions(&self) -> &Option<Vec<UserActionDiscriminants>> {
315        &self.user_actions
316    }
317
318    /// The REST API URL.
319    pub fn url(&self) -> &str {
320        &self.rest_url
321    }
322    /// The websocket URL.
323    pub fn ws_url(&self) -> &str {
324        &self.ws_url
325    }
326
327    /// Get the default keypair for signing transactions.
328    pub fn keypair(&self) -> Option<&Keypair> {
329        self.keypair.as_ref()
330    }
331
332    /// Get the default max fee for transactions.
333    pub fn max_fee(&self) -> Amount {
334        self.max_fee
335    }
336
337    /// Get the default max priority fee in basis points.
338    pub fn max_priority_fee_bips(&self) -> PriorityFeeBips {
339        self.max_priority_fee_bips
340    }
341
342    /// Get the default gas limit for transactions.
343    pub fn gas_limit(&self) -> Option<Gas> {
344        self.gas_limit.clone()
345    }
346
347    // ── Symbol / Market Lookups ─────────────────────────────────────────
348
349    /// Resolve a symbol string to its [`MarketId`].
350    ///
351    /// Returns `None` if the symbol is not found in the cached metadata.
352    ///
353    /// # Example
354    ///
355    /// ```ignore
356    /// let market_id = client.market_id("BTC-USD").expect("unknown symbol");
357    /// client.place_orders(market_id, orders, false, None).await?;
358    /// ```
359    pub fn market_id(&self, symbol: &str) -> Option<MarketId> {
360        self.metadata.market_id(symbol)
361    }
362
363    /// Get all available symbols and their metadata.
364    pub fn symbols(&self) -> &[SymbolInfo] {
365        self.metadata.symbols()
366    }
367
368    /// Look up symbol info by [`MarketId`].
369    pub fn symbol_info(&self, market_id: MarketId) -> Option<&SymbolInfo> {
370        self.metadata.symbol_info_by_id(market_id)
371    }
372
373    /// Look up symbol info by name.
374    pub fn symbol_info_by_name(&self, symbol: &str) -> Option<&SymbolInfo> {
375        self.metadata.symbol_info_by_name(symbol)
376    }
377
378    /// Re-fetch exchange metadata from the server.
379    ///
380    /// Call this in long-running bots to pick up newly listed markets.
381    pub async fn refresh_metadata(&mut self) -> SDKResult<()> {
382        let info = self.generated_client.exchange_info().await?;
383        self.metadata = ExchangeMetadata::from_symbols(&info.into_inner().symbols);
384        Ok(())
385    }
386}
387
388/// Implement Deref to allow calling generated client methods directly.
389///
390/// This enables ergonomic access to all API methods:
391///
392/// ```ignore
393/// let api = Client::new(url, None).await?;
394/// let info = api.exchange_info().await?;
395/// ```
396impl Deref for Client {
397    type Target = GeneratedClient;
398
399    fn deref(&self) -> &Self::Target {
400        self.client()
401    }
402}