Skip to main content

o2_deploy/
lib.rs

1//! Contract deployment logic for the Fuel O2 exchange.
2//!
3//! This crate extracts the core deploy workflow from the `api` package,
4//! making it reusable from both the API binary and the standalone `o2-deploy` CLI.
5
6use anyhow::Context;
7use fuel_core_client::client::types::primitives::{
8    ContractId,
9    Salt,
10};
11use fuel_core_types::fuel_types::BlockHeight;
12use fuels::{
13    accounts::{
14        Account,
15        ViewOnlyAccount,
16    },
17    prelude::Execution,
18    types::{
19        Identity,
20        SizedAsciiString,
21    },
22};
23use o2_api_types::{
24    domain::book::{
25        AssetConfig,
26        MarketIdAssets,
27        OrderBookConfig,
28    },
29    parse::HexDisplayFromStr,
30};
31use o2_tools::{
32    order_book::OrderBookManager,
33    order_book_deploy::{
34        OrderBookBlacklist,
35        OrderBookConfigurables,
36        OrderBookDeploy,
37        OrderBookDeployConfig,
38        OrderBookWhitelist,
39    },
40    order_book_registry::{
41        OrderBookRegistryDeployConfig,
42        OrderBookRegistryManager,
43    },
44    trade_account_deploy::{
45        DeployConfig,
46        TradeAccountDeploy,
47        TradeAccountDeployConfig,
48        TradingAccountOracle,
49    },
50    trade_account_registry::{
51        TradeAccountRegistryDeployConfig,
52        TradeAccountRegistryManager,
53    },
54    trial_trade_account_deploy::{
55        DeployConfig as TrialDeployConfig,
56        TrialTradeAccountDeploy,
57        TrialTradeAccountDeployConfig,
58        TrialTradingAccountOracle,
59    },
60};
61use serde_with::serde_as;
62use std::ops::{
63    Deref,
64    DerefMut,
65};
66
67/// Access-control role that authorizes order-book maintenance actions
68/// (`force_cancel_orders`, `cancel_blacklist_orders`, `eject_trigger_order`).
69///
70/// Must match `ORDERBOOK_MAINTAINER_ROLE` in `contracts/order-book/src/main.sw`.
71const ORDERBOOK_MAINTAINER_ROLE: u64 = 1;
72
73fn to_registry_market_id(m: &MarketIdAssets) -> o2_tools::order_book_registry::MarketId {
74    o2_tools::order_book_registry::MarketId {
75        base_asset: m.base_asset,
76        quote_asset: m.quote_asset,
77    }
78}
79
80// ---------------------------------------------------------------------------
81// Types
82// ---------------------------------------------------------------------------
83
84#[serde_as]
85#[derive(Debug, serde::Serialize, Clone, Default)]
86pub struct MarketsConfigOutput {
87    pub starting_height: u32,
88    #[serde_as(as = "HexDisplayFromStr")]
89    pub trade_account_registry_id: ContractId,
90    #[serde_as(as = "HexDisplayFromStr")]
91    pub trade_account_registry_blob_id: ContractId,
92    #[serde_as(as = "HexDisplayFromStr")]
93    pub trade_account_oracle_id: ContractId,
94    #[serde_as(as = "HexDisplayFromStr")]
95    pub trial_trade_account_oracle_id: ContractId,
96    #[serde_as(as = "HexDisplayFromStr")]
97    pub trade_account_root: ContractId,
98    #[serde_as(as = "HexDisplayFromStr")]
99    pub trade_account_proxy: ContractId,
100    #[serde_as(as = "HexDisplayFromStr")]
101    pub trade_account_blob_id: ContractId,
102    #[serde_as(as = "Option<HexDisplayFromStr>")]
103    pub order_book_whitelist_id: Option<ContractId>,
104    #[serde_as(as = "Option<HexDisplayFromStr>")]
105    pub order_book_blacklist_id: Option<ContractId>,
106    #[serde_as(as = "HexDisplayFromStr")]
107    pub order_book_registry_id: ContractId,
108    #[serde_as(as = "HexDisplayFromStr")]
109    pub order_book_registry_blob_id: ContractId,
110    #[serde_as(as = "Option<HexDisplayFromStr>")]
111    pub fast_bridge_asset_registry_proxy_id: Option<ContractId>,
112    pub pairs: Vec<OrderBookConfig>,
113}
114
115/// Intermediate type for deserializing order book configs with string-encoded numbers.
116#[serde_as]
117#[derive(Debug, Clone, serde::Deserialize)]
118struct OrderBookConfigDeHelper {
119    #[serde_as(as = "Option<serde_with::DisplayFromStr>")]
120    blob_id: Option<ContractId>,
121    #[serde_as(as = "Option<serde_with::DisplayFromStr>")]
122    contract_id: Option<ContractId>,
123    #[serde_as(as = "serde_with::DisplayFromStr")]
124    taker_fee: u64,
125    #[serde_as(as = "serde_with::DisplayFromStr")]
126    maker_fee: u64,
127    #[serde_as(as = "serde_with::DisplayFromStr")]
128    min_order: u64,
129    #[serde_as(as = "serde_with::DisplayFromStr")]
130    dust: u64,
131    price_window: u8,
132    #[serde(default)]
133    allow_fractional_price: bool,
134    base: AssetConfig,
135    quote: AssetConfig,
136}
137
138impl From<OrderBookConfigDeHelper> for OrderBookConfig {
139    fn from(h: OrderBookConfigDeHelper) -> Self {
140        let ids = MarketIdAssets {
141            base_asset: h.base.asset,
142            quote_asset: h.quote.asset,
143        };
144        let market_id = ids.market_id();
145        OrderBookConfig {
146            contract_id: h.contract_id,
147            blob_id: h.blob_id,
148            market_id,
149            taker_fee: h.taker_fee,
150            maker_fee: h.maker_fee,
151            min_order: h.min_order,
152            dust: h.dust,
153            price_window: h.price_window,
154            allow_fractional_price: h.allow_fractional_price,
155            base: h.base,
156            quote: h.quote,
157        }
158    }
159}
160
161#[derive(Debug, Clone, Default, serde::Serialize)]
162pub struct MarketsConfigPartial {
163    pub starting_height: u32,
164    pub trade_account_registry_id: Option<ContractId>,
165    pub order_book_registry_id: Option<ContractId>,
166    pub trade_account_oracle_id: Option<ContractId>,
167    pub trial_trade_account_oracle_id: Option<ContractId>,
168    pub order_book_whitelist_id: Option<ContractId>,
169    pub order_book_blacklist_id: Option<ContractId>,
170    pub fast_bridge_asset_registry_proxy_id: Option<ContractId>,
171    pub pairs: Vec<OrderBookConfig>,
172}
173
174impl<'de> serde::Deserialize<'de> for MarketsConfigPartial {
175    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
176    where
177        D: serde::Deserializer<'de>,
178    {
179        #[derive(serde::Deserialize, Default)]
180        struct Helper {
181            #[serde(default)]
182            starting_height: u32,
183            trade_account_registry_id: Option<ContractId>,
184            order_book_registry_id: Option<ContractId>,
185            trade_account_oracle_id: Option<ContractId>,
186            trial_trade_account_oracle_id: Option<ContractId>,
187            order_book_whitelist_id: Option<ContractId>,
188            order_book_blacklist_id: Option<ContractId>,
189            fast_bridge_asset_registry_proxy_id: Option<ContractId>,
190            #[serde(default)]
191            pairs: Vec<OrderBookConfigDeHelper>,
192        }
193        let h = Helper::deserialize(deserializer)?;
194        Ok(MarketsConfigPartial {
195            starting_height: h.starting_height,
196            trade_account_registry_id: h.trade_account_registry_id,
197            order_book_registry_id: h.order_book_registry_id,
198            trade_account_oracle_id: h.trade_account_oracle_id,
199            trial_trade_account_oracle_id: h.trial_trade_account_oracle_id,
200            order_book_whitelist_id: h.order_book_whitelist_id,
201            order_book_blacklist_id: h.order_book_blacklist_id,
202            fast_bridge_asset_registry_proxy_id: h.fast_bridge_asset_registry_proxy_id,
203            pairs: h.pairs.into_iter().map(Into::into).collect(),
204        })
205    }
206}
207
208#[derive(Debug, Clone, Default)]
209pub struct OwnershipTransferOptions {
210    pub new_proxy_owner: Option<fuels::types::Address>,
211    pub new_contract_owner: Option<fuels::types::Address>,
212    /// Accounts to revoke `ORDERBOOK_MAINTAINER_ROLE` from on each order book
213    /// (via `owner_revoke_role`) before granting the new maintainers, so the role
214    /// is rotated rather than accumulating holders across upgrades.
215    pub revoke_orderbook_maintainers: Vec<fuels::types::Address>,
216    /// Accounts to grant `ORDERBOOK_MAINTAINER_ROLE` to on each order book (via
217    /// `owner_grant_role`) before ownership is transferred away.
218    pub new_orderbook_maintainers: Vec<fuels::types::Address>,
219}
220
221/// Parameters for a deploy invocation.
222#[derive(Debug, Clone)]
223pub struct DeployParams {
224    pub deploy_config: MarketsConfigPartial,
225    pub output: Option<String>,
226    pub deploy_whitelist: bool,
227    pub deploy_blacklist: bool,
228    pub upgrade_bytecode: bool,
229    pub new_proxy_owner: Option<fuels::types::Address>,
230    pub new_contract_owner: Option<fuels::types::Address>,
231    /// Cosigner address for trial trade accounts. When set, the trial trade
232    /// account implementation is (re)deployed on the trial oracle and this
233    /// cosigner is configured on it; when absent, the configured cosigner is
234    /// left untouched (the implementation still follows the regular
235    /// seed/upgrade lifecycle).
236    pub trial_cosigner: Option<fuels::types::Address>,
237    /// Identity allowed to register (activate) trial trade accounts on the
238    /// registry. When set, it is written via
239    /// `set_trial_trade_account_creator` if it differs from the current one
240    /// — registries upgraded in place start with the creator unset, which
241    /// denies all trial registrations. When absent, the creator is left
242    /// untouched.
243    pub trial_creator: Option<Identity>,
244    pub revoke_orderbook_maintainers: Vec<fuels::types::Address>,
245    pub new_orderbook_maintainers: Vec<fuels::types::Address>,
246}
247
248// ---------------------------------------------------------------------------
249// Helpers
250// ---------------------------------------------------------------------------
251
252/// Load a JSON config file, returning `T::default()` when the path is empty.
253pub fn load_config_from_file<T>(config_path: &str) -> anyhow::Result<T>
254where
255    T: Default + serde::de::DeserializeOwned,
256{
257    if config_path.is_empty() {
258        return Ok(T::default());
259    }
260    let current_dir = std::env::current_dir()?;
261    let path = current_dir.join(config_path);
262    tracing::info!("Loading config from {}", path.display());
263    let file = std::fs::File::open(&path)?;
264    let config: T = serde_json::from_reader(file)?;
265    Ok(config)
266}
267
268// ---------------------------------------------------------------------------
269// Core deploy logic
270// ---------------------------------------------------------------------------
271
272/// Deploy (or upgrade) the full set of O2 contracts.
273///
274/// The wallet type `W` must implement `Account + Clone + Signer` (e.g.
275/// `fuels::prelude::WalletUnlocked` or the KMS-backed `O2Wallet` from the API).
276pub async fn deploy<W>(
277    wallet: W,
278    params: DeployParams,
279) -> anyhow::Result<MarketsConfigOutput>
280where
281    W: Account + ViewOnlyAccount + Clone + 'static,
282{
283    tracing::info!("Starting Fuel o2 Registries and Markets");
284    let mut markets_config_partial = params.deploy_config.clone();
285    let starting_height: BlockHeight = markets_config_partial.starting_height.into();
286    let trade_account_oracle_id = markets_config_partial.trade_account_oracle_id;
287    let trial_trade_account_oracle_id =
288        markets_config_partial.trial_trade_account_oracle_id;
289    let order_book_registry_id = markets_config_partial.order_book_registry_id;
290    let trade_account_registry_id = markets_config_partial.trade_account_registry_id;
291    let fast_bridge_asset_registry_proxy_id =
292        markets_config_partial.fast_bridge_asset_registry_proxy_id;
293
294    let mut salt = Salt::zeroed();
295    salt.deref_mut()[..4].copy_from_slice(&starting_height.deref().to_be_bytes());
296
297    let (trade_account_oracle_deploy, trade_account_blob_id) =
298        deploy_trade_account_oracle(
299            wallet.clone(),
300            params.upgrade_bytecode,
301            trade_account_oracle_id,
302            salt,
303        )
304        .await?;
305    // The trial oracle is a dedicated contract (already-deployed trade
306    // account oracles are immutable and cannot gain the trial functions), so
307    // it deploys separately and the registry references both oracle ids.
308    let trial_trade_account_oracle_id = deploy_trial_trade_account_oracle(
309        wallet.clone(),
310        params.upgrade_bytecode,
311        trial_trade_account_oracle_id,
312        params.trial_cosigner,
313        salt,
314    )
315    .await?;
316    let (trade_account_registry, trade_account_registry_blob_id) =
317        deploy_trade_account_registry(
318            wallet.clone(),
319            params.upgrade_bytecode,
320            trade_account_oracle_deploy.clone(),
321            trial_trade_account_oracle_id,
322            trade_account_registry_id,
323            salt,
324        )
325        .await?;
326    let order_book_blacklist_id = deploy_order_book_blacklist(
327        wallet.clone(),
328        params.deploy_blacklist,
329        markets_config_partial.order_book_blacklist_id,
330        salt,
331    )
332    .await?;
333    let order_book_whitelist_id = deploy_order_book_whitelist(
334        wallet.clone(),
335        params.deploy_whitelist,
336        markets_config_partial.order_book_whitelist_id,
337        salt,
338    )
339    .await?;
340    let (order_book_registry, order_book_registry_blob_id) = deploy_order_book_registry(
341        wallet.clone(),
342        params.upgrade_bytecode,
343        order_book_registry_id,
344        salt,
345    )
346    .await?;
347    let pairs = deploy_order_books(
348        wallet.clone(),
349        params.upgrade_bytecode,
350        order_book_blacklist_id,
351        order_book_whitelist_id,
352        order_book_registry.clone(),
353        &mut markets_config_partial.pairs,
354        OwnershipTransferOptions {
355            new_proxy_owner: params.new_proxy_owner,
356            new_contract_owner: params.new_contract_owner,
357            revoke_orderbook_maintainers: params.revoke_orderbook_maintainers.clone(),
358            new_orderbook_maintainers: params.new_orderbook_maintainers.clone(),
359        },
360    )
361    .await?;
362
363    let order_book_registry_id = order_book_registry.contract_id;
364    let trade_account_registry_id = trade_account_registry.contract_id;
365    let trade_account_oracle_id = trade_account_oracle_deploy.oracle_id;
366
367    let trade_account_proxy = trade_account_registry
368        .registry
369        .methods()
370        .default_bytecode()
371        .simulate(Execution::state_read_only())
372        .await?
373        .value
374        .context(
375            "Trade account registry default bytecode should exist after initialization",
376        )?;
377    let trade_account_root = trade_account_registry
378        .registry
379        .methods()
380        .factory_bytecode_root()
381        .simulate(Execution::state_read_only())
382        .await?
383        .value
384        .context("Trade account registry factory bytecode root should exist after initialization")?;
385
386    // Registries upgraded in place start with the trial creator unset (the
387    // configurable seed only applies on the first initialize), so the deploy
388    // writes it explicitly when provided. Must run before ownership transfer.
389    if let Some(trial_creator) = params.trial_creator {
390        let current_creator = trade_account_registry
391            .get_trial_trade_account_creator()
392            .await?;
393        if current_creator != trial_creator {
394            tracing::info!("Setting trial trade account creator to {trial_creator:?}");
395            trade_account_registry
396                .set_trial_trade_account_creator(trial_creator)
397                .await?;
398        }
399    }
400
401    transfer_ownership(
402        &wallet,
403        &params,
404        &order_book_registry,
405        &trade_account_registry,
406        &trade_account_oracle_deploy,
407        trial_trade_account_oracle_id,
408        order_book_blacklist_id,
409        order_book_whitelist_id,
410    )
411    .await?;
412
413    let deploy_result = MarketsConfigOutput {
414        starting_height: starting_height.into(),
415        trade_account_registry_id,
416        trade_account_registry_blob_id,
417        trade_account_proxy,
418        trade_account_blob_id,
419        trade_account_root: ContractId::from(trade_account_root.0),
420        trade_account_oracle_id,
421        trial_trade_account_oracle_id,
422        order_book_whitelist_id,
423        order_book_blacklist_id,
424        order_book_registry_id,
425        order_book_registry_blob_id,
426        pairs,
427        fast_bridge_asset_registry_proxy_id,
428    };
429
430    if let Some(output_path) = params.output {
431        let json = serde_json::to_string_pretty(&deploy_result)?;
432        tracing::info!("Deploy result saved to {}", output_path);
433        std::fs::write(output_path, json)?;
434    }
435
436    Ok(deploy_result)
437}
438
439// ---------------------------------------------------------------------------
440// Ownership transfer
441// ---------------------------------------------------------------------------
442
443#[allow(clippy::too_many_arguments)]
444async fn transfer_ownership<W>(
445    wallet: &W,
446    params: &DeployParams,
447    order_book_registry: &OrderBookRegistryManager<W>,
448    trade_account_registry: &TradeAccountRegistryManager<W>,
449    trade_account_oracle_deploy: &TradeAccountDeploy<W>,
450    trial_trade_account_oracle_id: ContractId,
451    order_book_blacklist_id: Option<ContractId>,
452    order_book_whitelist_id: Option<ContractId>,
453) -> anyhow::Result<()>
454where
455    W: Account + ViewOnlyAccount + Clone + 'static,
456{
457    if let Some(new_proxy_owner) = params.new_proxy_owner {
458        let new_identity = Identity::Address(new_proxy_owner);
459        tracing::info!(
460            "Transferring OrderBookRegistry proxy ownership to {}",
461            new_proxy_owner
462        );
463        order_book_registry
464            .registry_proxy
465            .methods()
466            .set_owner(new_identity)
467            .call()
468            .await?;
469        tracing::info!(
470            "Transferring TradeAccountRegistry proxy ownership to {}",
471            new_proxy_owner
472        );
473        trade_account_registry
474            .registry_proxy
475            .methods()
476            .set_owner(new_identity)
477            .call()
478            .await?;
479    }
480
481    if let Some(new_contract_owner) = params.new_contract_owner {
482        let new_identity = Identity::Address(new_contract_owner);
483        tracing::info!(
484            "Transferring TradeAccountOracle ownership to {}",
485            new_contract_owner
486        );
487        trade_account_oracle_deploy
488            .oracle
489            .methods()
490            .transfer_ownership(new_identity)
491            .call()
492            .await?;
493        tracing::info!(
494            "Transferring TrialTradeAccountOracle ownership to {}",
495            new_contract_owner
496        );
497        TrialTradingAccountOracle::new(trial_trade_account_oracle_id, wallet.clone())
498            .methods()
499            .transfer_ownership(new_identity)
500            .call()
501            .await?;
502        tracing::info!(
503            "Transferring TradeAccountRegistry ownership to {}",
504            new_contract_owner
505        );
506        trade_account_registry
507            .registry
508            .methods()
509            .transfer_ownership(new_identity)
510            .call()
511            .await?;
512        tracing::info!(
513            "Transferring OrderBookRegistry ownership to {}",
514            new_contract_owner
515        );
516        order_book_registry
517            .registry
518            .methods()
519            .transfer_ownership(new_identity)
520            .call()
521            .await?;
522        if let Some(blacklist_id) = order_book_blacklist_id {
523            tracing::info!(
524                "Transferring OrderBookBlacklist ownership to {}",
525                new_contract_owner
526            );
527            OrderBookBlacklist::new(blacklist_id, wallet.clone())
528                .methods()
529                .transfer_ownership(new_identity)
530                .call()
531                .await?;
532        }
533        if let Some(whitelist_id) = order_book_whitelist_id {
534            tracing::info!(
535                "Transferring OrderBookWhitelist ownership to {}",
536                new_contract_owner
537            );
538            OrderBookWhitelist::new(whitelist_id, wallet.clone())
539                .methods()
540                .transfer_ownership(new_identity)
541                .call()
542                .await?;
543        }
544    }
545
546    Ok(())
547}
548
549// ---------------------------------------------------------------------------
550// Internal deploy helpers
551// ---------------------------------------------------------------------------
552
553async fn deploy_order_book_blacklist<W>(
554    deployer_wallet: W,
555    deploy_blacklist: bool,
556    order_book_blacklist_id: Option<ContractId>,
557    salt: Salt,
558) -> anyhow::Result<Option<ContractId>>
559where
560    W: Account + ViewOnlyAccount + Clone + 'static,
561{
562    match order_book_blacklist_id {
563        Some(order_book_blacklist_id) => {
564            tracing::info!(
565                "Using existing OrderBookBlacklist: {}",
566                order_book_blacklist_id
567            );
568            Ok(Some(order_book_blacklist_id))
569        }
570        None => {
571            if !deploy_blacklist {
572                return Ok(None);
573            }
574            tracing::info!("Deploying OrderBookBlacklist");
575            let order_book_blacklist = OrderBookDeploy::deploy_order_book_blacklist(
576                &deployer_wallet,
577                &Identity::Address(ViewOnlyAccount::address(&deployer_wallet)),
578                &OrderBookDeployConfig {
579                    salt,
580                    ..Default::default()
581                },
582            )
583            .await?;
584            tracing::info!("OrderBookBlacklist: {}", order_book_blacklist.contract_id());
585            Ok(Some(order_book_blacklist.contract_id()))
586        }
587    }
588}
589
590async fn deploy_order_book_whitelist<W>(
591    deployer_wallet: W,
592    deploy_whitelist: bool,
593    order_book_whitelist_id: Option<ContractId>,
594    salt: Salt,
595) -> anyhow::Result<Option<ContractId>>
596where
597    W: Account + ViewOnlyAccount + Clone + 'static,
598{
599    match (order_book_whitelist_id, deploy_whitelist) {
600        (Some(order_book_whitelist_id), false)
601        | (Some(order_book_whitelist_id), true) => {
602            tracing::info!(
603                "Using existing OrderBookWhitelist: {}",
604                order_book_whitelist_id
605            );
606            Ok(Some(order_book_whitelist_id))
607        }
608        (None, false) => Ok(None),
609        (None, true) => {
610            tracing::info!("Deploying OrderBookWhitelist");
611            let trade_account_whitelist = OrderBookDeploy::deploy_order_book_whitelist(
612                &deployer_wallet,
613                &Identity::Address(ViewOnlyAccount::address(&deployer_wallet)),
614                &OrderBookDeployConfig {
615                    salt,
616                    ..Default::default()
617                },
618            )
619            .await?;
620            tracing::info!(
621                "OrderBookWhitelist: {}",
622                trade_account_whitelist.contract_id()
623            );
624            Ok(Some(trade_account_whitelist.contract_id()))
625        }
626    }
627}
628
629/// Load an existing oracle and recover from partial deployment if needed.
630/// Unlike `TradeAccountDeploy::from_oracle_id`, this does not error when
631/// the trade account implementation is missing — it deploys and sets it.
632async fn load_or_recover_trade_account_oracle<W>(
633    deployer_wallet: &W,
634    oracle_id: ContractId,
635) -> anyhow::Result<(TradeAccountDeploy<W>, ContractId)>
636where
637    W: Account + ViewOnlyAccount + Clone + 'static,
638{
639    let oracle = TradingAccountOracle::new(oracle_id, deployer_wallet.clone());
640    let impl_id = oracle
641        .methods()
642        .get_trade_account_impl()
643        .simulate(Execution::state_read_only())
644        .await?
645        .value;
646
647    let blob_id = match impl_id {
648        Some(id) => id,
649        None => {
650            tracing::info!(
651                "Trade account implementation not set on oracle {}, deploying...",
652                oracle_id
653            );
654            let blob = TradeAccountDeploy::trade_account_blob(
655                deployer_wallet,
656                &Default::default(),
657            )
658            .await?;
659            TradeAccountDeploy::deploy_trade_account_blob(
660                deployer_wallet,
661                &DeployConfig::Latest(Default::default()),
662            )
663            .await?;
664            oracle
665                .methods()
666                .set_trade_account_impl(ContractId::from(blob.id))
667                .call()
668                .await?;
669            ContractId::from(blob.id)
670        }
671    };
672
673    let deploy = TradeAccountDeploy {
674        oracle,
675        oracle_id,
676        trade_account_blob_id: blob_id.into(),
677        deployer_wallet: deployer_wallet.clone(),
678        proxy: None,
679        proxy_id: None,
680    };
681    Ok((deploy, blob_id))
682}
683
684async fn deploy_trade_account_oracle<W>(
685    deployer_wallet: W,
686    should_upgrade_bytecode: bool,
687    trade_account_oracle_id: Option<ContractId>,
688    salt: Salt,
689) -> anyhow::Result<(TradeAccountDeploy<W>, ContractId)>
690where
691    W: Account + ViewOnlyAccount + Clone + 'static,
692{
693    let (trade_account_oracle_deploy, mut trade_account_blob_id) =
694        match trade_account_oracle_id {
695            Some(oracle_id) => {
696                load_or_recover_trade_account_oracle(&deployer_wallet, oracle_id).await?
697            }
698            None => {
699                let deploy = TradeAccountDeploy::deploy(
700                    &deployer_wallet,
701                    &DeployConfig::Latest(TradeAccountDeployConfig {
702                        salt,
703                        ..Default::default()
704                    }),
705                )
706                .await?;
707                let blob_id = deploy
708                    .oracle
709                    .methods()
710                    .get_trade_account_impl()
711                    .simulate(Execution::state_read_only())
712                    .await?
713                    .value
714                    .context("Trade account impl should exist after fresh deploy")?;
715                (deploy, blob_id)
716            }
717        };
718    tracing::info!(
719        "TradeAccountOracle: {}",
720        trade_account_oracle_deploy.oracle_id
721    );
722
723    if should_upgrade_bytecode {
724        let trade_account_blob =
725            TradeAccountDeploy::trade_account_blob(&deployer_wallet, &Default::default())
726                .await?;
727        if ContractId::from(trade_account_blob.id) != trade_account_blob_id {
728            tracing::info!(
729                "Update TradeAccountImpl on Oracle from {:?} to new blob {:?}",
730                trade_account_blob_id,
731                ContractId::from(trade_account_blob.id)
732            );
733            TradeAccountDeploy::deploy_trade_account_blob(
734                &deployer_wallet,
735                &DeployConfig::Latest(Default::default()),
736            )
737            .await?;
738            trade_account_oracle_deploy
739                .oracle
740                .methods()
741                .set_trade_account_impl(ContractId::from(trade_account_blob.id))
742                .call()
743                .await?;
744            trade_account_blob_id = ContractId::from(trade_account_blob.id);
745        }
746    }
747
748    Ok((trade_account_oracle_deploy, trade_account_blob_id))
749}
750
751/// Deploy (or load) the dedicated trial trade account oracle and keep its
752/// implementation current. The implementation follows the trade-account
753/// implementation's lifecycle: seeded when the oracle has none, upgraded
754/// under `should_upgrade_bytecode`. The cosigner is opt-in: providing one
755/// also (re)deploys the implementation and configures the cosigner; without
756/// one, the configured cosigner is left untouched. This runs before
757/// ownership transfer since it writes to the oracle.
758async fn deploy_trial_trade_account_oracle<W>(
759    deployer_wallet: W,
760    should_upgrade_bytecode: bool,
761    trial_trade_account_oracle_id: Option<ContractId>,
762    trial_cosigner: Option<fuels::types::Address>,
763    salt: Salt,
764) -> anyhow::Result<ContractId>
765where
766    W: Account + ViewOnlyAccount + Clone + 'static,
767{
768    let mut trial_deploy_config = TrialTradeAccountDeployConfig {
769        salt,
770        ..Default::default()
771    };
772    if let Some(cosigner) = trial_cosigner {
773        trial_deploy_config = trial_deploy_config.with_cosigner(cosigner);
774    }
775    let deploy_config = TrialDeployConfig::Latest(trial_deploy_config);
776
777    let oracle_id = match trial_trade_account_oracle_id {
778        None => {
779            let trial_deploy =
780                TrialTradeAccountDeploy::deploy(&deployer_wallet, &deploy_config).await?;
781            tracing::info!(
782                "TrialTradeAccountOracle: {} (implementation {:?}, cosigner {:?})",
783                trial_deploy.oracle_id,
784                trial_deploy.trial_trade_account_blob_id,
785                trial_cosigner,
786            );
787            trial_deploy.oracle_id
788        }
789        Some(oracle_id) => {
790            let current_trial_impl =
791                TrialTradingAccountOracle::new(oracle_id, deployer_wallet.clone())
792                    .methods()
793                    .get_trial_account_impl()
794                    .simulate(Execution::state_read_only())
795                    .await?
796                    .value;
797            if current_trial_impl.is_none()
798                || should_upgrade_bytecode
799                || trial_cosigner.is_some()
800            {
801                let trial_deploy = TrialTradeAccountDeploy::deploy_to_oracle(
802                    &deployer_wallet,
803                    oracle_id,
804                    &deploy_config,
805                )
806                .await?;
807                tracing::info!(
808                    "Trial implementation {:?} deployed to oracle {} (cosigner {:?})",
809                    trial_deploy.trial_trade_account_blob_id,
810                    oracle_id,
811                    trial_cosigner,
812                );
813            }
814            oracle_id
815        }
816    };
817
818    Ok(oracle_id)
819}
820
821async fn deploy_trade_account_registry<W>(
822    deployer_wallet: W,
823    should_upgrade_bytecode: bool,
824    trade_account_deploy: TradeAccountDeploy<W>,
825    trial_trade_account_oracle_id: ContractId,
826    trade_account_registry_id: Option<ContractId>,
827    salt: Salt,
828) -> anyhow::Result<(TradeAccountRegistryManager<W>, ContractId)>
829where
830    W: Account + ViewOnlyAccount + Clone + 'static,
831{
832    let trade_account_oracle_id = trade_account_deploy.oracle_id;
833    let trade_account_registry = match trade_account_registry_id {
834        Some(trade_account_registry_contract_id) => TradeAccountRegistryManager::new(
835            deployer_wallet.clone(),
836            trade_account_registry_contract_id,
837        ),
838        None => {
839            let trade_account_registry_deploy_config = TradeAccountRegistryDeployConfig {
840                salt,
841                ..Default::default()
842            };
843            TradeAccountRegistryManager::deploy(
844                &deployer_wallet,
845                trade_account_oracle_id,
846                trial_trade_account_oracle_id,
847                &trade_account_registry_deploy_config,
848            )
849            .await?
850        }
851    };
852    tracing::info!(
853        "TradeAccountRegistry: {}",
854        trade_account_registry.contract_id
855    );
856    let mut trade_account_registry_blob_id = match trade_account_registry
857        .registry_proxy
858        .methods()
859        .proxy_target()
860        .simulate(Execution::state_read_only())
861        .await?
862        .value
863    {
864        Some(blob_id) => blob_id,
865        None => {
866            tracing::info!("TradeAccountRegistry proxy target not set, initializing...");
867            // Call initialize_proxy() to write INITIAL_OWNER and INITIAL_TARGET
868            // from configurables to storage (upgrade/set_proxy_target would fail
869            // because the owner is not yet in storage)
870            trade_account_registry
871                .registry_proxy
872                .methods()
873                .initialize_proxy()
874                .call()
875                .await?;
876            trade_account_registry
877                .registry
878                .methods()
879                .initialize()
880                .call()
881                .await?;
882            trade_account_registry
883                .registry_proxy
884                .methods()
885                .proxy_target()
886                .simulate(Execution::state_read_only())
887                .await?
888                .value
889                .context("TradeAccountRegistry proxy target should be set after initialization")?
890        }
891    };
892
893    if should_upgrade_bytecode {
894        let trade_account_registry_deploy_config =
895            TradeAccountRegistryDeployConfig::default();
896        let trade_account_proxy_blob = TradeAccountRegistryManager::register_proxy_blob(
897            &deployer_wallet,
898            &trade_account_registry_deploy_config,
899        )
900        .await?;
901        let trial_trade_account_proxy_blob =
902            TradeAccountRegistryManager::register_trial_proxy_blob(
903                &deployer_wallet,
904                &trade_account_registry_deploy_config,
905            )
906            .await?;
907
908        let trade_account_register_blob = TradeAccountRegistryManager::register_blob(
909            &deployer_wallet,
910            trade_account_oracle_id,
911            trial_trade_account_oracle_id,
912            trade_account_proxy_blob.id,
913            trial_trade_account_proxy_blob.id,
914            &trade_account_registry_deploy_config,
915        )
916        .await?;
917
918        if trade_account_registry_blob_id
919            != ContractId::from(trade_account_register_blob.id)
920        {
921            tracing::info!(
922                "Upgrade TradeAccountRegistry blob from {:?} to {:?}",
923                trade_account_registry.contract_id,
924                ContractId::from(trade_account_register_blob.id)
925            );
926            trade_account_registry
927                .upgrade(
928                    trade_account_oracle_id,
929                    trial_trade_account_oracle_id,
930                    &TradeAccountRegistryDeployConfig::default(),
931                )
932                .await?;
933            trade_account_registry_blob_id = trade_account_register_blob.id.into();
934        }
935    }
936    Ok((trade_account_registry, trade_account_registry_blob_id))
937}
938
939async fn deploy_order_book_registry<W>(
940    deployer_wallet: W,
941    should_upgrade_bytecode: bool,
942    order_book_registry_id: Option<ContractId>,
943    salt: Salt,
944) -> anyhow::Result<(OrderBookRegistryManager<W>, ContractId)>
945where
946    W: Account + ViewOnlyAccount + Clone + 'static,
947{
948    let order_book_registry = match order_book_registry_id {
949        Some(registry_contract_id) => {
950            OrderBookRegistryManager::new(deployer_wallet.clone(), registry_contract_id)
951        }
952        None => {
953            OrderBookRegistryManager::deploy(
954                &deployer_wallet,
955                &OrderBookRegistryDeployConfig {
956                    salt,
957                    ..Default::default()
958                },
959            )
960            .await?
961        }
962    };
963    tracing::info!("OrderBookRegistry: {}", order_book_registry.contract_id);
964    let mut order_book_registry_blob_id = match order_book_registry
965        .registry_proxy
966        .methods()
967        .proxy_target()
968        .simulate(Execution::state_read_only())
969        .await?
970        .value
971    {
972        Some(blob_id) => blob_id,
973        None => {
974            tracing::info!("OrderBookRegistry proxy target not set, initializing...");
975            // Call initialize_proxy() to write INITIAL_OWNER and INITIAL_TARGET
976            // from configurables to storage (upgrade/set_proxy_target would fail
977            // because the owner is not yet in storage)
978            order_book_registry
979                .registry_proxy
980                .methods()
981                .initialize_proxy()
982                .call()
983                .await?;
984            order_book_registry
985                .registry
986                .methods()
987                .initialize()
988                .call()
989                .await?;
990            order_book_registry
991                .registry_proxy
992                .methods()
993                .proxy_target()
994                .simulate(Execution::state_read_only())
995                .await?
996                .value
997                .context(
998                    "OrderBookRegistry proxy target should be set after initialization",
999                )?
1000        }
1001    };
1002
1003    if should_upgrade_bytecode {
1004        let order_book_register_deploy_config = OrderBookRegistryDeployConfig::default();
1005        let order_book_register_blob = OrderBookRegistryManager::register_blob(
1006            &deployer_wallet,
1007            &order_book_register_deploy_config,
1008        )
1009        .await?;
1010        if order_book_registry_blob_id != order_book_register_blob.id.into() {
1011            tracing::info!(
1012                "Upgrade OrderBookRegistry blob from {:?} to {:?}",
1013                order_book_registry.contract_id,
1014                ContractId::from(order_book_register_blob.id)
1015            );
1016            order_book_registry
1017                .upgrade(&order_book_register_deploy_config)
1018                .await?;
1019            order_book_registry_blob_id = order_book_register_blob.id.into();
1020        }
1021    }
1022
1023    Ok((order_book_registry, order_book_registry_blob_id))
1024}
1025
1026async fn deploy_order_books<W>(
1027    deployer_wallet: W,
1028    should_upgrade_bytecode: bool,
1029    order_book_blacklist_id: Option<ContractId>,
1030    order_book_whitelist_id: Option<ContractId>,
1031    order_book_registry: OrderBookRegistryManager<W>,
1032    order_book_configs: &mut [OrderBookConfig],
1033    ownership_options: OwnershipTransferOptions,
1034) -> anyhow::Result<Vec<OrderBookConfig>>
1035where
1036    W: Account + ViewOnlyAccount + Clone + 'static,
1037{
1038    let mut pairs: Vec<OrderBookConfig> = Vec::with_capacity(order_book_configs.len());
1039
1040    for order_book_config in order_book_configs.iter_mut() {
1041        let pair = deploy_single_order_book(
1042            &deployer_wallet,
1043            should_upgrade_bytecode,
1044            order_book_blacklist_id,
1045            order_book_whitelist_id,
1046            &order_book_registry,
1047            order_book_config,
1048            &ownership_options,
1049        )
1050        .await?;
1051        pairs.push(pair);
1052    }
1053
1054    Ok(pairs)
1055}
1056
1057async fn deploy_single_order_book<W>(
1058    deployer_wallet: &W,
1059    should_upgrade_bytecode: bool,
1060    order_book_blacklist_id: Option<ContractId>,
1061    order_book_whitelist_id: Option<ContractId>,
1062    order_book_registry: &OrderBookRegistryManager<W>,
1063    order_book_config: &mut OrderBookConfig,
1064    ownership_options: &OwnershipTransferOptions,
1065) -> anyhow::Result<OrderBookConfig>
1066where
1067    W: Account + ViewOnlyAccount + Clone + 'static,
1068{
1069    let market_symbol = format!(
1070        "{}/{}",
1071        order_book_config.base.symbol, order_book_config.quote.symbol
1072    );
1073    let market_id = MarketIdAssets {
1074        base_asset: order_book_config.base.asset,
1075        quote_asset: order_book_config.quote.asset,
1076    };
1077    let order_book_configurables = build_order_book_configurables(
1078        order_book_config,
1079        order_book_blacklist_id,
1080        order_book_whitelist_id,
1081        deployer_wallet,
1082    )?;
1083
1084    let order_book = load_or_deploy_order_book(
1085        deployer_wallet,
1086        order_book_registry,
1087        &market_id,
1088        &market_symbol,
1089        &order_book_configurables,
1090        order_book_config,
1091    )
1092    .await?;
1093
1094    tracing::info!(
1095        "[{}] OrderBook: {}",
1096        market_symbol,
1097        order_book.contract.contract_id()
1098    );
1099
1100    let order_book_blob_id = maybe_upgrade_order_book(
1101        deployer_wallet,
1102        should_upgrade_bytecode,
1103        &order_book,
1104        order_book_config,
1105        order_book_configurables,
1106        &market_symbol,
1107    )
1108    .await?;
1109
1110    // Set the maintainer while the deployer is still the owner, i.e. before any
1111    // ownership transfer below (`owner_grant_role`/`owner_revoke_role` are
1112    // `only_owner`).
1113    set_order_book_maintainer(&order_book, ownership_options, &market_symbol).await?;
1114
1115    transfer_order_book_ownership(&order_book, ownership_options, &market_symbol).await?;
1116
1117    order_book_config.contract_id = Some(order_book.contract.contract_id());
1118    order_book_config.blob_id = order_book_blob_id.into();
1119
1120    Ok(order_book_config.clone())
1121}
1122
1123fn build_order_book_configurables<W: ViewOnlyAccount>(
1124    config: &OrderBookConfig,
1125    order_book_blacklist_id: Option<ContractId>,
1126    order_book_whitelist_id: Option<ContractId>,
1127    deployer_wallet: &W,
1128) -> anyhow::Result<OrderBookConfigurables> {
1129    let price_precision = config
1130        .quote
1131        .decimals
1132        .checked_sub(config.quote.max_precision)
1133        .ok_or_else(|| {
1134            anyhow::anyhow!(
1135                "quote max_precision ({}) exceeds decimals ({})",
1136                config.quote.max_precision,
1137                config.quote.decimals
1138            )
1139        })?;
1140    let quantity_precision = config
1141        .base
1142        .decimals
1143        .checked_sub(config.base.max_precision)
1144        .ok_or_else(|| {
1145            anyhow::anyhow!(
1146                "base max_precision ({}) exceeds decimals ({})",
1147                config.base.max_precision,
1148                config.base.decimals
1149            )
1150        })?;
1151
1152    Ok(OrderBookConfigurables::default()
1153        .with_MIN_ORDER(config.min_order)?
1154        .with_ALLOW_FRACTIONAL_PRICE(config.allow_fractional_price)?
1155        .with_TAKER_FEE(config.taker_fee.into())?
1156        .with_MAKER_FEE(config.maker_fee.into())?
1157        .with_DUST(config.dust)?
1158        .with_PRICE_WINDOW(config.price_window as u64)?
1159        .with_BASE_DECIMALS(10u64.pow(config.base.decimals as u32))?
1160        .with_QUOTE_DECIMALS(10u64.pow(config.quote.decimals as u32))?
1161        .with_BASE_SYMBOL(SizedAsciiString::new_with_right_whitespace_padding(
1162            config.base.symbol.clone(),
1163        )?)?
1164        .with_QUOTE_SYMBOL(SizedAsciiString::new_with_right_whitespace_padding(
1165            config.quote.symbol.clone(),
1166        )?)?
1167        .with_PRICE_PRECISION(10u64.pow(price_precision as u32))?
1168        .with_QUANTITY_PRECISION(10u64.pow(quantity_precision as u32))?
1169        .with_INITIAL_OWNER(o2_tools::order_book_deploy::State::Initialized(
1170            Identity::Address(ViewOnlyAccount::address(deployer_wallet)),
1171        ))?
1172        .with_WHITE_LIST_CONTRACT(order_book_whitelist_id)?
1173        .with_BLACK_LIST_CONTRACT(order_book_blacklist_id)?)
1174}
1175
1176async fn load_or_deploy_order_book<W>(
1177    deployer_wallet: &W,
1178    order_book_registry: &OrderBookRegistryManager<W>,
1179    market_id: &MarketIdAssets,
1180    market_symbol: &str,
1181    order_book_configurables: &OrderBookConfigurables,
1182    order_book_config: &OrderBookConfig,
1183) -> anyhow::Result<OrderBookManager<W>>
1184where
1185    W: Account + ViewOnlyAccount + Clone + 'static,
1186{
1187    let register_contract_id = order_book_registry
1188        .registry
1189        .methods()
1190        .get_order_book(to_registry_market_id(market_id))
1191        .simulate(Execution::state_read_only())
1192        .await?
1193        .value;
1194
1195    match register_contract_id {
1196        Some(contract_id) => {
1197            let order_book_deploy = OrderBookDeploy::new(
1198                deployer_wallet.clone(),
1199                contract_id,
1200                market_id.base_asset,
1201                market_id.quote_asset,
1202            );
1203            // Handle partially deployed contracts (e.g. previous deploy failed
1204            // after registering but before initializing the proxy)
1205            let proxy_target = order_book_deploy
1206                .order_book_proxy
1207                .methods()
1208                .proxy_target()
1209                .simulate(Execution::state_read_only())
1210                .await?
1211                .value;
1212            if proxy_target.is_none() {
1213                tracing::info!(
1214                    "[{}] Proxy target not set, initializing...",
1215                    market_symbol
1216                );
1217                order_book_deploy.initialize().await?;
1218            }
1219            Ok(OrderBookManager::new(
1220                deployer_wallet,
1221                10u64.pow(order_book_config.base.decimals as u32),
1222                10u64.pow(order_book_config.quote.decimals as u32),
1223                &order_book_deploy,
1224            ))
1225        }
1226        None => {
1227            let (order_book_deployment, initialization_required) =
1228                OrderBookDeploy::deploy_without_initialization(
1229                    deployer_wallet,
1230                    market_id.base_asset,
1231                    market_id.quote_asset,
1232                    &OrderBookDeployConfig {
1233                        order_book_configurables: order_book_configurables.clone(),
1234                        salt: Salt::from(*order_book_registry.contract_id),
1235                        ..Default::default()
1236                    },
1237                )
1238                .await?;
1239
1240            order_book_registry
1241                .register_order_book(
1242                    to_registry_market_id(market_id),
1243                    order_book_deployment.contract_id,
1244                )
1245                .await?;
1246
1247            if initialization_required {
1248                order_book_deployment.initialize().await?;
1249            }
1250            Ok(OrderBookManager::new(
1251                deployer_wallet,
1252                10u64.pow(order_book_config.base.decimals as u32),
1253                10u64.pow(order_book_config.quote.decimals as u32),
1254                &order_book_deployment,
1255            ))
1256        }
1257    }
1258}
1259
1260async fn maybe_upgrade_order_book<W>(
1261    deployer_wallet: &W,
1262    should_upgrade_bytecode: bool,
1263    order_book: &OrderBookManager<W>,
1264    order_book_config: &OrderBookConfig,
1265    order_book_configurables: OrderBookConfigurables,
1266    market_symbol: &str,
1267) -> anyhow::Result<ContractId>
1268where
1269    W: Account + ViewOnlyAccount + Clone + 'static,
1270{
1271    let mut order_book_blob_id = order_book
1272        .proxy
1273        .methods()
1274        .proxy_target()
1275        .simulate(Execution::state_read_only())
1276        .await?
1277        .value
1278        .context("Order book proxy target should be set after initialization")?;
1279
1280    if should_upgrade_bytecode {
1281        let order_book_deploy_config = OrderBookDeployConfig {
1282            order_book_configurables,
1283            ..Default::default()
1284        };
1285        let order_book_deploy = OrderBookDeploy::new(
1286            deployer_wallet.clone(),
1287            order_book.contract.contract_id(),
1288            order_book_config.base.asset,
1289            order_book_config.quote.asset,
1290        );
1291        let order_book_manager = OrderBookManager::new(
1292            deployer_wallet,
1293            10u64.pow(order_book_config.base.decimals as u32),
1294            10u64.pow(order_book_config.quote.decimals as u32),
1295            &order_book_deploy,
1296        );
1297        let order_book_blob = OrderBookDeploy::order_book_blob(
1298            deployer_wallet,
1299            order_book_config.base.asset,
1300            order_book_config.quote.asset,
1301            &order_book_deploy_config,
1302        )
1303        .await?;
1304
1305        if order_book_blob_id != order_book_blob.id.into() {
1306            tracing::info!(
1307                "[{}] Upgrade OrderBook blob from {:?} to {:?}",
1308                market_symbol,
1309                order_book_blob_id,
1310                ContractId::from(order_book_blob.id)
1311            );
1312            order_book_manager
1313                .upgrade(&order_book_deploy_config)
1314                .await?;
1315            tracing::info!(
1316                "[{}] Emit new configuration event for {}",
1317                market_symbol,
1318                order_book.contract.contract_id()
1319            );
1320            order_book_manager.emit_config().await?;
1321            order_book_blob_id = order_book_blob.id.into();
1322        }
1323    }
1324
1325    Ok(order_book_blob_id)
1326}
1327
1328async fn set_order_book_maintainer<W>(
1329    order_book: &OrderBookManager<W>,
1330    ownership_options: &OwnershipTransferOptions,
1331    market_symbol: &str,
1332) -> anyhow::Result<()>
1333where
1334    W: Account + ViewOnlyAccount + Clone + 'static,
1335{
1336    // Revoke the maintainer role from the previous holders first, so the role is
1337    // rotated rather than accumulating holders across upgrades. Revoking an
1338    // account that does not hold the role is a no-op on-chain.
1339    for account in &ownership_options.revoke_orderbook_maintainers {
1340        tracing::info!(
1341            "[{}] Revoking ORDERBOOK_MAINTAINER_ROLE from {}",
1342            market_symbol,
1343            account
1344        );
1345        order_book
1346            .contract
1347            .methods()
1348            .owner_revoke_role(ORDERBOOK_MAINTAINER_ROLE, Identity::Address(*account))
1349            .call()
1350            .await?;
1351    }
1352
1353    for account in &ownership_options.new_orderbook_maintainers {
1354        tracing::info!(
1355            "[{}] Granting ORDERBOOK_MAINTAINER_ROLE to {}",
1356            market_symbol,
1357            account
1358        );
1359        order_book
1360            .contract
1361            .methods()
1362            .owner_grant_role(ORDERBOOK_MAINTAINER_ROLE, Identity::Address(*account))
1363            .call()
1364            .await?;
1365    }
1366
1367    Ok(())
1368}
1369
1370async fn transfer_order_book_ownership<W>(
1371    order_book: &OrderBookManager<W>,
1372    ownership_options: &OwnershipTransferOptions,
1373    market_symbol: &str,
1374) -> anyhow::Result<()>
1375where
1376    W: Account + ViewOnlyAccount + Clone + 'static,
1377{
1378    if let Some(new_owner) = ownership_options.new_proxy_owner {
1379        let new_identity = Identity::Address(new_owner);
1380        tracing::info!(
1381            "[{}] Transferring OrderBook proxy ownership to {}",
1382            market_symbol,
1383            new_owner
1384        );
1385        order_book
1386            .proxy
1387            .methods()
1388            .set_owner(new_identity)
1389            .call()
1390            .await?;
1391    }
1392
1393    if let Some(new_owner) = ownership_options.new_contract_owner {
1394        let new_identity = Identity::Address(new_owner);
1395        tracing::info!(
1396            "[{}] Transferring OrderBook contract ownership to {}",
1397            market_symbol,
1398            new_owner
1399        );
1400        order_book
1401            .contract
1402            .methods()
1403            .transfer_ownership(new_identity)
1404            .call()
1405            .await?;
1406    }
1407
1408    Ok(())
1409}
1410
1411#[cfg(test)]
1412mod tests {
1413    use super::*;
1414
1415    #[test]
1416    fn load_config_empty_path_returns_default() {
1417        let result: MarketsConfigPartial = load_config_from_file("").unwrap();
1418        assert!(result.pairs.is_empty());
1419    }
1420
1421    #[test]
1422    fn load_config_missing_file_errors() {
1423        let result: Result<MarketsConfigPartial, _> =
1424            load_config_from_file("nonexistent_file_12345.json");
1425        assert!(result.is_err());
1426    }
1427
1428    #[test]
1429    fn checked_sub_catches_overflow() {
1430        // Validates that our checked_sub pattern works correctly
1431        let decimals: u32 = 6;
1432        let max_precision: u32 = 8; // greater than decimals
1433
1434        let result = decimals.checked_sub(max_precision);
1435        assert!(
1436            result.is_none(),
1437            "should return None when max_precision > decimals"
1438        );
1439
1440        // Normal case
1441        let result = 9u32.checked_sub(6);
1442        assert_eq!(result, Some(3));
1443    }
1444
1445    #[test]
1446    fn markets_config_partial_default_has_empty_pairs() {
1447        let config = MarketsConfigPartial::default();
1448        assert!(config.pairs.is_empty());
1449    }
1450}