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