Skip to main content

o2_tools/
order_book.rs

1use fuels::{
2    prelude::*,
3    types::{
4        AssetId,
5        Identity,
6    },
7};
8
9use crate::order_book_deploy::{
10    OrderBookDeploy,
11    OrderBookDeployConfig,
12    OrderBookProxy,
13};
14
15// Re-export types from the contract ABI
16use crate::{
17    order_book_deploy,
18    order_book_deploy::{
19        OrderArgs,
20        OrderBook,
21        Side as ContractSide,
22        Time,
23    },
24    trade_account_deploy::CallParams,
25};
26
27// Re-export domain types from api-types
28pub use o2_api_types::primitives::{
29    OrderType,
30    Side,
31};
32
33pub const DEFAULT_METHOD_GAS: u64 = 1_000_000;
34
35/// Convert a contract OrderType into the shared OrderType.
36pub fn order_type_from_contract(order_type: order_book_deploy::OrderType) -> OrderType {
37    match order_type {
38        order_book_deploy::OrderType::Spot => OrderType::Spot,
39        order_book_deploy::OrderType::Limit((price, timestamp)) => {
40            OrderType::Limit(price, timestamp.unix as u128)
41        }
42        order_book_deploy::OrderType::FillOrKill => OrderType::FillOrKill,
43        order_book_deploy::OrderType::PostOnly => OrderType::PostOnly,
44        order_book_deploy::OrderType::Market => OrderType::Market,
45        order_book_deploy::OrderType::BoundedMarket((max_price, min_price)) => {
46            OrderType::BoundedMarket {
47                max_price,
48                min_price,
49            }
50        }
51    }
52}
53
54/// Convert a shared OrderType into the contract OrderType.
55pub fn order_type_to_contract(order_type: OrderType) -> order_book_deploy::OrderType {
56    match order_type {
57        OrderType::Spot => order_book_deploy::OrderType::Spot,
58        OrderType::Limit(price, timestamp) => order_book_deploy::OrderType::Limit((
59            price,
60            Time {
61                unix: timestamp as u64,
62            },
63        )),
64        OrderType::FillOrKill => order_book_deploy::OrderType::FillOrKill,
65        OrderType::PostOnly => order_book_deploy::OrderType::PostOnly,
66        OrderType::Market => order_book_deploy::OrderType::Market,
67        OrderType::BoundedMarket {
68            max_price,
69            min_price,
70        } => order_book_deploy::OrderType::BoundedMarket((max_price, min_price)),
71    }
72}
73
74/// Convert a contract Side into the shared Side.
75pub fn side_from_contract(order_side: ContractSide) -> Side {
76    match order_side {
77        ContractSide::Buy => Side::Buy,
78        ContractSide::Sell => Side::Sell,
79    }
80}
81
82/// Convert a shared Side into the contract Side.
83pub fn side_to_contract(side: Side) -> ContractSide {
84    match side {
85        Side::Buy => ContractSide::Buy,
86        Side::Sell => ContractSide::Sell,
87    }
88}
89
90/// Parameters for creating a new order in the order book.
91/// Contains all information needed to place a buy or sell order.
92#[derive(Debug, Clone)]
93pub struct CreateOrderParams {
94    /// The price per unit (in quote asset)
95    pub price: u64,
96    /// The quantity to buy/sell (in base asset)
97    pub quantity: u64,
98    /// The type of order (Limit or Spot)
99    pub order_type: OrderType,
100    /// The side of the order (Buy or Sell)
101    pub side: Side,
102    /// The asset ID of the asset being traded
103    pub asset_id: AssetId,
104}
105
106impl CreateOrderParams {
107    pub fn random(
108        side: Side,
109        asset_id: AssetId,
110        min_price: u64,
111        max_price: u64,
112        min_quantity: u64,
113        max_quantity: u64,
114    ) -> Self {
115        let price = (rand::random::<u64>() + min_price) % max_price;
116        let quantity = (rand::random::<u64>() + min_quantity) % max_quantity;
117        let order_type = OrderType::Spot;
118        Self {
119            price,
120            quantity,
121            order_type,
122            side,
123            asset_id,
124        }
125    }
126
127    /// Converts to the contract's OrderArgs format.
128    pub fn to_order_args(&self) -> OrderArgs {
129        OrderArgs {
130            price: self.price,
131            quantity: self.quantity,
132            order_type: order_type_to_contract(self.order_type),
133        }
134    }
135}
136
137#[derive(Clone, Copy)]
138pub struct OrderbookConfig {
139    /// Base asset ID for the trading pair
140    pub base_asset: AssetId,
141    pub base_decimals: u64,
142    /// Quote asset ID for the trading pair
143    pub quote_asset: AssetId,
144    pub quote_decimals: u64,
145}
146
147impl OrderbookConfig {
148    pub fn get_order_side_asset(&self, order_side: &Side) -> AssetId {
149        if order_side == &Side::Buy {
150            self.quote_asset
151        } else {
152            self.base_asset
153        }
154    }
155
156    pub fn get_order_side_amount(&self, quantity: u64, price: u64, side: &Side) -> u64 {
157        if side == &Side::Buy {
158            ((quantity as u128 * price as u128) / self.base_decimals as u128)
159                .try_into()
160                .unwrap()
161        } else {
162            quantity
163        }
164    }
165
166    pub fn create_call_params(
167        &self,
168        params: &CreateOrderParams,
169        gas: Option<u64>,
170    ) -> CallParams {
171        let amount =
172            self.get_order_side_amount(params.quantity, params.price, &params.side);
173        let asset_id = self.get_order_side_asset(&params.side);
174        CallParams::new(amount, asset_id, gas.unwrap_or(u64::MAX))
175    }
176}
177
178#[derive(Clone)]
179pub struct OrderBookManager<W: Account + Clone> {
180    /// The OrderBook contract instance
181    pub proxy: OrderBookProxy<W>,
182    /// The OrderBook contract instance
183    pub contract: OrderBook<W>,
184    /// The wallet used for creating transactions
185    pub gas_payer_wallet: W,
186    /// Confguration
187    pub config: OrderbookConfig,
188}
189
190impl<W: Account + Clone> OrderBookManager<W> {
191    /// Creates a new OrderBookManager instance.
192    ///
193    /// # Arguments
194    /// * `contract_id` - The deployed OrderBook contract ID
195    /// * `wallet` - The wallet to use for transactions
196    /// * `base_asset` - The base asset ID for the trading pair
197    /// * `quote_asset` - The quote asset ID for the trading pair
198    pub fn new(
199        gas_payer_wallet: &W,
200        base_decimals: u64,
201        quote_decimals: u64,
202        order_book_deploy: &OrderBookDeploy<W>,
203    ) -> Self {
204        let config = OrderbookConfig {
205            base_asset: order_book_deploy.base_asset,
206            base_decimals,
207            quote_asset: order_book_deploy.quote_asset,
208            quote_decimals,
209        };
210        Self {
211            proxy: order_book_deploy
212                .order_book_proxy
213                .clone()
214                .with_account(gas_payer_wallet.clone()),
215            contract: order_book_deploy
216                .order_book
217                .clone()
218                .with_account(gas_payer_wallet.clone()),
219            config,
220            gas_payer_wallet: gas_payer_wallet.clone(),
221        }
222    }
223
224    pub fn create_call_params(
225        &self,
226        params: &CreateOrderParams,
227        gas: Option<u64>,
228    ) -> CallParams {
229        self.config().create_call_params(params, gas)
230    }
231
232    pub fn get_order_side_asset(&self, order_side: &Side) -> AssetId {
233        self.config().get_order_side_asset(order_side)
234    }
235
236    pub fn get_order_side_amount(&self, quantity: u64, price: u64, side: &Side) -> u64 {
237        self.config().get_order_side_amount(quantity, price, side)
238    }
239
240    pub async fn balances_of(&self, identity: &Identity) -> anyhow::Result<(u128, u128)> {
241        let result = self
242            .contract
243            .methods()
244            .get_settled_balance_of(*identity)
245            .simulate(Execution::state_read_only())
246            .await?;
247        Ok((result.value.0 as u128, result.value.1 as u128))
248    }
249
250    pub async fn emit_config(&self) -> anyhow::Result<()> {
251        self.contract
252            .methods()
253            .emit_orderbook_config()
254            .call()
255            .await?;
256        Ok(())
257    }
258
259    pub async fn upgrade(
260        &self,
261        deploy_config: &OrderBookDeployConfig,
262    ) -> anyhow::Result<()> {
263        let base_asset_id = self.contract.methods().get_base_asset().call().await?.value;
264        let quote_asset_id = self
265            .contract
266            .methods()
267            .get_quote_asset()
268            .simulate(Execution::state_read_only())
269            .await?
270            .value;
271        let order_book_blob_id = OrderBookDeploy::deploy_order_book_blob(
272            &self.gas_payer_wallet,
273            base_asset_id,
274            quote_asset_id,
275            deploy_config,
276        )
277        .await?;
278        self.proxy
279            .methods()
280            .set_proxy_target(ContractId::new(order_book_blob_id))
281            .call()
282            .await?;
283        Ok(())
284    }
285
286    pub async fn accumulated_fees(&self) -> anyhow::Result<(u64, u64)> {
287        let fees = self
288            .contract
289            .methods()
290            .current_fees()
291            .simulate(Execution::state_read_only())
292            .await?
293            .value;
294        Ok(fees)
295    }
296
297    pub async fn get_whitelist_id(&self) -> anyhow::Result<Option<ContractId>> {
298        let result = self
299            .contract
300            .methods()
301            .get_whitelist_id()
302            .simulate(Execution::state_read_only())
303            .await?;
304        Ok(result.value)
305    }
306
307    pub async fn get_blacklist_id(&self) -> anyhow::Result<Option<ContractId>> {
308        let result = self
309            .contract
310            .methods()
311            .get_blacklist_id()
312            .simulate(Execution::state_read_only())
313            .await?;
314        Ok(result.value)
315    }
316
317    pub fn base_asset(&self) -> AssetId {
318        self.config().base_asset
319    }
320
321    pub fn base_decimals(&self) -> u64 {
322        self.config().base_decimals
323    }
324
325    pub fn quote_asset(&self) -> AssetId {
326        self.config().quote_asset
327    }
328
329    pub fn quote_decimals(&self) -> u64 {
330        self.config().quote_decimals
331    }
332
333    pub fn config(&self) -> OrderbookConfig {
334        self.config
335    }
336
337    pub async fn is_paused(&self) -> anyhow::Result<bool> {
338        let result = self
339            .contract
340            .methods()
341            .is_paused()
342            .simulate(Execution::state_read_only())
343            .await?;
344        Ok(result.value)
345    }
346}
347
348#[cfg(test)]
349mod tests {
350    use crate::{
351        helpers::get_asset_balance,
352        order_book_deploy::{
353            OrderBookDeployConfig,
354            OrderCreatedEvent,
355            OrderMatchedEvent,
356        },
357    };
358
359    use super::*;
360    use fuels::test_helpers::{
361        WalletsConfig,
362        launch_custom_provider_and_get_wallets,
363    };
364
365    #[tokio::test]
366    async fn test_order_book_manager() {
367        let base_asset = AssetId::from([1; 32]);
368        let quote_asset = AssetId::from([2; 32]);
369        let initial_balance = 1_000_000_000_000_000u64;
370
371        // Start fuel-core
372        let mut wallets = launch_custom_provider_and_get_wallets(
373            WalletsConfig::new_multiple_assets(
374                3,
375                vec![
376                    AssetConfig {
377                        id: AssetId::default(),
378                        num_coins: 1,
379                        coin_amount: initial_balance,
380                    },
381                    AssetConfig {
382                        id: quote_asset,
383                        num_coins: 1,
384                        coin_amount: initial_balance,
385                    },
386                    AssetConfig {
387                        id: base_asset,
388                        num_coins: 1,
389                        coin_amount: initial_balance,
390                    },
391                ],
392            ),
393            None,
394            None,
395        )
396        .await
397        .unwrap();
398        let deployer_wallet = wallets.pop().unwrap();
399        let maker_wallet = wallets.pop().unwrap();
400        let taker_wallet = wallets.pop().unwrap();
401
402        // Deploy OrderBook
403        let mut config = OrderBookDeployConfig::default();
404
405        config.order_book_configurables = config
406            .order_book_configurables
407            .with_MAKER_FEE(0.into())
408            .unwrap()
409            .with_TAKER_FEE(0.into())
410            .unwrap();
411
412        let deployment =
413            OrderBookDeploy::deploy(&deployer_wallet, base_asset, quote_asset, &config)
414                .await
415                .unwrap();
416
417        // Check if contract exists
418        let provider = deployer_wallet.try_provider().unwrap();
419        let contract_exists = provider
420            .contract_exists(&deployment.contract_id)
421            .await
422            .unwrap();
423        assert!(contract_exists, "OrderBook contract should exist");
424
425        // Verify configuration
426        assert_eq!(deployment.base_asset, base_asset);
427        assert_eq!(deployment.quote_asset, quote_asset);
428
429        let order_book_manager = OrderBookManager::new(
430            &deployer_wallet,
431            10u64.pow(9),
432            10u64.pow(9),
433            &deployment,
434        );
435        let maker_order_params = CreateOrderParams {
436            price: 1_000_000_000,
437            quantity: 2_000_000_000,
438            order_type: OrderType::Spot,
439            side: Side::Buy,
440            asset_id: quote_asset,
441        };
442        let maker_call_params =
443            order_book_manager.create_call_params(&maker_order_params, None);
444        let maker_order_book_instance = order_book_manager
445            .contract
446            .clone()
447            .with_account(maker_wallet.clone());
448        let result = maker_order_book_instance
449            .methods()
450            .create_order(maker_order_params.to_order_args())
451            .with_tx_policies(TxPolicies::default())
452            .call_params(CallParameters::new(
453                maker_call_params.coins,
454                maker_call_params.asset_id,
455                maker_call_params.gas,
456            ))
457            .unwrap()
458            .call()
459            .await
460            .unwrap();
461        let maker_order_created_events =
462            result.decode_logs_with_type::<OrderCreatedEvent>().unwrap();
463        let maker_order_created_event = maker_order_created_events.first().unwrap();
464        let taker_order_params = CreateOrderParams {
465            price: 1_000_000_000,
466            quantity: 1_000_000_000,
467            order_type: OrderType::Spot,
468            side: Side::Sell,
469            asset_id: base_asset,
470        };
471        let taker_call_params =
472            order_book_manager.create_call_params(&taker_order_params, None);
473        let result = order_book_manager
474            .contract
475            .clone()
476            .with_account(taker_wallet.clone())
477            .methods()
478            .create_order(taker_order_params.to_order_args())
479            .with_tx_policies(TxPolicies::default())
480            .call_params(CallParameters::new(
481                taker_call_params.coins,
482                taker_call_params.asset_id,
483                taker_call_params.gas,
484            ))
485            .unwrap()
486            .with_variable_output_policy(VariableOutputPolicy::Exactly(10))
487            .call()
488            .await
489            .unwrap();
490
491        let maker_balances = maker_wallet.get_balances().await.unwrap();
492        let taker_balances = taker_wallet.get_balances().await.unwrap();
493        let (maker_balance_base, maker_balance_quote) = order_book_manager
494            .balances_of(&Identity::Address(maker_wallet.address()))
495            .await
496            .unwrap();
497        let (taker_balance_base, taker_balance_quote) = order_book_manager
498            .balances_of(&Identity::Address(taker_wallet.address()))
499            .await
500            .unwrap();
501
502        assert_eq!(
503            get_asset_balance(&maker_balances, &base_asset) + maker_balance_base,
504            initial_balance as u128 + taker_order_params.quantity as u128
505        );
506        assert_eq!(
507            get_asset_balance(&maker_balances, &quote_asset) + maker_balance_quote,
508            initial_balance as u128 - maker_call_params.coins as u128
509        );
510        assert_eq!(
511            get_asset_balance(&taker_balances, &base_asset) + taker_balance_base,
512            initial_balance as u128 - taker_order_params.quantity as u128
513        );
514        assert_eq!(
515            get_asset_balance(&taker_balances, &quote_asset) + taker_balance_quote,
516            initial_balance as u128 + 1_000_000_000u128
517        );
518
519        // Log receipts
520        let matches = result.decode_logs_with_type::<OrderMatchedEvent>().unwrap();
521        // Only one match
522        assert_eq!(matches.len(), 1);
523        let match_event = matches.first().unwrap();
524        assert_eq!(match_event.price, maker_order_params.price);
525        assert_eq!(match_event.quantity, taker_order_params.quantity);
526
527        let _ = maker_order_book_instance
528            .methods()
529            .settle_balances(vec![
530                Identity::Address(maker_wallet.address()),
531                Identity::Address(taker_wallet.address()),
532            ])
533            .with_variable_output_policy(VariableOutputPolicy::Exactly(5))
534            .call()
535            .await
536            .unwrap();
537        let maker_quote_balance_before_cancel =
538            get_asset_balance(&maker_wallet.get_balances().await.unwrap(), &quote_asset);
539        let cancel_result = maker_order_book_instance
540            .methods()
541            .cancel_order(maker_order_created_event.order_id)
542            .with_tx_policies(TxPolicies::default())
543            .with_variable_output_policy(VariableOutputPolicy::Exactly(10))
544            .call()
545            .await
546            .unwrap();
547        let maker_quote_balance_after =
548            get_asset_balance(&maker_wallet.get_balances().await.unwrap(), &quote_asset);
549
550        assert!(cancel_result.value);
551        assert_eq!(
552            maker_quote_balance_after,
553            maker_quote_balance_before_cancel + 1_000_000_000u128
554        );
555    }
556}