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