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
67pub use o2_tools::prop_deploy::{
68    ExistingRegistry,
69    PropDeployConfig,
70    PropDeployment,
71};
72
73/// Access-control role that authorizes order-book maintenance actions
74/// (`force_cancel_orders`, `cancel_blacklist_orders`, `eject_trigger_order`).
75///
76/// Must match `ORDERBOOK_MAINTAINER_ROLE` in `contracts/order-book/src/main.sw`.
77const ORDERBOOK_MAINTAINER_ROLE: u64 = 1;
78
79/// Deploy the independent prop-account system.
80///
81/// This is additive to the existing exchange deployment: callers can choose
82/// when to deploy the direct account oracle, mock price feed, proxied registry,
83/// and proxied margin pool.
84pub async fn deploy_prop_system<W>(
85    wallet: &W,
86    config: &PropDeployConfig,
87) -> anyhow::Result<PropDeployment<W>>
88where
89    W: Account + Clone,
90{
91    PropDeployment::deploy(wallet, config).await
92}
93
94fn to_registry_market_id(m: &MarketIdAssets) -> o2_tools::order_book_registry::MarketId {
95    o2_tools::order_book_registry::MarketId {
96        base_asset: m.base_asset,
97        quote_asset: m.quote_asset,
98    }
99}
100
101// ---------------------------------------------------------------------------
102// Types
103// ---------------------------------------------------------------------------
104
105#[serde_as]
106#[derive(Debug, serde::Serialize, Clone, Default)]
107pub struct MarketsConfigOutput {
108    pub starting_height: u32,
109    #[serde_as(as = "HexDisplayFromStr")]
110    pub trade_account_registry_id: ContractId,
111    #[serde_as(as = "HexDisplayFromStr")]
112    pub trade_account_registry_blob_id: ContractId,
113    #[serde_as(as = "HexDisplayFromStr")]
114    pub trade_account_oracle_id: ContractId,
115    #[serde_as(as = "HexDisplayFromStr")]
116    pub trial_trade_account_oracle_id: ContractId,
117    #[serde_as(as = "HexDisplayFromStr")]
118    pub trade_account_root: ContractId,
119    #[serde_as(as = "HexDisplayFromStr")]
120    pub trade_account_proxy: ContractId,
121    #[serde_as(as = "HexDisplayFromStr")]
122    pub trade_account_blob_id: ContractId,
123    #[serde_as(as = "Option<HexDisplayFromStr>")]
124    pub order_book_whitelist_id: Option<ContractId>,
125    #[serde_as(as = "Option<HexDisplayFromStr>")]
126    pub order_book_blacklist_id: Option<ContractId>,
127    #[serde_as(as = "HexDisplayFromStr")]
128    pub order_book_registry_id: ContractId,
129    #[serde_as(as = "HexDisplayFromStr")]
130    pub order_book_registry_blob_id: ContractId,
131    #[serde_as(as = "Option<HexDisplayFromStr>")]
132    pub fast_bridge_asset_registry_proxy_id: Option<ContractId>,
133    #[serde_as(as = "Option<HexDisplayFromStr>")]
134    pub price_feed_id: Option<ContractId>,
135    #[serde_as(as = "Option<HexDisplayFromStr>")]
136    pub margin_pool_id: Option<ContractId>,
137    #[serde_as(as = "Option<HexDisplayFromStr>")]
138    pub margin_oracle_id: Option<ContractId>,
139    /// The `margin` section, echoed back with the ids this run resolved
140    /// folded in. CI writes the output OVER the deploy config, so anything
141    /// the output drops is destroyed: without this the first margin deploy
142    /// would erase the tier catalogue, the pool config, the publishers and
143    /// the initial prices, and every later run would silently do no margin
144    /// work at all.
145    #[serde(skip_serializing_if = "Option::is_none")]
146    pub margin: Option<MarginConfig>,
147    pub pairs: Vec<OrderBookConfig>,
148}
149
150/// Intermediate type for deserializing order book configs with string-encoded numbers.
151#[serde_as]
152#[derive(Debug, Clone, serde::Deserialize)]
153struct OrderBookConfigDeHelper {
154    #[serde_as(as = "Option<serde_with::DisplayFromStr>")]
155    blob_id: Option<ContractId>,
156    #[serde_as(as = "Option<serde_with::DisplayFromStr>")]
157    contract_id: Option<ContractId>,
158    #[serde_as(as = "serde_with::DisplayFromStr")]
159    taker_fee: u64,
160    #[serde_as(as = "serde_with::DisplayFromStr")]
161    maker_fee: u64,
162    #[serde_as(as = "serde_with::DisplayFromStr")]
163    min_order: u64,
164    #[serde_as(as = "serde_with::DisplayFromStr")]
165    dust: u64,
166    price_window: u8,
167    #[serde(default)]
168    allow_fractional_price: bool,
169    base: AssetConfig,
170    quote: AssetConfig,
171}
172
173impl From<OrderBookConfigDeHelper> for OrderBookConfig {
174    fn from(h: OrderBookConfigDeHelper) -> Self {
175        let ids = MarketIdAssets {
176            base_asset: h.base.asset,
177            quote_asset: h.quote.asset,
178        };
179        let market_id = ids.market_id();
180        OrderBookConfig {
181            contract_id: h.contract_id,
182            blob_id: h.blob_id,
183            market_id,
184            taker_fee: h.taker_fee,
185            maker_fee: h.maker_fee,
186            min_order: h.min_order,
187            dust: h.dust,
188            price_window: h.price_window,
189            allow_fractional_price: h.allow_fractional_price,
190            base: h.base,
191            quote: h.quote,
192        }
193    }
194}
195
196#[derive(Debug, Clone, Default, serde::Serialize)]
197pub struct MarketsConfigPartial {
198    pub starting_height: u32,
199    pub trade_account_registry_id: Option<ContractId>,
200    pub order_book_registry_id: Option<ContractId>,
201    pub trade_account_oracle_id: Option<ContractId>,
202    pub trial_trade_account_oracle_id: Option<ContractId>,
203    pub order_book_whitelist_id: Option<ContractId>,
204    pub order_book_blacklist_id: Option<ContractId>,
205    pub fast_bridge_asset_registry_proxy_id: Option<ContractId>,
206    pub pairs: Vec<OrderBookConfig>,
207    /// Optional `margin` section: the prop-account margin stack (price
208    /// feed, pool, account oracle, in-place registry upgrade) plus the
209    /// tier catalogue, deployed/upgraded idempotently after the books.
210    pub margin: Option<MarginConfig>,
211}
212
213impl<'de> serde::Deserialize<'de> for MarketsConfigPartial {
214    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
215    where
216        D: serde::Deserializer<'de>,
217    {
218        #[derive(serde::Deserialize, Default)]
219        struct Helper {
220            #[serde(default)]
221            starting_height: u32,
222            trade_account_registry_id: Option<ContractId>,
223            order_book_registry_id: Option<ContractId>,
224            trade_account_oracle_id: Option<ContractId>,
225            trial_trade_account_oracle_id: Option<ContractId>,
226            order_book_whitelist_id: Option<ContractId>,
227            order_book_blacklist_id: Option<ContractId>,
228            fast_bridge_asset_registry_proxy_id: Option<ContractId>,
229            #[serde(default)]
230            pairs: Vec<OrderBookConfigDeHelper>,
231            #[serde(default)]
232            margin: Option<MarginConfig>,
233        }
234        let h = Helper::deserialize(deserializer)?;
235        Ok(MarketsConfigPartial {
236            starting_height: h.starting_height,
237            trade_account_registry_id: h.trade_account_registry_id,
238            order_book_registry_id: h.order_book_registry_id,
239            trade_account_oracle_id: h.trade_account_oracle_id,
240            trial_trade_account_oracle_id: h.trial_trade_account_oracle_id,
241            order_book_whitelist_id: h.order_book_whitelist_id,
242            order_book_blacklist_id: h.order_book_blacklist_id,
243            fast_bridge_asset_registry_proxy_id: h.fast_bridge_asset_registry_proxy_id,
244            pairs: h.pairs.into_iter().map(Into::into).collect(),
245            margin: h.margin,
246        })
247    }
248}
249
250/// One tier of the margin catalogue. Mirrors the on-chain `TierParams`
251/// plus the tier id and the markets (order books) it covers. `line`
252/// follows the config convention of u64 amounts as strings.
253#[serde_as]
254#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
255pub struct MarginTierConfig {
256    pub tier_id: u64,
257    /// Credit line in collateral-asset base units, as a decimal string.
258    #[serde_as(as = "serde_with::DisplayFromStr")]
259    pub line: u64,
260    pub leverage: u64,
261    pub duration: u64,
262    pub maintenance_bps: u64,
263    pub open_buffer_bps: u64,
264    pub liq_price_factor: u64,
265    pub prolong_fee_bps: [u64; 4],
266    pub max_credit_line_bps: u64,
267    pub max_price_age: u64,
268    pub open_fee_bps: u64,
269    pub profit_share_bps: u64,
270    pub price_band_bps: u64,
271    /// Markets in this tier, referencing already-configured pairs by
272    /// "BASE/QUOTE" symbol or by hex market id.
273    pub markets: Vec<String>,
274}
275
276/// One initial price to publish on the feed before tiers are published
277/// (`publish_tier_version` prices every non-collateral tier asset).
278/// `bid`/`ask` are u128 decimal strings scaled 1e18 per whole unit.
279#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
280pub struct MarginInitialPrice {
281    pub asset: fuels::types::AssetId,
282    pub bid: String,
283    pub ask: String,
284    pub asset_decimals: u8,
285}
286
287/// Optional `margin` section of the deploy config: the prop margin pool,
288/// the prop account oracle, the price feed and the tier catalogue.
289#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
290pub struct MarginConfig {
291    /// DRY-RUN: report what exists on the live chain versus what a real
292    /// run would deploy/upgrade — per contract and per tier — and send NO
293    /// transaction.
294    #[serde(default)]
295    pub dry_run: bool,
296    /// Reuse an existing price feed instead of deploying the mock feed
297    /// (production passes its proxied feed; the mock fits test/dev only).
298    pub price_feed_id: Option<ContractId>,
299    /// TIER-ONLY mode: run no system deploy, only reconcile the tier
300    /// catalogue (and prices/publishers when configured) against this
301    /// already-deployed pool.
302    pub margin_pool_id: Option<ContractId>,
303    /// Identity receiving the platform profit share, prefixed with its
304    /// kind (`address:0x..` / `contract:0x..`). Defaults to the deployer.
305    pub platform_payout: Option<String>,
306    /// Price feed publishers to add (`address:0x..` — the feed grants the
307    /// submitter role to addresses). When empty, nothing is granted; the
308    /// deployer must already be a publisher for `initial_prices` to land.
309    #[serde(default)]
310    pub publishers: Vec<String>,
311    /// Collateral asset of the pool. Defaults to the quote asset of the
312    /// first configured pair.
313    pub collateral_asset: Option<fuels::types::AssetId>,
314    /// Collateral asset decimals. REQUIRED: a wrong figure misprices every
315    /// valuation the pool makes, so there is no default to fall back to.
316    pub collateral_decimals: Option<u8>,
317    /// Maximum order books one tier version may hold. Defaults to 80.
318    pub max_tier_books: Option<u64>,
319    /// Prices published (by the deployer) before tiers, so tier assets
320    /// are priced on the feed. Already-priced assets are skipped.
321    #[serde(default)]
322    pub initial_prices: Vec<MarginInitialPrice>,
323    /// The desired tier catalogue; reconciled idempotently (§ tier engine):
324    /// unchanged tiers are untouched, grown book lists use `add_books`,
325    /// any param change publishes a NEW version.
326    #[serde(default)]
327    pub tiers: Vec<MarginTierConfig>,
328}
329
330#[derive(Debug, Clone, Default)]
331pub struct OwnershipTransferOptions {
332    pub new_proxy_owner: Option<fuels::types::Address>,
333    pub new_contract_owner: Option<fuels::types::Address>,
334    /// Accounts to revoke `ORDERBOOK_MAINTAINER_ROLE` from on each order book
335    /// (via `owner_revoke_role`) before granting the new maintainers, so the role
336    /// is rotated rather than accumulating holders across upgrades.
337    pub revoke_orderbook_maintainers: Vec<fuels::types::Address>,
338    /// Accounts to grant `ORDERBOOK_MAINTAINER_ROLE` to on each order book (via
339    /// `owner_grant_role`) before ownership is transferred away.
340    pub new_orderbook_maintainers: Vec<fuels::types::Address>,
341}
342
343/// Parameters for a deploy invocation.
344#[derive(Debug, Clone)]
345pub struct DeployParams {
346    pub deploy_config: MarketsConfigPartial,
347    pub output: Option<String>,
348    pub deploy_whitelist: bool,
349    pub deploy_blacklist: bool,
350    pub upgrade_bytecode: bool,
351    pub new_proxy_owner: Option<fuels::types::Address>,
352    pub new_contract_owner: Option<fuels::types::Address>,
353    /// Cosigner address for trial trade accounts. When set, the trial trade
354    /// account implementation is (re)deployed on the trial oracle and this
355    /// cosigner is configured on it; when absent, the configured cosigner is
356    /// left untouched (the implementation still follows the regular
357    /// seed/upgrade lifecycle).
358    pub trial_cosigner: Option<fuels::types::Address>,
359    /// Identity allowed to register (activate) trial trade accounts on the
360    /// registry. When set, it is written via
361    /// `set_trial_trade_account_creator` if it differs from the current one
362    /// — registries upgraded in place start with the creator unset, which
363    /// denies all trial registrations. When absent, the creator is left
364    /// untouched.
365    pub trial_creator: Option<Identity>,
366    /// Cosigner address for prop/margin accounts. When set, it is written to
367    /// the prop account oracle if it differs from the live one; when absent,
368    /// the configured cosigner is left untouched. Deliberately NOT sourced
369    /// from the deploy config and NOT defaulted to the deployer: a cosigner
370    /// the backend does not hold the key for makes margin silently inert.
371    pub margin_cosigner: Option<fuels::types::Address>,
372    /// Recipient of the residue from forced margin exits (liquidation and
373    /// expiry). Same opt-in rule as `margin_cosigner`: set it and it is
374    /// reconciled, omit it and the live value stands. Never defaulted to the
375    /// deployer - the deploy key must not silently become the payee.
376    pub margin_liquidator: Option<Identity>,
377    pub revoke_orderbook_maintainers: Vec<fuels::types::Address>,
378    pub new_orderbook_maintainers: Vec<fuels::types::Address>,
379}
380
381// ---------------------------------------------------------------------------
382// Helpers
383// ---------------------------------------------------------------------------
384
385/// Load a JSON config file, returning `T::default()` when the path is empty.
386pub fn load_config_from_file<T>(config_path: &str) -> anyhow::Result<T>
387where
388    T: Default + serde::de::DeserializeOwned,
389{
390    if config_path.is_empty() {
391        return Ok(T::default());
392    }
393    let current_dir = std::env::current_dir()?;
394    let path = current_dir.join(config_path);
395    tracing::info!("Loading config from {}", path.display());
396    let file = std::fs::File::open(&path)?;
397    let config: T = serde_json::from_reader(file)?;
398    Ok(config)
399}
400
401// ---------------------------------------------------------------------------
402// Core deploy logic
403// ---------------------------------------------------------------------------
404
405/// Deploy (or upgrade) the full set of O2 contracts.
406///
407/// The wallet type `W` must implement `Account + Clone + Signer` (e.g.
408/// `fuels::prelude::WalletUnlocked` or the KMS-backed `O2Wallet` from the API).
409pub async fn deploy<W>(
410    wallet: W,
411    params: DeployParams,
412) -> anyhow::Result<MarketsConfigOutput>
413where
414    W: Account + ViewOnlyAccount + Clone + 'static,
415{
416    tracing::info!("Starting Fuel o2 Registries and Markets");
417    let mut markets_config_partial = params.deploy_config.clone();
418    let starting_height: BlockHeight = markets_config_partial.starting_height.into();
419    let trade_account_oracle_id = markets_config_partial.trade_account_oracle_id;
420    let trial_trade_account_oracle_id =
421        markets_config_partial.trial_trade_account_oracle_id;
422    let order_book_registry_id = markets_config_partial.order_book_registry_id;
423    let trade_account_registry_id = markets_config_partial.trade_account_registry_id;
424    let fast_bridge_asset_registry_proxy_id =
425        markets_config_partial.fast_bridge_asset_registry_proxy_id;
426
427    let mut salt = Salt::zeroed();
428    salt.deref_mut()[..4].copy_from_slice(&starting_height.deref().to_be_bytes());
429
430    let (trade_account_oracle_deploy, trade_account_blob_id) =
431        deploy_trade_account_oracle(
432            wallet.clone(),
433            params.upgrade_bytecode,
434            trade_account_oracle_id,
435            salt,
436        )
437        .await?;
438    // The trial oracle is a dedicated contract (already-deployed trade
439    // account oracles are immutable and cannot gain the trial functions), so
440    // it deploys separately and the registry references both oracle ids.
441    let trial_trade_account_oracle_id = deploy_trial_trade_account_oracle(
442        wallet.clone(),
443        params.upgrade_bytecode,
444        trial_trade_account_oracle_id,
445        params.trial_cosigner,
446        salt,
447    )
448    .await?;
449    let (trade_account_registry, trade_account_registry_blob_id) =
450        deploy_trade_account_registry(
451            wallet.clone(),
452            params.upgrade_bytecode,
453            trade_account_oracle_deploy.clone(),
454            trial_trade_account_oracle_id,
455            trade_account_registry_id,
456            salt,
457        )
458        .await?;
459    let order_book_blacklist_id = deploy_order_book_blacklist(
460        wallet.clone(),
461        params.deploy_blacklist,
462        markets_config_partial.order_book_blacklist_id,
463        salt,
464    )
465    .await?;
466    let order_book_whitelist_id = deploy_order_book_whitelist(
467        wallet.clone(),
468        params.deploy_whitelist,
469        markets_config_partial.order_book_whitelist_id,
470        salt,
471    )
472    .await?;
473    let (order_book_registry, order_book_registry_blob_id) = deploy_order_book_registry(
474        wallet.clone(),
475        params.upgrade_bytecode,
476        order_book_registry_id,
477        salt,
478    )
479    .await?;
480    let pairs = deploy_order_books(
481        wallet.clone(),
482        params.upgrade_bytecode,
483        order_book_blacklist_id,
484        order_book_whitelist_id,
485        order_book_registry.clone(),
486        &mut markets_config_partial.pairs,
487        OwnershipTransferOptions {
488            new_proxy_owner: params.new_proxy_owner,
489            new_contract_owner: params.new_contract_owner,
490            revoke_orderbook_maintainers: params.revoke_orderbook_maintainers.clone(),
491            new_orderbook_maintainers: params.new_orderbook_maintainers.clone(),
492        },
493    )
494    .await?;
495
496    let order_book_registry_id = order_book_registry.contract_id;
497    let trade_account_registry_id = trade_account_registry.contract_id;
498    let trade_account_oracle_id = trade_account_oracle_deploy.oracle_id;
499
500    let trade_account_proxy = trade_account_registry
501        .registry
502        .methods()
503        .default_bytecode()
504        .simulate(Execution::state_read_only())
505        .await?
506        .value
507        .context(
508            "Trade account registry default bytecode should exist after initialization",
509        )?;
510    let trade_account_root = trade_account_registry
511        .registry
512        .methods()
513        .factory_bytecode_root()
514        .simulate(Execution::state_read_only())
515        .await?
516        .value
517        .context("Trade account registry factory bytecode root should exist after initialization")?;
518
519    // The margin phase runs BEFORE ownership transfer: the in-place
520    // registry upgrade and the pool/tier governance calls need the deployer
521    // to still hold the proxies and the pool admin role.
522    let margin_ids = match &markets_config_partial.margin {
523        Some(margin) => Some(
524            deploy_margin(
525                &wallet,
526                margin,
527                salt,
528                trade_account_registry_id,
529                trade_account_oracle_id,
530                trial_trade_account_oracle_id,
531                &pairs,
532                params.margin_cosigner,
533                params.margin_liquidator,
534            )
535            .await?,
536        ),
537        None => None,
538    };
539
540    // Registries upgraded in place start with the trial creator unset (the
541    // configurable seed only applies on the first initialize), so the deploy
542    // writes it explicitly when provided. Must run before ownership transfer.
543    if let Some(trial_creator) = params.trial_creator {
544        let current_creator = trade_account_registry
545            .get_trial_trade_account_creator()
546            .await?;
547        if current_creator != trial_creator {
548            tracing::info!("Setting trial trade account creator to {trial_creator:?}");
549            trade_account_registry
550                .set_trial_trade_account_creator(trial_creator)
551                .await?;
552        }
553    }
554
555    transfer_ownership(
556        &wallet,
557        &params,
558        &order_book_registry,
559        &trade_account_registry,
560        &trade_account_oracle_deploy,
561        trial_trade_account_oracle_id,
562        order_book_blacklist_id,
563        order_book_whitelist_id,
564    )
565    .await?;
566
567    let deploy_result = MarketsConfigOutput {
568        starting_height: starting_height.into(),
569        trade_account_registry_id,
570        trade_account_registry_blob_id,
571        trade_account_proxy,
572        trade_account_blob_id,
573        trade_account_root: ContractId::from(trade_account_root.0),
574        trade_account_oracle_id,
575        trial_trade_account_oracle_id,
576        order_book_whitelist_id,
577        order_book_blacklist_id,
578        order_book_registry_id,
579        order_book_registry_blob_id,
580        pairs,
581        fast_bridge_asset_registry_proxy_id,
582        price_feed_id: margin_ids.map(|ids| ids.price_feed_id),
583        margin_pool_id: margin_ids.map(|ids| ids.margin_pool_id),
584        margin_oracle_id: margin_ids.and_then(|ids| ids.margin_oracle_id),
585        // Carry the section through, with the resolved ids written back so a
586        // re-run reads them and drops into tier-only mode against the pool it
587        // already deployed. A DRY RUN must not: its ids are predictions for
588        // contracts that were never created, and writing them would send the
589        // next real run into tier-only mode against a pool that does not
590        // exist, skipping the system deploy entirely.
591        margin: markets_config_partial.margin.clone().map(|mut margin| {
592            if let (Some(ids), false) = (margin_ids, margin.dry_run) {
593                margin.price_feed_id = Some(ids.price_feed_id);
594                margin.margin_pool_id = Some(ids.margin_pool_id);
595            }
596            margin
597        }),
598    };
599
600    if let Some(output_path) = params.output {
601        let json = serde_json::to_string_pretty(&deploy_result)?;
602        tracing::info!("Deploy result saved to {}", output_path);
603        std::fs::write(output_path, json)?;
604    }
605
606    Ok(deploy_result)
607}
608
609// ---------------------------------------------------------------------------
610// Margin (prop) deployment
611// ---------------------------------------------------------------------------
612
613/// The margin stack's resolved ids, echoed on the deploy output.
614#[derive(Debug, Clone, Copy)]
615pub struct MarginIds {
616    pub price_feed_id: ContractId,
617    pub margin_pool_id: ContractId,
618    pub margin_oracle_id: Option<ContractId>,
619}
620
621/// The margin phase of [`deploy`]: the prop system deployed/resumed
622/// deterministically (salted ids, exists-checked steps), the SHARED
623/// trade-account registry upgraded IN PLACE to carry the prop
624/// configurables, and the tier catalogue reconciled. With
625/// `margin.dry_run` everything is reported and nothing is sent; with
626/// `margin.margin_pool_id` the system deploy is skipped and only the
627/// feed/price/tier reconciliation runs against the given pool.
628#[allow(clippy::too_many_arguments)]
629async fn deploy_margin<W>(
630    wallet: &W,
631    margin: &MarginConfig,
632    salt: fuels::types::Salt,
633    trade_account_registry_id: ContractId,
634    trade_account_oracle_id: ContractId,
635    trial_trade_account_oracle_id: ContractId,
636    pairs: &[OrderBookConfig],
637    margin_cosigner: Option<fuels::types::Address>,
638    margin_liquidator: Option<Identity>,
639) -> anyhow::Result<MarginIds>
640where
641    W: Account + ViewOnlyAccount + Clone + 'static,
642{
643    use o2_tools::prop::{
644        PropAccountOracleContract,
645        PropMarginPoolContract,
646        PropPriceFeedMockContract,
647    };
648
649    let collateral_asset = match margin.collateral_asset {
650        Some(collateral_asset) => collateral_asset,
651        None => {
652            pairs
653                .first()
654                .context(
655                    "margin.collateral_asset is not set and there are no pairs to \
656                     default it from",
657                )?
658                .quote
659                .asset
660        }
661    };
662    // The cosigner and the liquidator arrive as DEPLOY PARAMETERS, not from
663    // the config file: both name a key or a payee that belongs to the
664    // operator running the deploy, and both are opt-in - provide one and it
665    // is reconciled, omit it and whatever is live stands.
666    let cosigner = margin_cosigner;
667    let platform_payout = margin
668        .platform_payout
669        .as_deref()
670        .map(parse_margin_identity)
671        .transpose()?;
672    let liquidator = margin_liquidator;
673
674    let mut prop_config = PropDeployConfig::new(collateral_asset);
675    // No default: a wrong decimals figure misprices every valuation the pool
676    // makes, and it is invisible until money moves.
677    prop_config.collateral_decimals = margin.collateral_decimals.context(
678        "margin.collateral_decimals is required - set it to the collateral \
679         asset's decimals in the deploy config",
680    )?;
681    prop_config.max_tier_books = margin.max_tier_books.unwrap_or(80);
682    prop_config.cosigner = cosigner;
683    prop_config.platform_payout = platform_payout;
684    prop_config.liquidator = liquidator;
685    prop_config.salt = salt;
686    prop_config.existing_price_feed = margin.price_feed_id;
687    prop_config.existing_registry = Some(ExistingRegistry {
688        registry_id: trade_account_registry_id,
689        trade_account_oracle_id,
690        trial_trade_account_oracle_id,
691    });
692
693    // Resolve the system: tier-only mode / dry run / real deploy-or-resume.
694    let (price_feed_id, margin_pool_id, margin_oracle_id, mutate) = if let Some(
695        margin_pool_id,
696    ) =
697        margin.margin_pool_id
698    {
699        tracing::info!("Margin: tier-only mode against pool {margin_pool_id}");
700        let price_feed_id = match margin.price_feed_id {
701            Some(price_feed_id) => price_feed_id,
702            None => {
703                PropMarginPoolContract::new(margin_pool_id, wallet.clone())
704                    .methods()
705                    .price_feed()
706                    .simulate(Execution::state_read_only())
707                    .await
708                    .context("read the pool's price feed")?
709                    .value
710            }
711        };
712        (price_feed_id, margin_pool_id, None, !margin.dry_run)
713    } else if margin.dry_run {
714        let report = PropDeployment::verify(wallet, &prop_config).await?;
715        tracing::info!(
716            "Margin DRY-RUN: system {} (oracle {}, pool {}, feed {}, registry {})",
717            if report.up_to_date {
718                "up to date — a real run would send no system transaction"
719            } else {
720                "NOT up to date — see the [prop verify] lines above"
721            },
722            report.oracle_id,
723            report.pool_id,
724            report.price_feed_id,
725            report.registry_id,
726        );
727        (
728            report.price_feed_id,
729            report.pool_id,
730            Some(report.oracle_id),
731            false,
732        )
733    } else {
734        let deployment = deploy_prop_system(wallet, &prop_config).await?;
735        tracing::info!(
736            "Margin: oracle {}, pool {}, feed {} (shared registry {} upgraded in place)",
737            deployment.oracle_id,
738            deployment.pool_id,
739            deployment.price_feed_id,
740            deployment.registry_id,
741        );
742        (
743            deployment.price_feed_id,
744            deployment.pool_id,
745            Some(deployment.oracle_id),
746            true,
747        )
748    };
749
750    // -- the payout parties ----------------------------------------------
751    // Reconciled compare-first against the LIVE pool, so a re-run is a
752    // no-op and an absent config key never overwrites what the pool has.
753    // A pool not deployed yet gets both values through its INITIAL_*
754    // configurables on the real run.
755    let pool_deployed = wallet
756        .try_provider()?
757        .contract_exists(&margin_pool_id)
758        .await?;
759    if pool_deployed {
760        let pool = PropMarginPoolContract::new(margin_pool_id, wallet.clone());
761        if let Some(platform_payout) = platform_payout {
762            let current = pool
763                .methods()
764                .platform_payout()
765                .simulate(Execution::state_read_only())
766                .await
767                .context("read the pool's platform payout")?
768                .value;
769            if current != platform_payout {
770                if mutate {
771                    tracing::info!(
772                        "Margin: platform payout {current:?} -> {platform_payout:?}"
773                    );
774                    pool.methods()
775                        .set_platform_payout(platform_payout)
776                        .call()
777                        .await?;
778                } else {
779                    tracing::info!(
780                        "Margin DRY-RUN: would set platform payout \
781                         {current:?} -> {platform_payout:?}"
782                    );
783                }
784            }
785        }
786        if let Some(liquidator) = liquidator {
787            let current = pool
788                .methods()
789                .liquidator()
790                .simulate(Execution::state_read_only())
791                .await
792                .context("read the pool's liquidator")?
793                .value;
794            if current != liquidator {
795                if mutate {
796                    tracing::info!("Margin: liquidator {current:?} -> {liquidator:?}");
797                    pool.methods().set_liquidator(liquidator).call().await?;
798                } else {
799                    tracing::info!(
800                        "Margin DRY-RUN: would set liquidator \
801                         {current:?} -> {liquidator:?}"
802                    );
803                }
804            }
805        }
806    }
807
808    // Tier-only mode resolves no oracle of its own - ask the live registry,
809    // which is the same lookup the backend performs at boot.
810    let margin_oracle_id = match margin_oracle_id {
811        Some(oracle_id) => Some(oracle_id),
812        None if cosigner.is_some() => {
813            let registry = o2_tools::trade_account_registry::TradeAccountRegistry::new(
814                trade_account_registry_id,
815                wallet.clone(),
816            );
817            let oracle_id = registry
818                .methods()
819                .get_prop_oracle_id()
820                .simulate(Execution::state_read_only())
821                .await
822                .context("read the registry's prop account oracle")?
823                .value;
824            (oracle_id != ContractId::zeroed()).then_some(oracle_id)
825        }
826        None => None,
827    };
828
829    // The cosigner lives on the ORACLE, not the pool, and only reaches
830    // storage through INITIAL_COSIGNER on the very first initialize - so an
831    // already-initialized oracle needs this explicit reconciliation, or
832    // "set it if provided" would silently mean "only on a fresh deploy".
833    if let (Some(cosigner), Some(oracle_id)) = (cosigner, margin_oracle_id)
834        && wallet.try_provider()?.contract_exists(&oracle_id).await?
835    {
836        let oracle = PropAccountOracleContract::new(oracle_id, wallet.clone());
837        let current = oracle
838            .methods()
839            .get_cosigner()
840            .simulate(Execution::state_read_only())
841            .await
842            .context("read the prop account oracle's cosigner")?
843            .value;
844        if current != Some(cosigner) {
845            if mutate {
846                tracing::info!("Margin: cosigner {current:?} -> {cosigner:?}");
847                oracle.methods().set_cosigner(cosigner).call().await?;
848            } else {
849                tracing::info!(
850                    "Margin DRY-RUN: would set cosigner \
851                     {current:?} -> {cosigner:?}"
852                );
853            }
854        }
855    }
856
857    // -- publishers + initial prices ------------------------------------
858    let price_feed = PropPriceFeedMockContract::new(price_feed_id, wallet.clone());
859    if mutate {
860        for publisher in &margin.publishers {
861            let publisher = parse_margin_identity(publisher)?;
862            tracing::info!("Margin: adding price feed publisher {publisher:?}");
863            price_feed.methods().add_publisher(publisher).call().await?;
864        }
865    } else if !margin.publishers.is_empty() {
866        tracing::info!(
867            "Margin DRY-RUN: would add {} price feed publisher(s)",
868            margin.publishers.len()
869        );
870    }
871    for initial_price in &margin.initial_prices {
872        let has_price = price_feed
873            .methods()
874            .has_price(initial_price.asset)
875            .simulate(Execution::state_read_only())
876            .await?
877            .value;
878        if has_price {
879            continue;
880        }
881        if !mutate {
882            tracing::info!(
883                "Margin DRY-RUN: would publish an initial price for {}",
884                initial_price.asset
885            );
886            continue;
887        }
888        let decimals = price_feed
889            .methods()
890            .get_asset_decimals(initial_price.asset)
891            .simulate(Execution::state_read_only())
892            .await?
893            .value;
894        if decimals.is_none() {
895            price_feed
896                .methods()
897                .set_asset_decimals(initial_price.asset, initial_price.asset_decimals)
898                .call()
899                .await?;
900        }
901        let bid: u128 = initial_price
902            .bid
903            .parse()
904            .map_err(|e| anyhow::anyhow!("invalid initial price bid: {e}"))?;
905        let ask: u128 = initial_price
906            .ask
907            .parse()
908            .map_err(|e| anyhow::anyhow!("invalid initial price ask: {e}"))?;
909        tracing::info!(
910            "Margin: publishing initial price for {}",
911            initial_price.asset
912        );
913        price_feed
914            .methods()
915            .publish_prices(vec![o2_tools::prop::PriceInput {
916                asset: initial_price.asset,
917                bid,
918                ask,
919                timestamp: std::time::SystemTime::now()
920                    .duration_since(std::time::UNIX_EPOCH)?
921                    .as_secs(),
922            }])
923            .call()
924            .await?;
925    }
926
927    // -- the tier catalogue ---------------------------------------------
928    let pool = PropMarginPoolContract::new(margin_pool_id, wallet.clone());
929    let pool_reachable = wallet
930        .try_provider()?
931        .contract_exists(&margin_pool_id)
932        .await?;
933    if pool_reachable {
934        reconcile_margin_tiers(&pool, price_feed_id, pairs, &margin.tiers, !mutate)
935            .await?;
936    } else if !margin.tiers.is_empty() {
937        tracing::info!(
938            "Margin DRY-RUN: pool not deployed yet — all {} tier(s) would publish \
939             their first version",
940            margin.tiers.len()
941        );
942    }
943
944    Ok(MarginIds {
945        price_feed_id,
946        margin_pool_id,
947        margin_oracle_id,
948    })
949}
950
951/// The tier engine: reconcile the DECLARED catalogue against the LIVE
952/// pool, idempotently.
953///
954/// - no live version              -> publish version 1;
955/// - params differ from the live  -> publish a NEW version;
956/// - params equal, books grew     -> `add_books` with the missing books;
957/// - params equal, books covered  -> untouched (books are append-only:
958///   a SHRUNK declared list cannot be enacted and is reported).
959async fn reconcile_margin_tiers<W>(
960    pool: &o2_tools::prop::PropMarginPoolContract<W>,
961    price_feed_id: ContractId,
962    pairs: &[OrderBookConfig],
963    tiers: &[MarginTierConfig],
964    dry_run: bool,
965) -> anyhow::Result<()>
966where
967    W: Account + ViewOnlyAccount + Clone + 'static,
968{
969    for tier in tiers {
970        let books = tier
971            .markets
972            .iter()
973            .map(|market| resolve_tier_market(market, pairs))
974            .collect::<anyhow::Result<Vec<_>>>()?;
975        let params = tier_params(tier);
976        let current_version = pool
977            .methods()
978            .current_tier_version(tier.tier_id)
979            .simulate(Execution::state_read_only())
980            .await?
981            .value;
982
983        let mut contract_ids = vec![price_feed_id];
984        contract_ids.extend(books.iter().copied());
985
986        let Some(version) = current_version else {
987            if dry_run {
988                tracing::info!(
989                    "Margin DRY-RUN: tier {} — would publish its FIRST version \
990                     ({} book(s))",
991                    tier.tier_id,
992                    books.len()
993                );
994                continue;
995            }
996            let version = pool
997                .methods()
998                .publish_tier_version(tier.tier_id, params, books)
999                .with_contract_ids(&contract_ids)
1000                .call()
1001                .await?
1002                .value;
1003            tracing::info!("Margin: tier {} published version {version}", tier.tier_id);
1004            continue;
1005        };
1006
1007        let live = pool
1008            .methods()
1009            .get_tier(tier.tier_id, version)
1010            .simulate(Execution::state_read_only())
1011            .await?
1012            .value
1013            .with_context(|| {
1014                format!("tier {} version {version} vanished mid-read", tier.tier_id)
1015            })?;
1016        if live != params {
1017            if dry_run {
1018                tracing::info!(
1019                    "Margin DRY-RUN: tier {} — params differ from live version \
1020                     {version}; would publish a NEW version",
1021                    tier.tier_id
1022                );
1023                continue;
1024            }
1025            let new_version = pool
1026                .methods()
1027                .publish_tier_version(tier.tier_id, params, books)
1028                .with_contract_ids(&contract_ids)
1029                .call()
1030                .await?
1031                .value;
1032            tracing::info!(
1033                "Margin: tier {} republished as version {new_version}",
1034                tier.tier_id
1035            );
1036            continue;
1037        }
1038
1039        let live_books = pool
1040            .methods()
1041            .tier_books(tier.tier_id, version)
1042            .simulate(Execution::state_read_only())
1043            .await?
1044            .value;
1045        let missing: Vec<ContractId> = books
1046            .iter()
1047            .copied()
1048            .filter(|book| !live_books.contains(book))
1049            .collect();
1050        let shrunk = live_books
1051            .iter()
1052            .filter(|book| !books.contains(book))
1053            .count();
1054        if shrunk > 0 {
1055            tracing::warn!(
1056                "Margin: tier {} declares {shrunk} fewer book(s) than live version \
1057                 {version}; books are append-only — publish a new version to drop \
1058                 markets",
1059                tier.tier_id
1060            );
1061        }
1062        if missing.is_empty() {
1063            tracing::info!(
1064                "Margin: tier {} version {version} matches the declared catalogue — \
1065                 unchanged",
1066                tier.tier_id
1067            );
1068            continue;
1069        }
1070        if dry_run {
1071            tracing::info!(
1072                "Margin DRY-RUN: tier {} — would add {} book(s) to version {version}",
1073                tier.tier_id,
1074                missing.len()
1075            );
1076            continue;
1077        }
1078        let mut add_contract_ids = vec![price_feed_id];
1079        add_contract_ids.extend(missing.iter().copied());
1080        pool.methods()
1081            .add_books(tier.tier_id, missing.clone())
1082            .with_contract_ids(&add_contract_ids)
1083            .call()
1084            .await?;
1085        tracing::info!(
1086            "Margin: tier {} version {version} gained {} book(s)",
1087            tier.tier_id,
1088            missing.len()
1089        );
1090    }
1091    Ok(())
1092}
1093
1094fn tier_params(tier: &MarginTierConfig) -> o2_tools::prop::TierParams {
1095    o2_tools::prop::TierParams {
1096        line: tier.line,
1097        leverage: tier.leverage,
1098        duration: tier.duration,
1099        maintenance_bps: tier.maintenance_bps,
1100        open_buffer_bps: tier.open_buffer_bps,
1101        liq_price_factor: tier.liq_price_factor,
1102        prolong_fee_bps: tier.prolong_fee_bps,
1103        max_credit_line_bps: tier.max_credit_line_bps,
1104        max_price_age: tier.max_price_age,
1105        open_fee_bps: tier.open_fee_bps,
1106        profit_share_bps: tier.profit_share_bps,
1107        price_band_bps: tier.price_band_bps,
1108    }
1109}
1110
1111/// Resolve a tier `markets` entry — a "BASE/QUOTE" symbol pair or a hex
1112/// market id — to the deployed order book's contract id.
1113fn resolve_tier_market(
1114    market: &str,
1115    pairs: &[OrderBookConfig],
1116) -> anyhow::Result<ContractId> {
1117    let wanted = market.trim();
1118    let wanted_id = wanted
1119        .strip_prefix("0x")
1120        .unwrap_or(wanted)
1121        .to_ascii_lowercase();
1122    let pair = pairs
1123        .iter()
1124        .find(|pair| {
1125            let symbol = format!("{}/{}", pair.base.symbol, pair.quote.symbol);
1126            symbol.eq_ignore_ascii_case(wanted)
1127                || hex::encode(*pair.market_id) == wanted_id
1128        })
1129        .with_context(|| format!("margin tier references unknown market `{market}`"))?;
1130    pair.contract_id.with_context(|| {
1131        format!("margin tier market `{market}` has no deployed order book")
1132    })
1133}
1134
1135/// `address:0x..` or `contract:0x..` into an `Identity`.
1136fn parse_margin_identity(s: &str) -> anyhow::Result<Identity> {
1137    if let Some(hex_part) = s.strip_prefix("address:") {
1138        Ok(Identity::Address(fuels::types::Address::new(
1139            parse_margin_bytes32(hex_part)?,
1140        )))
1141    } else if let Some(hex_part) = s.strip_prefix("contract:") {
1142        Ok(Identity::ContractId(ContractId::new(parse_margin_bytes32(
1143            hex_part,
1144        )?)))
1145    } else {
1146        anyhow::bail!("expected `address:0x..` or `contract:0x..`, got `{s}`")
1147    }
1148}
1149
1150fn parse_margin_bytes32(s: &str) -> anyhow::Result<[u8; 32]> {
1151    let raw = s.trim().strip_prefix("0x").unwrap_or(s.trim());
1152    let bytes = hex::decode(raw)
1153        .map_err(|e| anyhow::anyhow!("expected 32 hex bytes, got `{s}`: {e}"))?;
1154    bytes
1155        .try_into()
1156        .map_err(|_| anyhow::anyhow!("expected 32 hex bytes, got `{s}`"))
1157}
1158
1159// ---------------------------------------------------------------------------
1160// Ownership transfer
1161// ---------------------------------------------------------------------------
1162
1163#[allow(clippy::too_many_arguments)]
1164async fn transfer_ownership<W>(
1165    wallet: &W,
1166    params: &DeployParams,
1167    order_book_registry: &OrderBookRegistryManager<W>,
1168    trade_account_registry: &TradeAccountRegistryManager<W>,
1169    trade_account_oracle_deploy: &TradeAccountDeploy<W>,
1170    trial_trade_account_oracle_id: ContractId,
1171    order_book_blacklist_id: Option<ContractId>,
1172    order_book_whitelist_id: Option<ContractId>,
1173) -> anyhow::Result<()>
1174where
1175    W: Account + ViewOnlyAccount + Clone + 'static,
1176{
1177    if let Some(new_proxy_owner) = params.new_proxy_owner {
1178        let new_identity = Identity::Address(new_proxy_owner);
1179        tracing::info!(
1180            "Transferring OrderBookRegistry proxy ownership to {}",
1181            new_proxy_owner
1182        );
1183        order_book_registry
1184            .registry_proxy
1185            .methods()
1186            .set_owner(new_identity)
1187            .call()
1188            .await?;
1189        tracing::info!(
1190            "Transferring TradeAccountRegistry proxy ownership to {}",
1191            new_proxy_owner
1192        );
1193        trade_account_registry
1194            .registry_proxy
1195            .methods()
1196            .set_owner(new_identity)
1197            .call()
1198            .await?;
1199    }
1200
1201    if let Some(new_contract_owner) = params.new_contract_owner {
1202        let new_identity = Identity::Address(new_contract_owner);
1203        tracing::info!(
1204            "Transferring TradeAccountOracle ownership to {}",
1205            new_contract_owner
1206        );
1207        trade_account_oracle_deploy
1208            .oracle
1209            .methods()
1210            .transfer_ownership(new_identity)
1211            .call()
1212            .await?;
1213        tracing::info!(
1214            "Transferring TrialTradeAccountOracle ownership to {}",
1215            new_contract_owner
1216        );
1217        TrialTradingAccountOracle::new(trial_trade_account_oracle_id, wallet.clone())
1218            .methods()
1219            .transfer_ownership(new_identity)
1220            .call()
1221            .await?;
1222        tracing::info!(
1223            "Transferring TradeAccountRegistry ownership to {}",
1224            new_contract_owner
1225        );
1226        trade_account_registry
1227            .registry
1228            .methods()
1229            .transfer_ownership(new_identity)
1230            .call()
1231            .await?;
1232        tracing::info!(
1233            "Transferring OrderBookRegistry ownership to {}",
1234            new_contract_owner
1235        );
1236        order_book_registry
1237            .registry
1238            .methods()
1239            .transfer_ownership(new_identity)
1240            .call()
1241            .await?;
1242        if let Some(blacklist_id) = order_book_blacklist_id {
1243            tracing::info!(
1244                "Transferring OrderBookBlacklist ownership to {}",
1245                new_contract_owner
1246            );
1247            OrderBookBlacklist::new(blacklist_id, wallet.clone())
1248                .methods()
1249                .transfer_ownership(new_identity)
1250                .call()
1251                .await?;
1252        }
1253        if let Some(whitelist_id) = order_book_whitelist_id {
1254            tracing::info!(
1255                "Transferring OrderBookWhitelist ownership to {}",
1256                new_contract_owner
1257            );
1258            OrderBookWhitelist::new(whitelist_id, wallet.clone())
1259                .methods()
1260                .transfer_ownership(new_identity)
1261                .call()
1262                .await?;
1263        }
1264    }
1265
1266    Ok(())
1267}
1268
1269// ---------------------------------------------------------------------------
1270// Internal deploy helpers
1271// ---------------------------------------------------------------------------
1272
1273async fn deploy_order_book_blacklist<W>(
1274    deployer_wallet: W,
1275    deploy_blacklist: bool,
1276    order_book_blacklist_id: Option<ContractId>,
1277    salt: Salt,
1278) -> anyhow::Result<Option<ContractId>>
1279where
1280    W: Account + ViewOnlyAccount + Clone + 'static,
1281{
1282    match order_book_blacklist_id {
1283        Some(order_book_blacklist_id) => {
1284            tracing::info!(
1285                "Using existing OrderBookBlacklist: {}",
1286                order_book_blacklist_id
1287            );
1288            Ok(Some(order_book_blacklist_id))
1289        }
1290        None => {
1291            if !deploy_blacklist {
1292                return Ok(None);
1293            }
1294            tracing::info!("Deploying OrderBookBlacklist");
1295            let order_book_blacklist = OrderBookDeploy::deploy_order_book_blacklist(
1296                &deployer_wallet,
1297                &Identity::Address(ViewOnlyAccount::address(&deployer_wallet)),
1298                &OrderBookDeployConfig {
1299                    salt,
1300                    ..Default::default()
1301                },
1302            )
1303            .await?;
1304            tracing::info!("OrderBookBlacklist: {}", order_book_blacklist.contract_id());
1305            Ok(Some(order_book_blacklist.contract_id()))
1306        }
1307    }
1308}
1309
1310async fn deploy_order_book_whitelist<W>(
1311    deployer_wallet: W,
1312    deploy_whitelist: bool,
1313    order_book_whitelist_id: Option<ContractId>,
1314    salt: Salt,
1315) -> anyhow::Result<Option<ContractId>>
1316where
1317    W: Account + ViewOnlyAccount + Clone + 'static,
1318{
1319    match (order_book_whitelist_id, deploy_whitelist) {
1320        (Some(order_book_whitelist_id), false)
1321        | (Some(order_book_whitelist_id), true) => {
1322            tracing::info!(
1323                "Using existing OrderBookWhitelist: {}",
1324                order_book_whitelist_id
1325            );
1326            Ok(Some(order_book_whitelist_id))
1327        }
1328        (None, false) => Ok(None),
1329        (None, true) => {
1330            tracing::info!("Deploying OrderBookWhitelist");
1331            let trade_account_whitelist = OrderBookDeploy::deploy_order_book_whitelist(
1332                &deployer_wallet,
1333                &Identity::Address(ViewOnlyAccount::address(&deployer_wallet)),
1334                &OrderBookDeployConfig {
1335                    salt,
1336                    ..Default::default()
1337                },
1338            )
1339            .await?;
1340            tracing::info!(
1341                "OrderBookWhitelist: {}",
1342                trade_account_whitelist.contract_id()
1343            );
1344            Ok(Some(trade_account_whitelist.contract_id()))
1345        }
1346    }
1347}
1348
1349/// Load an existing oracle and recover from partial deployment if needed.
1350/// Unlike `TradeAccountDeploy::from_oracle_id`, this does not error when
1351/// the trade account implementation is missing — it deploys and sets it.
1352async fn load_or_recover_trade_account_oracle<W>(
1353    deployer_wallet: &W,
1354    oracle_id: ContractId,
1355) -> anyhow::Result<(TradeAccountDeploy<W>, ContractId)>
1356where
1357    W: Account + ViewOnlyAccount + Clone + 'static,
1358{
1359    let oracle = TradingAccountOracle::new(oracle_id, deployer_wallet.clone());
1360    let impl_id = oracle
1361        .methods()
1362        .get_trade_account_impl()
1363        .simulate(Execution::state_read_only())
1364        .await?
1365        .value;
1366
1367    let blob_id = match impl_id {
1368        Some(id) => id,
1369        None => {
1370            tracing::info!(
1371                "Trade account implementation not set on oracle {}, deploying...",
1372                oracle_id
1373            );
1374            let blob = TradeAccountDeploy::trade_account_blob(
1375                deployer_wallet,
1376                &Default::default(),
1377            )
1378            .await?;
1379            TradeAccountDeploy::deploy_trade_account_blob(
1380                deployer_wallet,
1381                &DeployConfig::Latest(Default::default()),
1382            )
1383            .await?;
1384            oracle
1385                .methods()
1386                .set_trade_account_impl(ContractId::from(blob.id))
1387                .call()
1388                .await?;
1389            ContractId::from(blob.id)
1390        }
1391    };
1392
1393    let deploy = TradeAccountDeploy {
1394        oracle,
1395        oracle_id,
1396        trade_account_blob_id: blob_id.into(),
1397        deployer_wallet: deployer_wallet.clone(),
1398        proxy: None,
1399        proxy_id: None,
1400    };
1401    Ok((deploy, blob_id))
1402}
1403
1404async fn deploy_trade_account_oracle<W>(
1405    deployer_wallet: W,
1406    should_upgrade_bytecode: bool,
1407    trade_account_oracle_id: Option<ContractId>,
1408    salt: Salt,
1409) -> anyhow::Result<(TradeAccountDeploy<W>, ContractId)>
1410where
1411    W: Account + ViewOnlyAccount + Clone + 'static,
1412{
1413    let (trade_account_oracle_deploy, mut trade_account_blob_id) =
1414        match trade_account_oracle_id {
1415            Some(oracle_id) => {
1416                load_or_recover_trade_account_oracle(&deployer_wallet, oracle_id).await?
1417            }
1418            None => {
1419                let deploy = TradeAccountDeploy::deploy(
1420                    &deployer_wallet,
1421                    &DeployConfig::Latest(TradeAccountDeployConfig {
1422                        salt,
1423                        ..Default::default()
1424                    }),
1425                )
1426                .await?;
1427                let blob_id = deploy
1428                    .oracle
1429                    .methods()
1430                    .get_trade_account_impl()
1431                    .simulate(Execution::state_read_only())
1432                    .await?
1433                    .value
1434                    .context("Trade account impl should exist after fresh deploy")?;
1435                (deploy, blob_id)
1436            }
1437        };
1438    tracing::info!(
1439        "TradeAccountOracle: {}",
1440        trade_account_oracle_deploy.oracle_id
1441    );
1442
1443    if should_upgrade_bytecode {
1444        let trade_account_blob =
1445            TradeAccountDeploy::trade_account_blob(&deployer_wallet, &Default::default())
1446                .await?;
1447        if ContractId::from(trade_account_blob.id) != trade_account_blob_id {
1448            tracing::info!(
1449                "Update TradeAccountImpl on Oracle from {:?} to new blob {:?}",
1450                trade_account_blob_id,
1451                ContractId::from(trade_account_blob.id)
1452            );
1453            TradeAccountDeploy::deploy_trade_account_blob(
1454                &deployer_wallet,
1455                &DeployConfig::Latest(Default::default()),
1456            )
1457            .await?;
1458            trade_account_oracle_deploy
1459                .oracle
1460                .methods()
1461                .set_trade_account_impl(ContractId::from(trade_account_blob.id))
1462                .call()
1463                .await?;
1464            trade_account_blob_id = ContractId::from(trade_account_blob.id);
1465        }
1466    }
1467
1468    Ok((trade_account_oracle_deploy, trade_account_blob_id))
1469}
1470
1471/// Deploy (or load) the dedicated trial trade account oracle and keep its
1472/// implementation current. The implementation follows the trade-account
1473/// implementation's lifecycle: seeded when the oracle has none, upgraded
1474/// under `should_upgrade_bytecode`. The cosigner is opt-in: providing one
1475/// also (re)deploys the implementation and configures the cosigner; without
1476/// one, the configured cosigner is left untouched. This runs before
1477/// ownership transfer since it writes to the oracle.
1478async fn deploy_trial_trade_account_oracle<W>(
1479    deployer_wallet: W,
1480    should_upgrade_bytecode: bool,
1481    trial_trade_account_oracle_id: Option<ContractId>,
1482    trial_cosigner: Option<fuels::types::Address>,
1483    salt: Salt,
1484) -> anyhow::Result<ContractId>
1485where
1486    W: Account + ViewOnlyAccount + Clone + 'static,
1487{
1488    let mut trial_deploy_config = TrialTradeAccountDeployConfig {
1489        salt,
1490        ..Default::default()
1491    };
1492    if let Some(cosigner) = trial_cosigner {
1493        trial_deploy_config = trial_deploy_config.with_cosigner(cosigner);
1494    }
1495    let deploy_config = TrialDeployConfig::Latest(trial_deploy_config);
1496
1497    let oracle_id = match trial_trade_account_oracle_id {
1498        None => {
1499            let trial_deploy =
1500                TrialTradeAccountDeploy::deploy(&deployer_wallet, &deploy_config).await?;
1501            tracing::info!(
1502                "TrialTradeAccountOracle: {} (implementation {:?}, cosigner {:?})",
1503                trial_deploy.oracle_id,
1504                trial_deploy.trial_trade_account_blob_id,
1505                trial_cosigner,
1506            );
1507            trial_deploy.oracle_id
1508        }
1509        Some(oracle_id) => {
1510            let current_trial_impl =
1511                TrialTradingAccountOracle::new(oracle_id, deployer_wallet.clone())
1512                    .methods()
1513                    .get_trial_account_impl()
1514                    .simulate(Execution::state_read_only())
1515                    .await?
1516                    .value;
1517            if current_trial_impl.is_none()
1518                || should_upgrade_bytecode
1519                || trial_cosigner.is_some()
1520            {
1521                let trial_deploy = TrialTradeAccountDeploy::deploy_to_oracle(
1522                    &deployer_wallet,
1523                    oracle_id,
1524                    &deploy_config,
1525                )
1526                .await?;
1527                tracing::info!(
1528                    "Trial implementation {:?} deployed to oracle {} (cosigner {:?})",
1529                    trial_deploy.trial_trade_account_blob_id,
1530                    oracle_id,
1531                    trial_cosigner,
1532                );
1533            }
1534            oracle_id
1535        }
1536    };
1537
1538    Ok(oracle_id)
1539}
1540
1541async fn deploy_trade_account_registry<W>(
1542    deployer_wallet: W,
1543    should_upgrade_bytecode: bool,
1544    trade_account_deploy: TradeAccountDeploy<W>,
1545    trial_trade_account_oracle_id: ContractId,
1546    trade_account_registry_id: Option<ContractId>,
1547    salt: Salt,
1548) -> anyhow::Result<(TradeAccountRegistryManager<W>, ContractId)>
1549where
1550    W: Account + ViewOnlyAccount + Clone + 'static,
1551{
1552    let trade_account_oracle_id = trade_account_deploy.oracle_id;
1553    let trade_account_registry = match trade_account_registry_id {
1554        Some(trade_account_registry_contract_id) => TradeAccountRegistryManager::new(
1555            deployer_wallet.clone(),
1556            trade_account_registry_contract_id,
1557        ),
1558        None => {
1559            let trade_account_registry_deploy_config = TradeAccountRegistryDeployConfig {
1560                salt,
1561                ..Default::default()
1562            };
1563            TradeAccountRegistryManager::deploy(
1564                &deployer_wallet,
1565                trade_account_oracle_id,
1566                trial_trade_account_oracle_id,
1567                &trade_account_registry_deploy_config,
1568            )
1569            .await?
1570        }
1571    };
1572    tracing::info!(
1573        "TradeAccountRegistry: {}",
1574        trade_account_registry.contract_id
1575    );
1576    let mut trade_account_registry_blob_id = match trade_account_registry
1577        .registry_proxy
1578        .methods()
1579        .proxy_target()
1580        .simulate(Execution::state_read_only())
1581        .await?
1582        .value
1583    {
1584        Some(blob_id) => blob_id,
1585        None => {
1586            tracing::info!("TradeAccountRegistry proxy target not set, initializing...");
1587            // Call initialize_proxy() to write INITIAL_OWNER and INITIAL_TARGET
1588            // from configurables to storage (upgrade/set_proxy_target would fail
1589            // because the owner is not yet in storage)
1590            trade_account_registry
1591                .registry_proxy
1592                .methods()
1593                .initialize_proxy()
1594                .call()
1595                .await?;
1596            trade_account_registry
1597                .registry
1598                .methods()
1599                .initialize()
1600                .call()
1601                .await?;
1602            trade_account_registry
1603                .registry_proxy
1604                .methods()
1605                .proxy_target()
1606                .simulate(Execution::state_read_only())
1607                .await?
1608                .value
1609                .context("TradeAccountRegistry proxy target should be set after initialization")?
1610        }
1611    };
1612
1613    if should_upgrade_bytecode {
1614        let trade_account_registry_deploy_config =
1615            TradeAccountRegistryDeployConfig::default();
1616        let trade_account_proxy_blob = TradeAccountRegistryManager::register_proxy_blob(
1617            &deployer_wallet,
1618            &trade_account_registry_deploy_config,
1619        )
1620        .await?;
1621        let trial_trade_account_proxy_blob =
1622            TradeAccountRegistryManager::register_trial_proxy_blob(
1623                &deployer_wallet,
1624                &trade_account_registry_deploy_config,
1625            )
1626            .await?;
1627
1628        let trade_account_register_blob = TradeAccountRegistryManager::register_blob(
1629            &deployer_wallet,
1630            trade_account_oracle_id,
1631            trial_trade_account_oracle_id,
1632            trade_account_proxy_blob.id,
1633            trial_trade_account_proxy_blob.id,
1634            &trade_account_registry_deploy_config,
1635        )
1636        .await?;
1637
1638        if trade_account_registry_blob_id
1639            != ContractId::from(trade_account_register_blob.id)
1640        {
1641            tracing::info!(
1642                "Upgrade TradeAccountRegistry blob from {:?} to {:?}",
1643                trade_account_registry.contract_id,
1644                ContractId::from(trade_account_register_blob.id)
1645            );
1646            trade_account_registry
1647                .upgrade(
1648                    trade_account_oracle_id,
1649                    trial_trade_account_oracle_id,
1650                    &TradeAccountRegistryDeployConfig::default(),
1651                )
1652                .await?;
1653            trade_account_registry_blob_id = trade_account_register_blob.id.into();
1654        }
1655    }
1656    Ok((trade_account_registry, trade_account_registry_blob_id))
1657}
1658
1659async fn deploy_order_book_registry<W>(
1660    deployer_wallet: W,
1661    should_upgrade_bytecode: bool,
1662    order_book_registry_id: Option<ContractId>,
1663    salt: Salt,
1664) -> anyhow::Result<(OrderBookRegistryManager<W>, ContractId)>
1665where
1666    W: Account + ViewOnlyAccount + Clone + 'static,
1667{
1668    let order_book_registry = match order_book_registry_id {
1669        Some(registry_contract_id) => {
1670            OrderBookRegistryManager::new(deployer_wallet.clone(), registry_contract_id)
1671        }
1672        None => {
1673            OrderBookRegistryManager::deploy(
1674                &deployer_wallet,
1675                &OrderBookRegistryDeployConfig {
1676                    salt,
1677                    ..Default::default()
1678                },
1679            )
1680            .await?
1681        }
1682    };
1683    tracing::info!("OrderBookRegistry: {}", order_book_registry.contract_id);
1684    let mut order_book_registry_blob_id = match order_book_registry
1685        .registry_proxy
1686        .methods()
1687        .proxy_target()
1688        .simulate(Execution::state_read_only())
1689        .await?
1690        .value
1691    {
1692        Some(blob_id) => blob_id,
1693        None => {
1694            tracing::info!("OrderBookRegistry proxy target not set, initializing...");
1695            // Call initialize_proxy() to write INITIAL_OWNER and INITIAL_TARGET
1696            // from configurables to storage (upgrade/set_proxy_target would fail
1697            // because the owner is not yet in storage)
1698            order_book_registry
1699                .registry_proxy
1700                .methods()
1701                .initialize_proxy()
1702                .call()
1703                .await?;
1704            order_book_registry
1705                .registry
1706                .methods()
1707                .initialize()
1708                .call()
1709                .await?;
1710            order_book_registry
1711                .registry_proxy
1712                .methods()
1713                .proxy_target()
1714                .simulate(Execution::state_read_only())
1715                .await?
1716                .value
1717                .context(
1718                    "OrderBookRegistry proxy target should be set after initialization",
1719                )?
1720        }
1721    };
1722
1723    if should_upgrade_bytecode {
1724        let order_book_register_deploy_config = OrderBookRegistryDeployConfig::default();
1725        let order_book_register_blob = OrderBookRegistryManager::register_blob(
1726            &deployer_wallet,
1727            &order_book_register_deploy_config,
1728        )
1729        .await?;
1730        if order_book_registry_blob_id != order_book_register_blob.id.into() {
1731            tracing::info!(
1732                "Upgrade OrderBookRegistry blob from {:?} to {:?}",
1733                order_book_registry.contract_id,
1734                ContractId::from(order_book_register_blob.id)
1735            );
1736            order_book_registry
1737                .upgrade(&order_book_register_deploy_config)
1738                .await?;
1739            order_book_registry_blob_id = order_book_register_blob.id.into();
1740        }
1741    }
1742
1743    Ok((order_book_registry, order_book_registry_blob_id))
1744}
1745
1746async fn deploy_order_books<W>(
1747    deployer_wallet: W,
1748    should_upgrade_bytecode: bool,
1749    order_book_blacklist_id: Option<ContractId>,
1750    order_book_whitelist_id: Option<ContractId>,
1751    order_book_registry: OrderBookRegistryManager<W>,
1752    order_book_configs: &mut [OrderBookConfig],
1753    ownership_options: OwnershipTransferOptions,
1754) -> anyhow::Result<Vec<OrderBookConfig>>
1755where
1756    W: Account + ViewOnlyAccount + Clone + 'static,
1757{
1758    let mut pairs: Vec<OrderBookConfig> = Vec::with_capacity(order_book_configs.len());
1759
1760    for order_book_config in order_book_configs.iter_mut() {
1761        let pair = deploy_single_order_book(
1762            &deployer_wallet,
1763            should_upgrade_bytecode,
1764            order_book_blacklist_id,
1765            order_book_whitelist_id,
1766            &order_book_registry,
1767            order_book_config,
1768            &ownership_options,
1769        )
1770        .await?;
1771        pairs.push(pair);
1772    }
1773
1774    Ok(pairs)
1775}
1776
1777async fn deploy_single_order_book<W>(
1778    deployer_wallet: &W,
1779    should_upgrade_bytecode: bool,
1780    order_book_blacklist_id: Option<ContractId>,
1781    order_book_whitelist_id: Option<ContractId>,
1782    order_book_registry: &OrderBookRegistryManager<W>,
1783    order_book_config: &mut OrderBookConfig,
1784    ownership_options: &OwnershipTransferOptions,
1785) -> anyhow::Result<OrderBookConfig>
1786where
1787    W: Account + ViewOnlyAccount + Clone + 'static,
1788{
1789    let market_symbol = format!(
1790        "{}/{}",
1791        order_book_config.base.symbol, order_book_config.quote.symbol
1792    );
1793    let market_id = MarketIdAssets {
1794        base_asset: order_book_config.base.asset,
1795        quote_asset: order_book_config.quote.asset,
1796    };
1797    let order_book_configurables = build_order_book_configurables(
1798        order_book_config,
1799        order_book_blacklist_id,
1800        order_book_whitelist_id,
1801        deployer_wallet,
1802    )?;
1803
1804    let order_book = load_or_deploy_order_book(
1805        deployer_wallet,
1806        order_book_registry,
1807        &market_id,
1808        &market_symbol,
1809        &order_book_configurables,
1810        order_book_config,
1811    )
1812    .await?;
1813
1814    tracing::info!(
1815        "[{}] OrderBook: {}",
1816        market_symbol,
1817        order_book.contract.contract_id()
1818    );
1819
1820    let order_book_blob_id = maybe_upgrade_order_book(
1821        deployer_wallet,
1822        should_upgrade_bytecode,
1823        &order_book,
1824        order_book_config,
1825        order_book_configurables,
1826        &market_symbol,
1827    )
1828    .await?;
1829
1830    // Set the maintainer while the deployer is still the owner, i.e. before any
1831    // ownership transfer below (`owner_grant_role`/`owner_revoke_role` are
1832    // `only_owner`).
1833    set_order_book_maintainer(&order_book, ownership_options, &market_symbol).await?;
1834
1835    transfer_order_book_ownership(&order_book, ownership_options, &market_symbol).await?;
1836
1837    order_book_config.contract_id = Some(order_book.contract.contract_id());
1838    order_book_config.blob_id = order_book_blob_id.into();
1839
1840    Ok(order_book_config.clone())
1841}
1842
1843fn build_order_book_configurables<W: ViewOnlyAccount>(
1844    config: &OrderBookConfig,
1845    order_book_blacklist_id: Option<ContractId>,
1846    order_book_whitelist_id: Option<ContractId>,
1847    deployer_wallet: &W,
1848) -> anyhow::Result<OrderBookConfigurables> {
1849    let price_precision = config
1850        .quote
1851        .decimals
1852        .checked_sub(config.quote.max_precision)
1853        .ok_or_else(|| {
1854            anyhow::anyhow!(
1855                "quote max_precision ({}) exceeds decimals ({})",
1856                config.quote.max_precision,
1857                config.quote.decimals
1858            )
1859        })?;
1860    let quantity_precision = config
1861        .base
1862        .decimals
1863        .checked_sub(config.base.max_precision)
1864        .ok_or_else(|| {
1865            anyhow::anyhow!(
1866                "base max_precision ({}) exceeds decimals ({})",
1867                config.base.max_precision,
1868                config.base.decimals
1869            )
1870        })?;
1871
1872    Ok(OrderBookConfigurables::default()
1873        .with_MIN_ORDER(config.min_order)?
1874        .with_ALLOW_FRACTIONAL_PRICE(config.allow_fractional_price)?
1875        .with_TAKER_FEE(config.taker_fee.into())?
1876        .with_MAKER_FEE(config.maker_fee.into())?
1877        .with_DUST(config.dust)?
1878        .with_PRICE_WINDOW(config.price_window as u64)?
1879        .with_BASE_DECIMALS(10u64.pow(config.base.decimals as u32))?
1880        .with_QUOTE_DECIMALS(10u64.pow(config.quote.decimals as u32))?
1881        .with_BASE_SYMBOL(SizedAsciiString::new_with_right_whitespace_padding(
1882            config.base.symbol.clone(),
1883        )?)?
1884        .with_QUOTE_SYMBOL(SizedAsciiString::new_with_right_whitespace_padding(
1885            config.quote.symbol.clone(),
1886        )?)?
1887        .with_PRICE_PRECISION(10u64.pow(price_precision as u32))?
1888        .with_QUANTITY_PRECISION(10u64.pow(quantity_precision as u32))?
1889        .with_INITIAL_OWNER(o2_tools::order_book_deploy::State::Initialized(
1890            Identity::Address(ViewOnlyAccount::address(deployer_wallet)),
1891        ))?
1892        .with_WHITE_LIST_CONTRACT(order_book_whitelist_id)?
1893        .with_BLACK_LIST_CONTRACT(order_book_blacklist_id)?)
1894}
1895
1896async fn load_or_deploy_order_book<W>(
1897    deployer_wallet: &W,
1898    order_book_registry: &OrderBookRegistryManager<W>,
1899    market_id: &MarketIdAssets,
1900    market_symbol: &str,
1901    order_book_configurables: &OrderBookConfigurables,
1902    order_book_config: &OrderBookConfig,
1903) -> anyhow::Result<OrderBookManager<W>>
1904where
1905    W: Account + ViewOnlyAccount + Clone + 'static,
1906{
1907    let register_contract_id = order_book_registry
1908        .registry
1909        .methods()
1910        .get_order_book(to_registry_market_id(market_id))
1911        .simulate(Execution::state_read_only())
1912        .await?
1913        .value;
1914
1915    match register_contract_id {
1916        Some(contract_id) => {
1917            let order_book_deploy = OrderBookDeploy::new(
1918                deployer_wallet.clone(),
1919                contract_id,
1920                market_id.base_asset,
1921                market_id.quote_asset,
1922            );
1923            // Handle partially deployed contracts (e.g. previous deploy failed
1924            // after registering but before initializing the proxy)
1925            let proxy_target = order_book_deploy
1926                .order_book_proxy
1927                .methods()
1928                .proxy_target()
1929                .simulate(Execution::state_read_only())
1930                .await?
1931                .value;
1932            if proxy_target.is_none() {
1933                tracing::info!(
1934                    "[{}] Proxy target not set, initializing...",
1935                    market_symbol
1936                );
1937                order_book_deploy.initialize().await?;
1938            }
1939            Ok(OrderBookManager::new(
1940                deployer_wallet,
1941                10u64.pow(order_book_config.base.decimals as u32),
1942                10u64.pow(order_book_config.quote.decimals as u32),
1943                &order_book_deploy,
1944            ))
1945        }
1946        None => {
1947            let (order_book_deployment, initialization_required) =
1948                OrderBookDeploy::deploy_without_initialization(
1949                    deployer_wallet,
1950                    market_id.base_asset,
1951                    market_id.quote_asset,
1952                    &OrderBookDeployConfig {
1953                        order_book_configurables: order_book_configurables.clone(),
1954                        salt: Salt::from(*order_book_registry.contract_id),
1955                        ..Default::default()
1956                    },
1957                )
1958                .await?;
1959
1960            order_book_registry
1961                .register_order_book(
1962                    to_registry_market_id(market_id),
1963                    order_book_deployment.contract_id,
1964                )
1965                .await?;
1966
1967            if initialization_required {
1968                order_book_deployment.initialize().await?;
1969            }
1970            Ok(OrderBookManager::new(
1971                deployer_wallet,
1972                10u64.pow(order_book_config.base.decimals as u32),
1973                10u64.pow(order_book_config.quote.decimals as u32),
1974                &order_book_deployment,
1975            ))
1976        }
1977    }
1978}
1979
1980async fn maybe_upgrade_order_book<W>(
1981    deployer_wallet: &W,
1982    should_upgrade_bytecode: bool,
1983    order_book: &OrderBookManager<W>,
1984    order_book_config: &OrderBookConfig,
1985    order_book_configurables: OrderBookConfigurables,
1986    market_symbol: &str,
1987) -> anyhow::Result<ContractId>
1988where
1989    W: Account + ViewOnlyAccount + Clone + 'static,
1990{
1991    let mut order_book_blob_id = order_book
1992        .proxy
1993        .methods()
1994        .proxy_target()
1995        .simulate(Execution::state_read_only())
1996        .await?
1997        .value
1998        .context("Order book proxy target should be set after initialization")?;
1999
2000    if should_upgrade_bytecode {
2001        let order_book_deploy_config = OrderBookDeployConfig {
2002            order_book_configurables,
2003            ..Default::default()
2004        };
2005        let order_book_deploy = OrderBookDeploy::new(
2006            deployer_wallet.clone(),
2007            order_book.contract.contract_id(),
2008            order_book_config.base.asset,
2009            order_book_config.quote.asset,
2010        );
2011        let order_book_manager = OrderBookManager::new(
2012            deployer_wallet,
2013            10u64.pow(order_book_config.base.decimals as u32),
2014            10u64.pow(order_book_config.quote.decimals as u32),
2015            &order_book_deploy,
2016        );
2017        let order_book_blob = OrderBookDeploy::order_book_blob(
2018            deployer_wallet,
2019            order_book_config.base.asset,
2020            order_book_config.quote.asset,
2021            &order_book_deploy_config,
2022        )
2023        .await?;
2024
2025        if order_book_blob_id != order_book_blob.id.into() {
2026            tracing::info!(
2027                "[{}] Upgrade OrderBook blob from {:?} to {:?}",
2028                market_symbol,
2029                order_book_blob_id,
2030                ContractId::from(order_book_blob.id)
2031            );
2032            order_book_manager
2033                .upgrade(&order_book_deploy_config)
2034                .await?;
2035            tracing::info!(
2036                "[{}] Emit new configuration event for {}",
2037                market_symbol,
2038                order_book.contract.contract_id()
2039            );
2040            order_book_manager.emit_config().await?;
2041            order_book_blob_id = order_book_blob.id.into();
2042        }
2043    }
2044
2045    Ok(order_book_blob_id)
2046}
2047
2048async fn set_order_book_maintainer<W>(
2049    order_book: &OrderBookManager<W>,
2050    ownership_options: &OwnershipTransferOptions,
2051    market_symbol: &str,
2052) -> anyhow::Result<()>
2053where
2054    W: Account + ViewOnlyAccount + Clone + 'static,
2055{
2056    // Revoke the maintainer role from the previous holders first, so the role is
2057    // rotated rather than accumulating holders across upgrades. Revoking an
2058    // account that does not hold the role is a no-op on-chain.
2059    for account in &ownership_options.revoke_orderbook_maintainers {
2060        tracing::info!(
2061            "[{}] Revoking ORDERBOOK_MAINTAINER_ROLE from {}",
2062            market_symbol,
2063            account
2064        );
2065        order_book
2066            .contract
2067            .methods()
2068            .owner_revoke_role(ORDERBOOK_MAINTAINER_ROLE, Identity::Address(*account))
2069            .call()
2070            .await?;
2071    }
2072
2073    for account in &ownership_options.new_orderbook_maintainers {
2074        tracing::info!(
2075            "[{}] Granting ORDERBOOK_MAINTAINER_ROLE to {}",
2076            market_symbol,
2077            account
2078        );
2079        order_book
2080            .contract
2081            .methods()
2082            .owner_grant_role(ORDERBOOK_MAINTAINER_ROLE, Identity::Address(*account))
2083            .call()
2084            .await?;
2085    }
2086
2087    Ok(())
2088}
2089
2090async fn transfer_order_book_ownership<W>(
2091    order_book: &OrderBookManager<W>,
2092    ownership_options: &OwnershipTransferOptions,
2093    market_symbol: &str,
2094) -> anyhow::Result<()>
2095where
2096    W: Account + ViewOnlyAccount + Clone + 'static,
2097{
2098    if let Some(new_owner) = ownership_options.new_proxy_owner {
2099        let new_identity = Identity::Address(new_owner);
2100        tracing::info!(
2101            "[{}] Transferring OrderBook proxy ownership to {}",
2102            market_symbol,
2103            new_owner
2104        );
2105        order_book
2106            .proxy
2107            .methods()
2108            .set_owner(new_identity)
2109            .call()
2110            .await?;
2111    }
2112
2113    if let Some(new_owner) = ownership_options.new_contract_owner {
2114        let new_identity = Identity::Address(new_owner);
2115        tracing::info!(
2116            "[{}] Transferring OrderBook contract ownership to {}",
2117            market_symbol,
2118            new_owner
2119        );
2120        order_book
2121            .contract
2122            .methods()
2123            .transfer_ownership(new_identity)
2124            .call()
2125            .await?;
2126    }
2127
2128    Ok(())
2129}
2130
2131#[cfg(test)]
2132mod tests {
2133    use super::*;
2134
2135    #[test]
2136    fn load_config_empty_path_returns_default() {
2137        let result: MarketsConfigPartial = load_config_from_file("").unwrap();
2138        assert!(result.pairs.is_empty());
2139    }
2140
2141    #[test]
2142    fn load_config_missing_file_errors() {
2143        let result: Result<MarketsConfigPartial, _> =
2144            load_config_from_file("nonexistent_file_12345.json");
2145        assert!(result.is_err());
2146    }
2147
2148    #[test]
2149    fn checked_sub_catches_overflow() {
2150        // Validates that our checked_sub pattern works correctly
2151        let decimals: u32 = 6;
2152        let max_precision: u32 = 8; // greater than decimals
2153
2154        let result = decimals.checked_sub(max_precision);
2155        assert!(
2156            result.is_none(),
2157            "should return None when max_precision > decimals"
2158        );
2159
2160        // Normal case
2161        let result = 9u32.checked_sub(6);
2162        assert_eq!(result, Some(3));
2163    }
2164
2165    #[test]
2166    fn markets_config_partial_default_has_empty_pairs() {
2167        let config = MarketsConfigPartial::default();
2168        assert!(config.pairs.is_empty());
2169    }
2170}