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