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
15pub 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 metadata: ExchangeMetadata,
52
53 max_priority_fee_bips: PriorityFeeBips,
55 max_fee: Amount,
57 gas_limit: Option<Gas>,
59}
60
61#[derive(Debug, Clone)]
78pub enum Network {
79 Mainnet,
80 Testnet,
81 Custom(String),
82}
83
84impl Network {
85 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 #[builder]
124 pub async fn new(
125 #[builder(into)] network: Network,
126 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 #[builder(into)]
141 solana_offchain_url: Option<String>,
142 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 #[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 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 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 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 *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 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 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 pub async fn mainnet() -> SDKResult<Self> {
343 Self::builder().network(Network::Mainnet).build().await
344 }
345
346 pub fn client(&self) -> &GeneratedClient {
351 &self.generated_client
352 }
353
354 pub fn chain_id(&self) -> u64 {
356 self.chain_id
357 }
358
359 pub fn chain_hash(&self) -> [u8; 32] {
361 *self.chain_hash.lock().expect("Taking the chain-hash lock can never fail.")
364 }
365
366 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 pub fn url(&self) -> &str {
377 &self.rest_url
378 }
379 pub fn ws_url(&self) -> &str {
381 &self.ws_url
382 }
383
384 pub fn solana_offchain_url(&self) -> &str {
386 &self.solana_offchain_url
387 }
388
389 pub fn keypair(&self) -> Option<&Keypair> {
391 self.keypair.as_ref()
392 }
393
394 pub fn max_fee(&self) -> Amount {
396 self.max_fee
397 }
398
399 pub fn max_priority_fee_bips(&self) -> PriorityFeeBips {
401 self.max_priority_fee_bips
402 }
403
404 pub fn gas_limit(&self) -> Option<Gas> {
406 self.gas_limit.clone()
407 }
408
409 pub fn market_id(&self, symbol: &str) -> Option<MarketId> {
422 self.metadata.market_id(symbol)
423 }
424
425 pub fn symbols(&self) -> &[SymbolInfo] {
427 self.metadata.symbols()
428 }
429
430 pub fn symbol_info(&self, market_id: MarketId) -> Option<&SymbolInfo> {
432 self.metadata.symbol_info_by_id(market_id)
433 }
434
435 pub fn symbol_info_by_name(&self, symbol: &str) -> Option<&SymbolInfo> {
437 self.metadata.symbol_info_by_name(symbol)
438 }
439
440 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
450impl 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}