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        if let Some(margin) = &h.margin
237            && margin.price_feed_id.is_some()
238        {
239            return Err(serde::de::Error::custom(
240                "`margin.price_feed_id` moved into `margin.price_feed.id`. \
241                 Refusing to load a config that uses the retired spelling: it \
242                 would be ignored, a fresh feed would be derived, and the pool \
243                 would be repointed at an oracle with no publisher. Move the id \
244                 (and `publishers` / `supported_assets`) under \
245                 `margin.price_feed`.",
246            ));
247        }
248        Ok(MarketsConfigPartial {
249            starting_height: h.starting_height,
250            trade_account_registry_id: h.trade_account_registry_id,
251            order_book_registry_id: h.order_book_registry_id,
252            trade_account_oracle_id: h.trade_account_oracle_id,
253            trial_trade_account_oracle_id: h.trial_trade_account_oracle_id,
254            order_book_whitelist_id: h.order_book_whitelist_id,
255            order_book_blacklist_id: h.order_book_blacklist_id,
256            fast_bridge_asset_registry_proxy_id: h.fast_bridge_asset_registry_proxy_id,
257            pairs: h.pairs.into_iter().map(Into::into).collect(),
258            margin: h.margin,
259        })
260    }
261}
262
263/// One tier of the margin catalogue. Mirrors the on-chain `TierParams`
264/// plus the tier id and the markets (order books) it covers. `line`
265/// follows the config convention of u64 amounts as strings.
266#[serde_as]
267#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
268pub struct MarginTierConfig {
269    pub tier_id: u64,
270    /// Credit line in collateral-asset base units, as a decimal string.
271    #[serde_as(as = "serde_with::DisplayFromStr")]
272    pub line: u64,
273    pub leverage: u64,
274    pub duration: u64,
275    pub maintenance_bps: u64,
276    pub open_buffer_bps: u64,
277    pub liq_price_factor: u64,
278    /// Flat fee for one prolongation of each period, in WHOLE collateral
279    /// units as a decimal string - `"13.5"` at 9 decimals means
280    /// `13_500_000_000` base units. Indexed six hours, day, week, month; a
281    /// zero means the period is not offered.
282    pub prolong_fee: [String; 4],
283    pub max_credit_line_bps: u64,
284    pub max_price_age: u64,
285    /// Flat fee charged once at open, in WHOLE collateral units as a decimal
286    /// string. Same form as `prolong_fee`.
287    pub open_fee: String,
288    pub profit_share_bps: u64,
289    pub price_band_bps: u64,
290    /// Markets in this tier, referencing already-configured pairs by
291    /// "BASE/QUOTE" symbol or by hex market id.
292    pub markets: Vec<String>,
293}
294
295/// One asset the price feed must know the decimals of. Registering the
296/// decimals is what makes an asset SUPPORTED by the feed; publishing a
297/// price for it is a separate, later act. Declaring assets here therefore
298/// lets a freshly deployed feed be configured up front, before any
299/// publisher has produced a single quote.
300#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
301pub struct MarginSupportedAsset {
302    pub asset: fuels::types::AssetId,
303    pub decimals: u8,
304}
305
306/// The `price_feed` sub-section: everything about the ORACLE the pool values
307/// against, kept apart from the pool's own settings because it is a separate
308/// contract with its own lifecycle, ownership and roles.
309#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
310pub struct MarginPriceFeedConfig {
311    /// Reuse an existing feed. `None` deploys the production price feed
312    /// behind its own SRC-14 proxy.
313    pub id: Option<ContractId>,
314    /// Submission-age bound in seconds, reconciled compare-first. `None`
315    /// leaves whatever the feed enforces today.
316    pub max_offchain_age_seconds: Option<u64>,
317    /// Identities that must HOLD the submitter role, each prefixed with its
318    /// kind (`address:0x..` for a wallet, `contract:0x..` for a publishing
319    /// contract). Reconciled compare-first: an identity that already holds it
320    /// costs no transaction. When empty, nothing is granted and only the
321    /// feed's owner may publish.
322    #[serde(default)]
323    pub publishers: Vec<String>,
324    /// Identities that must NOT hold the submitter role, same prefixed form,
325    /// revoked only if currently held.
326    ///
327    /// An explicit list on purpose. The feed stores roles as a bitmap keyed
328    /// BY ACCOUNT with no reverse index, so neither the contract nor this
329    /// deploy can enumerate who holds the role - "revoke everyone not in
330    /// `publishers`" is not a question that can be asked on chain, and
331    /// inferring it from config absence would silently kick out a publisher
332    /// granted out of band.
333    ///
334    /// Revoking stops FUTURE writes only. A price the publisher already
335    /// pushed stays live until it ages out or is invalidated explicitly.
336    #[serde(default)]
337    pub remove_publishers: Vec<String>,
338    /// Assets the feed supports, registered by their decimals before any price
339    /// exists. Reconciled compare-first: an asset the feed already knows at the
340    /// SAME decimals is skipped, an unknown one is registered, and a
341    /// DISAGREEMENT is refused rather than overwritten. The pool pins an
342    /// asset's decimals on first use and rejects a later change, so silently
343    /// moving them here would strand it.
344    #[serde(default)]
345    pub supported_assets: Vec<MarginSupportedAsset>,
346}
347
348/// Optional `margin` section of the deploy config: the prop margin pool,
349/// the prop account oracle, the price feed and the tier catalogue.
350#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
351pub struct MarginConfig {
352    /// The price feed: its own contract, its own section.
353    #[serde(default)]
354    pub price_feed: MarginPriceFeedConfig,
355    /// The RETIRED flat spelling of `price_feed.id`, present only so that a
356    /// stale config is REJECTED rather than silently ignored.
357    ///
358    /// Silence here is catastrophic rather than untidy: an unread feed id
359    /// means the run derives a FRESH feed, and the pool-feed reconcile then
360    /// moves a live pool onto an oracle nobody publishes to. Every
361    /// valuation, liquidation and session open fails afterwards.
362    ///
363    /// Never serialized, so the write-back drops it once migrated.
364    #[serde(default, skip_serializing)]
365    pub price_feed_id: Option<ContractId>,
366    /// TIER-ONLY mode: run no system deploy, only reconcile the tier
367    /// catalogue (and the feed's roster/assets when configured) against
368    /// this already-deployed pool.
369    pub margin_pool_id: Option<ContractId>,
370    /// Identity receiving the platform profit share, prefixed with its
371    /// kind (`address:0x..` / `contract:0x..`). Defaults to the deployer.
372    pub platform_payout: Option<String>,
373    /// Collateral asset of the pool. Defaults to the quote asset of the
374    /// first configured pair.
375    pub collateral_asset: Option<fuels::types::AssetId>,
376    /// Collateral asset decimals. REQUIRED: a wrong figure misprices every
377    /// valuation the pool makes, so there is no default to fall back to.
378    pub collateral_decimals: Option<u8>,
379    /// Maximum order books one tier version may hold. Defaults to 80.
380    pub max_tier_books: Option<u64>,
381    /// Fee charged by `repay_base_from_collateral`, in PARTS PER MILLION of the
382    /// quote value of the debt closed - the order book's `TAKER_FEE` scale, so
383    /// `100` is one basis point. Defaults to 100.
384    ///
385    /// It is a pool CONFIGURABLE, so changing it here only takes effect on a
386    /// deploy or an implementation upgrade (`--upgrade-bytecode`), never on a
387    /// tier-only reconcile against an already-deployed pool.
388    pub base_repay_fee_ppm: Option<u64>,
389    /// The desired tier catalogue; reconciled idempotently (§ tier engine):
390    /// unchanged tiers are untouched, grown book lists use `add_books`,
391    /// any param change publishes a NEW version.
392    #[serde(default)]
393    pub tiers: Vec<MarginTierConfig>,
394}
395
396#[derive(Debug, Clone, Default)]
397pub struct OwnershipTransferOptions {
398    pub new_proxy_owner: Option<fuels::types::Address>,
399    pub new_contract_owner: Option<fuels::types::Address>,
400    /// Accounts to revoke `ORDERBOOK_MAINTAINER_ROLE` from on each order book
401    /// (via `owner_revoke_role`) before granting the new maintainers, so the role
402    /// is rotated rather than accumulating holders across upgrades.
403    pub revoke_orderbook_maintainers: Vec<fuels::types::Address>,
404    /// Accounts to grant `ORDERBOOK_MAINTAINER_ROLE` to on each order book (via
405    /// `owner_grant_role`) before ownership is transferred away.
406    pub new_orderbook_maintainers: Vec<fuels::types::Address>,
407}
408
409/// Parameters for a deploy invocation.
410#[derive(Debug, Clone)]
411pub struct DeployParams {
412    pub deploy_config: MarketsConfigPartial,
413    pub output: Option<String>,
414    pub deploy_whitelist: bool,
415    pub deploy_blacklist: bool,
416    pub upgrade_bytecode: bool,
417    pub new_proxy_owner: Option<fuels::types::Address>,
418    pub new_contract_owner: Option<fuels::types::Address>,
419    /// Cosigner address for trial trade accounts. When set, the trial trade
420    /// account implementation is (re)deployed on the trial oracle and this
421    /// cosigner is configured on it; when absent, the configured cosigner is
422    /// left untouched (the implementation still follows the regular
423    /// seed/upgrade lifecycle).
424    pub trial_cosigner: Option<fuels::types::Address>,
425    /// Identity allowed to register (activate) trial trade accounts on the
426    /// registry. When set, it is written via
427    /// `set_trial_trade_account_creator` if it differs from the current one
428    /// — registries upgraded in place start with the creator unset, which
429    /// denies all trial registrations. When absent, the creator is left
430    /// untouched.
431    pub trial_creator: Option<Identity>,
432    /// Reconcile margin CONFIGURATION only, leaving BYTECODE — the pool, the
433    /// oracle, the price-feed implementation — and the registry's prop wiring
434    /// untouched.
435    ///
436    /// It does still reconcile the things that are configuration rather than
437    /// code: the tier catalogue, the feed's publisher roster, its supported
438    /// assets, its submission-age bound, and the pool's feed pointer. That is
439    /// the point of the mode — rotating a publisher must not need a redeploy.
440    ///
441    /// An explicit switch rather than an inference from the markets
442    /// config: the mode decides WHETHER the system phase runs, which is
443    /// unrelated to WHICH pool the config names. Conflating the two meant
444    /// the ordinary steady state disabled the phase that owns the
445    /// registry's prop configurables, so an upgrade that dropped them was
446    /// never repaired.
447    pub margin_tier_only: bool,
448    /// Cosigner address for prop/margin accounts. When set, it is written to
449    /// the prop account oracle if it differs from the live one; when absent,
450    /// the configured cosigner is left untouched. Deliberately NOT sourced
451    /// from the deploy config and NOT defaulted to the deployer: a cosigner
452    /// the backend does not hold the key for makes margin silently inert.
453    pub margin_cosigner: Option<fuels::types::Address>,
454    /// Recipient of the residue from forced margin exits (liquidation and
455    /// expiry). Same opt-in rule as `margin_cosigner`: set it and it is
456    /// reconciled, omit it and the live value stands. Never defaulted to the
457    /// deployer - the deploy key must not silently become the payee.
458    pub margin_liquidator: Option<Identity>,
459    pub revoke_orderbook_maintainers: Vec<fuels::types::Address>,
460    pub new_orderbook_maintainers: Vec<fuels::types::Address>,
461}
462
463// ---------------------------------------------------------------------------
464// Helpers
465// ---------------------------------------------------------------------------
466
467/// Load a JSON config file, returning `T::default()` when the path is empty.
468pub fn load_config_from_file<T>(config_path: &str) -> anyhow::Result<T>
469where
470    T: Default + serde::de::DeserializeOwned,
471{
472    if config_path.is_empty() {
473        return Ok(T::default());
474    }
475    let current_dir = std::env::current_dir()?;
476    let path = current_dir.join(config_path);
477    tracing::info!("Loading config from {}", path.display());
478    let file = std::fs::File::open(&path)?;
479    let config: T = serde_json::from_reader(file)?;
480    Ok(config)
481}
482
483// ---------------------------------------------------------------------------
484// Core deploy logic
485// ---------------------------------------------------------------------------
486
487/// Deploy (or upgrade) the full set of O2 contracts.
488///
489/// The wallet type `W` must implement `Account + Clone + Signer` (e.g.
490/// `fuels::prelude::WalletUnlocked` or the KMS-backed `O2Wallet` from the API).
491pub async fn deploy<W>(
492    wallet: W,
493    params: DeployParams,
494) -> anyhow::Result<MarketsConfigOutput>
495where
496    W: Account + ViewOnlyAccount + Clone + 'static,
497{
498    tracing::info!("Starting Fuel o2 Registries and Markets");
499    let mut markets_config_partial = params.deploy_config.clone();
500    let starting_height: BlockHeight = markets_config_partial.starting_height.into();
501    let trade_account_oracle_id = markets_config_partial.trade_account_oracle_id;
502    let trial_trade_account_oracle_id =
503        markets_config_partial.trial_trade_account_oracle_id;
504    let order_book_registry_id = markets_config_partial.order_book_registry_id;
505    let trade_account_registry_id = markets_config_partial.trade_account_registry_id;
506    let fast_bridge_asset_registry_proxy_id =
507        markets_config_partial.fast_bridge_asset_registry_proxy_id;
508
509    let mut salt = Salt::zeroed();
510    salt.deref_mut()[..4].copy_from_slice(&starting_height.deref().to_be_bytes());
511
512    let (trade_account_oracle_deploy, trade_account_blob_id) =
513        deploy_trade_account_oracle(
514            wallet.clone(),
515            params.upgrade_bytecode,
516            trade_account_oracle_id,
517            salt,
518        )
519        .await?;
520    // The trial oracle is a dedicated contract (already-deployed trade
521    // account oracles are immutable and cannot gain the trial functions), so
522    // it deploys separately and the registry references both oracle ids.
523    let trial_trade_account_oracle_id = deploy_trial_trade_account_oracle(
524        wallet.clone(),
525        params.upgrade_bytecode,
526        trial_trade_account_oracle_id,
527        params.trial_cosigner,
528        salt,
529    )
530    .await?;
531    let (trade_account_registry, trade_account_registry_blob_id) =
532        deploy_trade_account_registry(
533            wallet.clone(),
534            params.upgrade_bytecode,
535            trade_account_oracle_deploy.clone(),
536            trial_trade_account_oracle_id,
537            trade_account_registry_id,
538            salt,
539        )
540        .await?;
541    let order_book_blacklist_id = deploy_order_book_blacklist(
542        wallet.clone(),
543        params.deploy_blacklist,
544        markets_config_partial.order_book_blacklist_id,
545        salt,
546    )
547    .await?;
548    let order_book_whitelist_id = deploy_order_book_whitelist(
549        wallet.clone(),
550        params.deploy_whitelist,
551        markets_config_partial.order_book_whitelist_id,
552        salt,
553    )
554    .await?;
555    let (order_book_registry, order_book_registry_blob_id) = deploy_order_book_registry(
556        wallet.clone(),
557        params.upgrade_bytecode,
558        order_book_registry_id,
559        salt,
560    )
561    .await?;
562    let pairs = deploy_order_books(
563        wallet.clone(),
564        params.upgrade_bytecode,
565        order_book_blacklist_id,
566        order_book_whitelist_id,
567        order_book_registry.clone(),
568        &mut markets_config_partial.pairs,
569        OwnershipTransferOptions {
570            new_proxy_owner: params.new_proxy_owner,
571            new_contract_owner: params.new_contract_owner,
572            revoke_orderbook_maintainers: params.revoke_orderbook_maintainers.clone(),
573            new_orderbook_maintainers: params.new_orderbook_maintainers.clone(),
574        },
575    )
576    .await?;
577
578    let order_book_registry_id = order_book_registry.contract_id;
579    let trade_account_registry_id = trade_account_registry.contract_id;
580    let trade_account_oracle_id = trade_account_oracle_deploy.oracle_id;
581
582    let trade_account_proxy = trade_account_registry
583        .registry
584        .methods()
585        .default_bytecode()
586        .simulate(Execution::state_read_only())
587        .await?
588        .value
589        .context(
590            "Trade account registry default bytecode should exist after initialization",
591        )?;
592    let trade_account_root = trade_account_registry
593        .registry
594        .methods()
595        .factory_bytecode_root()
596        .simulate(Execution::state_read_only())
597        .await?
598        .value
599        .context("Trade account registry factory bytecode root should exist after initialization")?;
600
601    // The margin phase runs BEFORE ownership transfer: the in-place
602    // registry upgrade and the pool/tier governance calls need the deployer
603    // to still hold the proxies and the pool admin role.
604    let margin_ids = match &markets_config_partial.margin {
605        Some(margin) => Some(
606            deploy_margin(
607                &wallet,
608                margin,
609                salt,
610                params.margin_tier_only,
611                trade_account_registry_id,
612                trade_account_oracle_id,
613                trial_trade_account_oracle_id,
614                &pairs,
615                params.margin_cosigner,
616                params.margin_liquidator,
617                params.upgrade_bytecode,
618            )
619            .await?,
620        ),
621        None => None,
622    };
623
624    // Registries upgraded in place start with the trial creator unset (the
625    // configurable seed only applies on the first initialize), so the deploy
626    // writes it explicitly when provided. Must run before ownership transfer.
627    if let Some(trial_creator) = params.trial_creator {
628        let current_creator = trade_account_registry
629            .get_trial_trade_account_creator()
630            .await?;
631        if current_creator != trial_creator {
632            tracing::info!("Setting trial trade account creator to {trial_creator:?}");
633            trade_account_registry
634                .set_trial_trade_account_creator(trial_creator)
635                .await?;
636        }
637    }
638
639    transfer_ownership(
640        &wallet,
641        &params,
642        &order_book_registry,
643        &trade_account_registry,
644        &trade_account_oracle_deploy,
645        trial_trade_account_oracle_id,
646        order_book_blacklist_id,
647        order_book_whitelist_id,
648    )
649    .await?;
650
651    let deploy_result = MarketsConfigOutput {
652        starting_height: starting_height.into(),
653        trade_account_registry_id,
654        trade_account_registry_blob_id,
655        trade_account_proxy,
656        trade_account_blob_id,
657        trade_account_root: ContractId::from(trade_account_root.0),
658        trade_account_oracle_id,
659        trial_trade_account_oracle_id,
660        order_book_whitelist_id,
661        order_book_blacklist_id,
662        order_book_registry_id,
663        order_book_registry_blob_id,
664        pairs,
665        fast_bridge_asset_registry_proxy_id,
666        price_feed_id: margin_ids.map(|ids| ids.price_feed_id),
667        margin_pool_id: margin_ids.map(|ids| ids.margin_pool_id),
668        margin_oracle_id: margin_ids.and_then(|ids| ids.margin_oracle_id),
669        // Carry the section through, with the resolved ids written back so a
670        // re-run reads them and drops into tier-only mode against the pool it
671        // already deployed. A DRY RUN must not: its ids are predictions for
672        // contracts that were never created, and writing them would send the
673        // next real run into tier-only mode against a pool that does not
674        // exist, skipping the system deploy entirely.
675        margin: markets_config_partial.margin.clone().map(|mut margin| {
676            if let Some(ids) = margin_ids {
677                margin.price_feed.id = Some(ids.price_feed_id);
678                margin.margin_pool_id = Some(ids.margin_pool_id);
679            }
680            margin
681        }),
682    };
683
684    if let Some(output_path) = params.output {
685        let json = serde_json::to_string_pretty(&deploy_result)?;
686        tracing::info!("Deploy result saved to {}", output_path);
687        std::fs::write(output_path, json)?;
688    }
689
690    Ok(deploy_result)
691}
692
693// ---------------------------------------------------------------------------
694// Margin (prop) deployment
695// ---------------------------------------------------------------------------
696
697/// The margin stack's resolved ids, echoed on the deploy output.
698#[derive(Debug, Clone, Copy)]
699pub struct MarginIds {
700    pub price_feed_id: ContractId,
701    pub margin_pool_id: ContractId,
702    pub margin_oracle_id: Option<ContractId>,
703}
704
705/// The margin phase of [`deploy`]: the prop system deployed/resumed
706/// deterministically (salted ids, exists-checked steps), the SHARED
707/// trade-account registry upgraded IN PLACE to carry the prop
708/// configurables, and the tier catalogue reconciled. With `tier_only` the
709/// system deploy is skipped and only the feed and tier reconciliation runs
710/// against the pool the config names.
711///
712/// Every step is compare-first: it reads the live value and sends nothing
713/// when the chain already agrees. A settled system therefore costs a run of
714/// reads and no transactions, which is what a rehearsal used to be for.
715#[allow(clippy::too_many_arguments)]
716async fn deploy_margin<W>(
717    wallet: &W,
718    margin: &MarginConfig,
719    salt: fuels::types::Salt,
720    tier_only: bool,
721    trade_account_registry_id: ContractId,
722    trade_account_oracle_id: ContractId,
723    trial_trade_account_oracle_id: ContractId,
724    pairs: &[OrderBookConfig],
725    margin_cosigner: Option<fuels::types::Address>,
726    margin_liquidator: Option<Identity>,
727    upgrade_bytecode: bool,
728) -> anyhow::Result<MarginIds>
729where
730    W: Account + ViewOnlyAccount + Clone + 'static,
731{
732    use o2_tools::prop::{
733        PriceFeedContract,
734        PropAccountOracleContract,
735        PropMarginPoolContract,
736    };
737
738    let collateral_asset = match margin.collateral_asset {
739        Some(collateral_asset) => collateral_asset,
740        None => {
741            pairs
742                .first()
743                .context(
744                    "margin.collateral_asset is not set and there are no pairs to \
745                     default it from",
746                )?
747                .quote
748                .asset
749        }
750    };
751    // The cosigner and the liquidator arrive as DEPLOY PARAMETERS, not from
752    // the config file: both name a key or a payee that belongs to the
753    // operator running the deploy, and both are opt-in - provide one and it
754    // is reconciled, omit it and whatever is live stands.
755    let cosigner = margin_cosigner;
756    let platform_payout = margin
757        .platform_payout
758        .as_deref()
759        .map(parse_margin_identity)
760        .transpose()?;
761    let liquidator = margin_liquidator;
762
763    let mut prop_config = PropDeployConfig::new(collateral_asset);
764    // No default: a wrong decimals figure misprices every valuation the pool
765    // makes, and it is invisible until money moves.
766    prop_config.collateral_decimals = margin.collateral_decimals.context(
767        "margin.collateral_decimals is required - set it to the collateral \
768         asset's decimals in the deploy config",
769    )?;
770    prop_config.max_tier_books = margin.max_tier_books.unwrap_or(80);
771    prop_config.base_repay_fee_ppm = margin.base_repay_fee_ppm.unwrap_or(100);
772    prop_config.cosigner = cosigner;
773    prop_config.platform_payout = platform_payout;
774    prop_config.liquidator = liquidator;
775    prop_config.salt = salt;
776    prop_config.existing_price_feed = margin.price_feed.id;
777    prop_config.max_offchain_age_seconds = margin.price_feed.max_offchain_age_seconds;
778    // The config names WHICH pool; it no longer decides whether the system
779    // phase runs. Pinning it also keeps an upgrade from forking the pool
780    // when a release changes the pool bytecode.
781    prop_config.existing_pool = margin.margin_pool_id;
782    prop_config.existing_registry = Some(ExistingRegistry {
783        registry_id: trade_account_registry_id,
784        trade_account_oracle_id,
785        trial_trade_account_oracle_id,
786    });
787
788    anyhow::ensure!(
789        !(tier_only && margin.margin_pool_id.is_none()),
790        "--margin-tier-only was requested but the markets config names no \
791         `margin.margin_pool_id`: there is no pool to reconcile tiers against"
792    );
793
794    // Resolve the system: tier-only mode, or a real deploy-or-resume.
795    let (price_feed_id, margin_pool_id, margin_oracle_id) = if let (
796        true,
797        Some(margin_pool_id),
798    ) =
799        (tier_only, margin.margin_pool_id)
800    {
801        tracing::info!("Margin: tier-only mode against pool {margin_pool_id}");
802        let price_feed_id = match margin.price_feed.id {
803            Some(price_feed_id) => price_feed_id,
804            None => {
805                PropMarginPoolContract::new(margin_pool_id, wallet.clone())
806                    .methods()
807                    .price_feed()
808                    .simulate(Execution::state_read_only())
809                    .await
810                    .context("read the pool's price feed")?
811                    .value
812            }
813        };
814        // Read the oracle back off the registry rather than reporting
815        // `None`. The caller writes what it gets straight into
816        // markets.json, so a `None` here NULLS a live, known-good id —
817        // the same class of mistake as writing ids a rehearsal never
818        // deployed.
819        let margin_oracle_id =
820            TradeAccountRegistryManager::new(wallet.clone(), trade_account_registry_id)
821                .registry
822                .methods()
823                .get_prop_oracle_id()
824                .simulate(Execution::state_read_only())
825                .await
826                .map(|result| result.value)
827                .ok()
828                .filter(|oracle_id| *oracle_id != ContractId::zeroed());
829        (price_feed_id, margin_pool_id, margin_oracle_id)
830    } else {
831        let deployment = deploy_prop_system(wallet, &prop_config).await?;
832        tracing::info!(
833            "Margin: oracle {}, pool {}, feed {} (shared registry {} upgraded in place)",
834            deployment.oracle_id,
835            deployment.pool_id,
836            deployment.price_feed_id,
837            deployment.registry_id,
838        );
839        (
840            deployment.price_feed_id,
841            deployment.pool_id,
842            Some(deployment.oracle_id),
843        )
844    };
845
846    // -- the payout parties ----------------------------------------------
847    // Reconciled compare-first against the LIVE pool, so a re-run is a
848    // no-op and an absent config key never overwrites what the pool has.
849    // A pool not deployed yet gets both values through its INITIAL_*
850    // configurables on the real run.
851    let pool_deployed = wallet
852        .try_provider()?
853        .contract_exists(&margin_pool_id)
854        .await?;
855    if pool_deployed {
856        let pool = PropMarginPoolContract::new(margin_pool_id, wallet.clone());
857        if let Some(platform_payout) = platform_payout {
858            let current = pool
859                .methods()
860                .platform_payout()
861                .simulate(Execution::state_read_only())
862                .await
863                .context("read the pool's platform payout")?
864                .value;
865            if current != platform_payout {
866                tracing::info!(
867                    "Margin: platform payout {current:?} -> {platform_payout:?}"
868                );
869                pool.methods()
870                    .set_platform_payout(platform_payout)
871                    .call()
872                    .await?;
873            }
874        }
875        if let Some(liquidator) = liquidator {
876            let current = pool
877                .methods()
878                .liquidator()
879                .simulate(Execution::state_read_only())
880                .await
881                .context("read the pool's liquidator")?
882                .value;
883            if current != liquidator {
884                tracing::info!("Margin: liquidator {current:?} -> {liquidator:?}");
885                pool.methods().set_liquidator(liquidator).call().await?;
886            }
887        }
888    }
889
890    // Tier-only mode resolves no oracle of its own - ask the live registry,
891    // which is the same lookup the backend performs at boot.
892    let margin_oracle_id = match margin_oracle_id {
893        Some(oracle_id) => Some(oracle_id),
894        None if cosigner.is_some() => {
895            let registry = o2_tools::trade_account_registry::TradeAccountRegistry::new(
896                trade_account_registry_id,
897                wallet.clone(),
898            );
899            let oracle_id = registry
900                .methods()
901                .get_prop_oracle_id()
902                .simulate(Execution::state_read_only())
903                .await
904                .context("read the registry's prop account oracle")?
905                .value;
906            (oracle_id != ContractId::zeroed()).then_some(oracle_id)
907        }
908        None => None,
909    };
910
911    // The cosigner lives on the ORACLE, not the pool, and only reaches
912    // storage through INITIAL_COSIGNER on the very first initialize - so an
913    // already-initialized oracle needs this explicit reconciliation, or
914    // "set it if provided" would silently mean "only on a fresh deploy".
915    if let (Some(cosigner), Some(oracle_id)) = (cosigner, margin_oracle_id)
916        && wallet.try_provider()?.contract_exists(&oracle_id).await?
917    {
918        let oracle = PropAccountOracleContract::new(oracle_id, wallet.clone());
919        let current = oracle
920            .methods()
921            .get_cosigner()
922            .simulate(Execution::state_read_only())
923            .await
924            .context("read the prop account oracle's cosigner")?
925            .value;
926        if current != Some(cosigner) {
927            tracing::info!("Margin: cosigner {current:?} -> {cosigner:?}");
928            oracle.methods().set_cosigner(cosigner).call().await?;
929        }
930    }
931
932    // -- who may publish --------------------------------------------------
933    // Compare-first, like every other reconcile here: the role is granted or
934    // revoked only when the feed disagrees with the config, so a settled
935    // roster sends nothing at all.
936    //
937    // An identity in BOTH lists would be granted and then revoked in the same
938    // run, silently, decided by iteration order. Refuse the ambiguity.
939    for entry in &margin.price_feed.publishers {
940        anyhow::ensure!(
941            !margin.price_feed.remove_publishers.contains(entry),
942            "price feed publisher {entry} is listed in both `publishers` and \
943             `remove_publishers`"
944        );
945    }
946    let price_feed = PriceFeedContract::new(price_feed_id, wallet.clone());
947    for (entry, should_hold) in margin
948        .price_feed
949        .publishers
950        .iter()
951        .map(|entry| (entry, true))
952        .chain(
953            margin
954                .price_feed
955                .remove_publishers
956                .iter()
957                .map(|entry| (entry, false)),
958        )
959    {
960        let publisher = parse_margin_identity(entry)?;
961        let holds = price_feed
962            .methods()
963            .has_role(o2_tools::prop_deploy::PRICE_SUBMITTER_ROLE, publisher)
964            .simulate(Execution::state_read_only())
965            .await
966            .context("read a price feed publisher's submitter role")?
967            .value;
968        if holds == should_hold {
969            continue;
970        }
971        if should_hold {
972            tracing::info!("Margin: adding price feed publisher {publisher:?}");
973            price_feed
974                .methods()
975                .add_publisher(publisher)
976                .call()
977                .await
978                .context("grant a price feed publisher the submitter role")?;
979        } else {
980            tracing::info!("Margin: removing price feed publisher {publisher:?}");
981            price_feed
982                .methods()
983                .remove_publisher(publisher)
984                .call()
985                .await
986                .context("revoke a price feed publisher's submitter role")?;
987        }
988    }
989    // -- the feed's implementation ---------------------------------------
990    // Same contract as the order books: under `--upgrade-bytecode`, rebuild
991    // the configured blob and retarget the proxy only when it differs, so a
992    // feed already on this bytecode costs no transaction.
993    //
994    // Never in tier-only mode: that mode reconciles configuration, never
995    // bytecode. Rotating a publisher must not also swap the implementation
996    // every margin valuation reads.
997    if upgrade_bytecode && !tier_only {
998        o2_tools::prop_deploy::upgrade_price_feed_implementation(
999            wallet,
1000            price_feed_id,
1001            // The blob's owner configurable must match what the fresh deploy
1002            // derived, or the retarget would chase a different blob id.
1003            prop_config
1004                .owner
1005                .unwrap_or(Identity::Address(wallet.address())),
1006            &prop_config,
1007        )
1008        .await
1009        .context("upgrade the price feed implementation")?;
1010    }
1011
1012    // -- the feed's submission-age bound ----------------------------------
1013    // Also reconciled here, not just through the system deploy, so tier-only
1014    // mode honours it. Compare-first, so the settled case sends nothing and
1015    // the double-apply on a full deploy is free.
1016    if let Some(seconds) = margin.price_feed.max_offchain_age_seconds {
1017        let current = price_feed
1018            .methods()
1019            .get_max_offchain_age()
1020            .simulate(Execution::state_read_only())
1021            .await
1022            .context("read the price feed's submission-age bound")?
1023            .value;
1024        if current != seconds {
1025            tracing::info!("Margin: submission-age bound {current} -> {seconds}");
1026            price_feed
1027                .methods()
1028                .set_max_offchain_age(seconds)
1029                .call()
1030                .await
1031                .context("set the price feed's submission-age bound")?;
1032        }
1033    }
1034
1035    // -- the feed's supported assets -------------------------------------
1036    // Registering decimals is what makes an asset known to the feed, and it
1037    // is independent of ever having a price: a tier catalogue publishes
1038    // against a feed configured this way even when no quote has ever been
1039    // seeded.
1040    for supported in &margin.price_feed.supported_assets {
1041        let current = price_feed
1042            .methods()
1043            .get_asset_decimals(supported.asset)
1044            .simulate(Execution::state_read_only())
1045            .await
1046            .context("read the price feed's asset decimals")?
1047            .value;
1048        match current {
1049            Some(decimals) if decimals == supported.decimals => continue,
1050            // Refused, not overwritten: the pool pins an asset's oracle
1051            // decimals on first use and reverts on a later change, so an
1052            // asset already live at other decimals would be stranded.
1053            Some(decimals) => anyhow::bail!(
1054                "price feed already knows {} at {decimals} decimals, config says \
1055                 {} - refusing to move it, since the pool pins decimals on first \
1056                 use and rejects a change",
1057                supported.asset,
1058                supported.decimals,
1059            ),
1060            None => {
1061                tracing::info!(
1062                    "Margin: registering {} at {} decimals on the feed",
1063                    supported.asset,
1064                    supported.decimals,
1065                );
1066                price_feed
1067                    .methods()
1068                    .set_asset_decimals(supported.asset, supported.decimals)
1069                    .call()
1070                    .await
1071                    .context("register a supported asset on the price feed")?;
1072            }
1073        }
1074    }
1075
1076    let pool = PropMarginPoolContract::new(margin_pool_id, wallet.clone());
1077    let pool_reachable = wallet
1078        .try_provider()?
1079        .contract_exists(&margin_pool_id)
1080        .await?;
1081
1082    // -- the feed the POOL values against ---------------------------------
1083    // `INITIAL_PRICE_FEED` only reaches storage through the pool's one-time
1084    // `initialize`, so a pool that is already live keeps its old feed no
1085    // matter what the config says. Reconcile it explicitly, or "point the
1086    // pool at this feed" would silently mean "only on a fresh deploy".
1087    //
1088    // Runs AFTER the supported-assets step on purpose: `set_price_feed`
1089    // validates that the replacement already knows every admitted asset at
1090    // the pinned decimals, which is exactly what that step registers.
1091    if pool_reachable {
1092        let live_feed = pool
1093            .methods()
1094            .price_feed()
1095            .simulate(Execution::state_read_only())
1096            .await
1097            .context("read the pool's price feed")?
1098            .value;
1099        if live_feed != price_feed_id {
1100            tracing::info!("Margin: pool price feed {live_feed} -> {price_feed_id}");
1101            pool.methods()
1102                .set_price_feed(price_feed_id)
1103                // `validate_replacement_feed` calls INTO the new feed once per
1104                // admitted asset, so it must ride as an input.
1105                .with_contract_ids(&[price_feed_id])
1106                .call()
1107                .await
1108                .context(
1109                    "point the pool at the configured price feed - the \
1110                     replacement must already know every admitted asset at the \
1111                     decimals the pool pinned",
1112                )?;
1113        }
1114    }
1115
1116    // -- the tier catalogue ---------------------------------------------
1117    // The pool is unreachable only when the system deploy did not run, which
1118    // means tier-only mode against a pool the config NAMES but the chain does
1119    // not have. The guard above only checks that a pool is named, so refuse
1120    // here rather than skip: a run that publishes no tiers and reports
1121    // success is the failure an operator cannot see.
1122    anyhow::ensure!(
1123        pool_reachable || margin.tiers.is_empty(),
1124        "margin pool {margin_pool_id} is not on chain, so its {} configured \
1125         tier(s) cannot be reconciled",
1126        margin.tiers.len()
1127    );
1128    if pool_reachable {
1129        reconcile_margin_tiers(
1130            &pool,
1131            price_feed_id,
1132            pairs,
1133            &margin.tiers,
1134            prop_config.collateral_decimals,
1135        )
1136        .await?;
1137    }
1138
1139    Ok(MarginIds {
1140        price_feed_id,
1141        margin_pool_id,
1142        margin_oracle_id,
1143    })
1144}
1145
1146/// The tier engine: reconcile the DECLARED catalogue against the LIVE
1147/// pool, idempotently.
1148///
1149/// - no live version              -> publish version 1;
1150/// - params differ from the live  -> publish a NEW version;
1151/// - params equal, books grew     -> `add_books` with the missing books;
1152/// - params equal, books covered  -> untouched (books are append-only:
1153///   a SHRUNK declared list cannot be enacted and is reported).
1154async fn reconcile_margin_tiers<W>(
1155    pool: &o2_tools::prop::PropMarginPoolContract<W>,
1156    price_feed_id: ContractId,
1157    pairs: &[OrderBookConfig],
1158    tiers: &[MarginTierConfig],
1159    collateral_decimals: u8,
1160) -> anyhow::Result<()>
1161where
1162    W: Account + ViewOnlyAccount + Clone + 'static,
1163{
1164    for tier in tiers {
1165        let books = tier
1166            .markets
1167            .iter()
1168            .map(|market| resolve_tier_market(market, pairs))
1169            .collect::<anyhow::Result<Vec<_>>>()?;
1170        let params = tier_params(tier, collateral_decimals)?;
1171        let current_version = pool
1172            .methods()
1173            .current_tier_version(tier.tier_id)
1174            .simulate(Execution::state_read_only())
1175            .await?
1176            .value;
1177
1178        let mut contract_ids = vec![price_feed_id];
1179        contract_ids.extend(books.iter().copied());
1180
1181        let Some(version) = current_version else {
1182            let version = pool
1183                .methods()
1184                .publish_tier_version(tier.tier_id, params, books)
1185                .with_contract_ids(&contract_ids)
1186                .call()
1187                .await?
1188                .value;
1189            tracing::info!("Margin: tier {} published version {version}", tier.tier_id);
1190            continue;
1191        };
1192
1193        let live = pool
1194            .methods()
1195            .get_tier(tier.tier_id, version)
1196            .simulate(Execution::state_read_only())
1197            .await?
1198            .value
1199            .with_context(|| {
1200                format!("tier {} version {version} vanished mid-read", tier.tier_id)
1201            })?;
1202        if live != params {
1203            let new_version = pool
1204                .methods()
1205                .publish_tier_version(tier.tier_id, params, books)
1206                .with_contract_ids(&contract_ids)
1207                .call()
1208                .await?
1209                .value;
1210            tracing::info!(
1211                "Margin: tier {} republished as version {new_version}",
1212                tier.tier_id
1213            );
1214            continue;
1215        }
1216
1217        let live_books = pool
1218            .methods()
1219            .tier_books(tier.tier_id, version)
1220            .simulate(Execution::state_read_only())
1221            .await?
1222            .value;
1223        let missing: Vec<ContractId> = books
1224            .iter()
1225            .copied()
1226            .filter(|book| !live_books.contains(book))
1227            .collect();
1228        let shrunk = live_books
1229            .iter()
1230            .filter(|book| !books.contains(book))
1231            .count();
1232        if shrunk > 0 {
1233            tracing::warn!(
1234                "Margin: tier {} declares {shrunk} fewer book(s) than live version \
1235                 {version}; books are append-only — publish a new version to drop \
1236                 markets",
1237                tier.tier_id
1238            );
1239        }
1240        if missing.is_empty() {
1241            tracing::info!(
1242                "Margin: tier {} version {version} matches the declared catalogue — \
1243                 unchanged",
1244                tier.tier_id
1245            );
1246            continue;
1247        }
1248        let mut add_contract_ids = vec![price_feed_id];
1249        add_contract_ids.extend(missing.iter().copied());
1250        pool.methods()
1251            .add_books(tier.tier_id, missing.clone())
1252            .with_contract_ids(&add_contract_ids)
1253            .call()
1254            .await?;
1255        tracing::info!(
1256            "Margin: tier {} version {version} gained {} book(s)",
1257            tier.tier_id,
1258            missing.len()
1259        );
1260    }
1261    Ok(())
1262}
1263
1264/// Whole collateral units (`"7.5"`) into base units, EXACTLY.
1265///
1266/// Never through `f64`: a binary float cannot hold most decimal fractions, so
1267/// `"7.1"` at 9 decimals would land on 7_099_999_999 - and this is money. The
1268/// integer is built from the digits instead.
1269///
1270/// More fractional digits than the asset has is an ERROR, not a rounding: a
1271/// figure the chain cannot represent is a figure the operator did not mean.
1272fn collateral_amount(value: &str, decimals: u8, field: &str) -> anyhow::Result<u64> {
1273    let raw = value.trim();
1274    anyhow::ensure!(!raw.is_empty(), "{field}: empty amount");
1275    anyhow::ensure!(
1276        !raw.starts_with('-'),
1277        "{field}: `{raw}` is negative; fees are unsigned"
1278    );
1279    let (whole, fraction) = match raw.split_once('.') {
1280        Some((whole, fraction)) => (whole, fraction),
1281        None => (raw, ""),
1282    };
1283    anyhow::ensure!(
1284        !fraction.contains('.'),
1285        "{field}: `{raw}` has more than one decimal point"
1286    );
1287    anyhow::ensure!(
1288        !whole.is_empty() && whole.bytes().all(|b| b.is_ascii_digit()),
1289        "{field}: `{raw}` is not a decimal amount"
1290    );
1291    anyhow::ensure!(
1292        fraction.bytes().all(|b| b.is_ascii_digit()),
1293        "{field}: `{raw}` is not a decimal amount"
1294    );
1295    let decimals = usize::from(decimals);
1296    anyhow::ensure!(
1297        fraction.len() <= decimals,
1298        "{field}: `{raw}` has {} decimal places, but the collateral asset has \
1299         only {decimals}; the chain cannot represent it",
1300        fraction.len()
1301    );
1302    let digits = format!("{whole}{fraction}{}", "0".repeat(decimals - fraction.len()));
1303    digits
1304        .parse::<u64>()
1305        .map_err(|e| anyhow::anyhow!("{field}: `{raw}` does not fit a u64: {e}"))
1306}
1307
1308fn tier_params(
1309    tier: &MarginTierConfig,
1310    collateral_decimals: u8,
1311) -> anyhow::Result<o2_tools::prop::TierParams> {
1312    let fee = |value: &str, what: &str| {
1313        collateral_amount(
1314            value,
1315            collateral_decimals,
1316            &format!("tier {} {what}", tier.tier_id),
1317        )
1318    };
1319    Ok(o2_tools::prop::TierParams {
1320        line: tier.line,
1321        leverage: tier.leverage,
1322        duration: tier.duration,
1323        maintenance_bps: tier.maintenance_bps,
1324        open_buffer_bps: tier.open_buffer_bps,
1325        liq_price_factor: tier.liq_price_factor,
1326        prolong_fee: [
1327            fee(&tier.prolong_fee[0], "prolong_fee[0]")?,
1328            fee(&tier.prolong_fee[1], "prolong_fee[1]")?,
1329            fee(&tier.prolong_fee[2], "prolong_fee[2]")?,
1330            fee(&tier.prolong_fee[3], "prolong_fee[3]")?,
1331        ],
1332        max_credit_line_bps: tier.max_credit_line_bps,
1333        max_price_age: tier.max_price_age,
1334        open_fee: fee(&tier.open_fee, "open_fee")?,
1335        profit_share_bps: tier.profit_share_bps,
1336        price_band_bps: tier.price_band_bps,
1337    })
1338}
1339
1340/// Resolve a tier `markets` entry — a "BASE/QUOTE" symbol pair or a hex
1341/// market id — to the deployed order book's contract id.
1342fn resolve_tier_market(
1343    market: &str,
1344    pairs: &[OrderBookConfig],
1345) -> anyhow::Result<ContractId> {
1346    let wanted = market.trim();
1347    let wanted_id = wanted
1348        .strip_prefix("0x")
1349        .unwrap_or(wanted)
1350        .to_ascii_lowercase();
1351    let pair = pairs
1352        .iter()
1353        .find(|pair| {
1354            let symbol = format!("{}/{}", pair.base.symbol, pair.quote.symbol);
1355            symbol.eq_ignore_ascii_case(wanted)
1356                || hex::encode(*pair.market_id) == wanted_id
1357        })
1358        .with_context(|| format!("margin tier references unknown market `{market}`"))?;
1359    pair.contract_id.with_context(|| {
1360        format!("margin tier market `{market}` has no deployed order book")
1361    })
1362}
1363
1364/// `address:0x..` or `contract:0x..` into an `Identity`.
1365fn parse_margin_identity(s: &str) -> anyhow::Result<Identity> {
1366    if let Some(hex_part) = s.strip_prefix("address:") {
1367        Ok(Identity::Address(fuels::types::Address::new(
1368            parse_margin_bytes32(hex_part)?,
1369        )))
1370    } else if let Some(hex_part) = s.strip_prefix("contract:") {
1371        Ok(Identity::ContractId(ContractId::new(parse_margin_bytes32(
1372            hex_part,
1373        )?)))
1374    } else {
1375        anyhow::bail!("expected `address:0x..` or `contract:0x..`, got `{s}`")
1376    }
1377}
1378
1379fn parse_margin_bytes32(s: &str) -> anyhow::Result<[u8; 32]> {
1380    let raw = s.trim().strip_prefix("0x").unwrap_or(s.trim());
1381    let bytes = hex::decode(raw)
1382        .map_err(|e| anyhow::anyhow!("expected 32 hex bytes, got `{s}`: {e}"))?;
1383    bytes
1384        .try_into()
1385        .map_err(|_| anyhow::anyhow!("expected 32 hex bytes, got `{s}`"))
1386}
1387
1388// ---------------------------------------------------------------------------
1389// Ownership transfer
1390// ---------------------------------------------------------------------------
1391
1392#[allow(clippy::too_many_arguments)]
1393async fn transfer_ownership<W>(
1394    wallet: &W,
1395    params: &DeployParams,
1396    order_book_registry: &OrderBookRegistryManager<W>,
1397    trade_account_registry: &TradeAccountRegistryManager<W>,
1398    trade_account_oracle_deploy: &TradeAccountDeploy<W>,
1399    trial_trade_account_oracle_id: ContractId,
1400    order_book_blacklist_id: Option<ContractId>,
1401    order_book_whitelist_id: Option<ContractId>,
1402) -> anyhow::Result<()>
1403where
1404    W: Account + ViewOnlyAccount + Clone + 'static,
1405{
1406    if let Some(new_proxy_owner) = params.new_proxy_owner {
1407        let new_identity = Identity::Address(new_proxy_owner);
1408        tracing::info!(
1409            "Transferring OrderBookRegistry proxy ownership to {}",
1410            new_proxy_owner
1411        );
1412        order_book_registry
1413            .registry_proxy
1414            .methods()
1415            .set_owner(new_identity)
1416            .call()
1417            .await?;
1418        tracing::info!(
1419            "Transferring TradeAccountRegistry proxy ownership to {}",
1420            new_proxy_owner
1421        );
1422        trade_account_registry
1423            .registry_proxy
1424            .methods()
1425            .set_owner(new_identity)
1426            .call()
1427            .await?;
1428    }
1429
1430    if let Some(new_contract_owner) = params.new_contract_owner {
1431        let new_identity = Identity::Address(new_contract_owner);
1432        tracing::info!(
1433            "Transferring TradeAccountOracle ownership to {}",
1434            new_contract_owner
1435        );
1436        trade_account_oracle_deploy
1437            .oracle
1438            .methods()
1439            .transfer_ownership(new_identity)
1440            .call()
1441            .await?;
1442        tracing::info!(
1443            "Transferring TrialTradeAccountOracle ownership to {}",
1444            new_contract_owner
1445        );
1446        TrialTradingAccountOracle::new(trial_trade_account_oracle_id, wallet.clone())
1447            .methods()
1448            .transfer_ownership(new_identity)
1449            .call()
1450            .await?;
1451        tracing::info!(
1452            "Transferring TradeAccountRegistry ownership to {}",
1453            new_contract_owner
1454        );
1455        trade_account_registry
1456            .registry
1457            .methods()
1458            .transfer_ownership(new_identity)
1459            .call()
1460            .await?;
1461        tracing::info!(
1462            "Transferring OrderBookRegistry ownership to {}",
1463            new_contract_owner
1464        );
1465        order_book_registry
1466            .registry
1467            .methods()
1468            .transfer_ownership(new_identity)
1469            .call()
1470            .await?;
1471        if let Some(blacklist_id) = order_book_blacklist_id {
1472            tracing::info!(
1473                "Transferring OrderBookBlacklist ownership to {}",
1474                new_contract_owner
1475            );
1476            OrderBookBlacklist::new(blacklist_id, wallet.clone())
1477                .methods()
1478                .transfer_ownership(new_identity)
1479                .call()
1480                .await?;
1481        }
1482        if let Some(whitelist_id) = order_book_whitelist_id {
1483            tracing::info!(
1484                "Transferring OrderBookWhitelist ownership to {}",
1485                new_contract_owner
1486            );
1487            OrderBookWhitelist::new(whitelist_id, wallet.clone())
1488                .methods()
1489                .transfer_ownership(new_identity)
1490                .call()
1491                .await?;
1492        }
1493    }
1494
1495    Ok(())
1496}
1497
1498// ---------------------------------------------------------------------------
1499// Internal deploy helpers
1500// ---------------------------------------------------------------------------
1501
1502async fn deploy_order_book_blacklist<W>(
1503    deployer_wallet: W,
1504    deploy_blacklist: bool,
1505    order_book_blacklist_id: Option<ContractId>,
1506    salt: Salt,
1507) -> anyhow::Result<Option<ContractId>>
1508where
1509    W: Account + ViewOnlyAccount + Clone + 'static,
1510{
1511    match order_book_blacklist_id {
1512        Some(order_book_blacklist_id) => {
1513            tracing::info!(
1514                "Using existing OrderBookBlacklist: {}",
1515                order_book_blacklist_id
1516            );
1517            Ok(Some(order_book_blacklist_id))
1518        }
1519        None => {
1520            if !deploy_blacklist {
1521                return Ok(None);
1522            }
1523            tracing::info!("Deploying OrderBookBlacklist");
1524            let order_book_blacklist = OrderBookDeploy::deploy_order_book_blacklist(
1525                &deployer_wallet,
1526                &Identity::Address(ViewOnlyAccount::address(&deployer_wallet)),
1527                &OrderBookDeployConfig {
1528                    salt,
1529                    ..Default::default()
1530                },
1531            )
1532            .await?;
1533            tracing::info!("OrderBookBlacklist: {}", order_book_blacklist.contract_id());
1534            Ok(Some(order_book_blacklist.contract_id()))
1535        }
1536    }
1537}
1538
1539async fn deploy_order_book_whitelist<W>(
1540    deployer_wallet: W,
1541    deploy_whitelist: bool,
1542    order_book_whitelist_id: Option<ContractId>,
1543    salt: Salt,
1544) -> anyhow::Result<Option<ContractId>>
1545where
1546    W: Account + ViewOnlyAccount + Clone + 'static,
1547{
1548    match (order_book_whitelist_id, deploy_whitelist) {
1549        (Some(order_book_whitelist_id), false)
1550        | (Some(order_book_whitelist_id), true) => {
1551            tracing::info!(
1552                "Using existing OrderBookWhitelist: {}",
1553                order_book_whitelist_id
1554            );
1555            Ok(Some(order_book_whitelist_id))
1556        }
1557        (None, false) => Ok(None),
1558        (None, true) => {
1559            tracing::info!("Deploying OrderBookWhitelist");
1560            let trade_account_whitelist = OrderBookDeploy::deploy_order_book_whitelist(
1561                &deployer_wallet,
1562                &Identity::Address(ViewOnlyAccount::address(&deployer_wallet)),
1563                &OrderBookDeployConfig {
1564                    salt,
1565                    ..Default::default()
1566                },
1567            )
1568            .await?;
1569            tracing::info!(
1570                "OrderBookWhitelist: {}",
1571                trade_account_whitelist.contract_id()
1572            );
1573            Ok(Some(trade_account_whitelist.contract_id()))
1574        }
1575    }
1576}
1577
1578/// Load an existing oracle and recover from partial deployment if needed.
1579/// Unlike `TradeAccountDeploy::from_oracle_id`, this does not error when
1580/// the trade account implementation is missing — it deploys and sets it.
1581async fn load_or_recover_trade_account_oracle<W>(
1582    deployer_wallet: &W,
1583    oracle_id: ContractId,
1584) -> anyhow::Result<(TradeAccountDeploy<W>, ContractId)>
1585where
1586    W: Account + ViewOnlyAccount + Clone + 'static,
1587{
1588    let oracle = TradingAccountOracle::new(oracle_id, deployer_wallet.clone());
1589    let impl_id = oracle
1590        .methods()
1591        .get_trade_account_impl()
1592        .simulate(Execution::state_read_only())
1593        .await?
1594        .value;
1595
1596    let blob_id = match impl_id {
1597        Some(id) => id,
1598        None => {
1599            tracing::info!(
1600                "Trade account implementation not set on oracle {}, deploying...",
1601                oracle_id
1602            );
1603            let blob = TradeAccountDeploy::trade_account_blob(
1604                deployer_wallet,
1605                &Default::default(),
1606            )
1607            .await?;
1608            TradeAccountDeploy::deploy_trade_account_blob(
1609                deployer_wallet,
1610                &DeployConfig::Latest(Default::default()),
1611            )
1612            .await?;
1613            oracle
1614                .methods()
1615                .set_trade_account_impl(ContractId::from(blob.id))
1616                .call()
1617                .await?;
1618            ContractId::from(blob.id)
1619        }
1620    };
1621
1622    let deploy = TradeAccountDeploy {
1623        oracle,
1624        oracle_id,
1625        trade_account_blob_id: blob_id.into(),
1626        deployer_wallet: deployer_wallet.clone(),
1627        proxy: None,
1628        proxy_id: None,
1629    };
1630    Ok((deploy, blob_id))
1631}
1632
1633async fn deploy_trade_account_oracle<W>(
1634    deployer_wallet: W,
1635    should_upgrade_bytecode: bool,
1636    trade_account_oracle_id: Option<ContractId>,
1637    salt: Salt,
1638) -> anyhow::Result<(TradeAccountDeploy<W>, ContractId)>
1639where
1640    W: Account + ViewOnlyAccount + Clone + 'static,
1641{
1642    let (trade_account_oracle_deploy, mut trade_account_blob_id) =
1643        match trade_account_oracle_id {
1644            Some(oracle_id) => {
1645                load_or_recover_trade_account_oracle(&deployer_wallet, oracle_id).await?
1646            }
1647            None => {
1648                let deploy = TradeAccountDeploy::deploy(
1649                    &deployer_wallet,
1650                    &DeployConfig::Latest(TradeAccountDeployConfig {
1651                        salt,
1652                        ..Default::default()
1653                    }),
1654                )
1655                .await?;
1656                let blob_id = deploy
1657                    .oracle
1658                    .methods()
1659                    .get_trade_account_impl()
1660                    .simulate(Execution::state_read_only())
1661                    .await?
1662                    .value
1663                    .context("Trade account impl should exist after fresh deploy")?;
1664                (deploy, blob_id)
1665            }
1666        };
1667    tracing::info!(
1668        "TradeAccountOracle: {}",
1669        trade_account_oracle_deploy.oracle_id
1670    );
1671
1672    if should_upgrade_bytecode {
1673        let trade_account_blob =
1674            TradeAccountDeploy::trade_account_blob(&deployer_wallet, &Default::default())
1675                .await?;
1676        if ContractId::from(trade_account_blob.id) != trade_account_blob_id {
1677            tracing::info!(
1678                "Update TradeAccountImpl on Oracle from {:?} to new blob {:?}",
1679                trade_account_blob_id,
1680                ContractId::from(trade_account_blob.id)
1681            );
1682            TradeAccountDeploy::deploy_trade_account_blob(
1683                &deployer_wallet,
1684                &DeployConfig::Latest(Default::default()),
1685            )
1686            .await?;
1687            trade_account_oracle_deploy
1688                .oracle
1689                .methods()
1690                .set_trade_account_impl(ContractId::from(trade_account_blob.id))
1691                .call()
1692                .await?;
1693            trade_account_blob_id = ContractId::from(trade_account_blob.id);
1694        }
1695    }
1696
1697    Ok((trade_account_oracle_deploy, trade_account_blob_id))
1698}
1699
1700/// Deploy (or load) the dedicated trial trade account oracle and keep its
1701/// implementation current. The implementation follows the trade-account
1702/// implementation's lifecycle: seeded when the oracle has none, upgraded
1703/// under `should_upgrade_bytecode`. The cosigner is opt-in: providing one
1704/// also (re)deploys the implementation and configures the cosigner; without
1705/// one, the configured cosigner is left untouched. This runs before
1706/// ownership transfer since it writes to the oracle.
1707async fn deploy_trial_trade_account_oracle<W>(
1708    deployer_wallet: W,
1709    should_upgrade_bytecode: bool,
1710    trial_trade_account_oracle_id: Option<ContractId>,
1711    trial_cosigner: Option<fuels::types::Address>,
1712    salt: Salt,
1713) -> anyhow::Result<ContractId>
1714where
1715    W: Account + ViewOnlyAccount + Clone + 'static,
1716{
1717    let mut trial_deploy_config = TrialTradeAccountDeployConfig {
1718        salt,
1719        ..Default::default()
1720    };
1721    if let Some(cosigner) = trial_cosigner {
1722        trial_deploy_config = trial_deploy_config.with_cosigner(cosigner);
1723    }
1724    let deploy_config = TrialDeployConfig::Latest(trial_deploy_config);
1725
1726    let oracle_id = match trial_trade_account_oracle_id {
1727        None => {
1728            let trial_deploy =
1729                TrialTradeAccountDeploy::deploy(&deployer_wallet, &deploy_config).await?;
1730            tracing::info!(
1731                "TrialTradeAccountOracle: {} (implementation {:?}, cosigner {:?})",
1732                trial_deploy.oracle_id,
1733                trial_deploy.trial_trade_account_blob_id,
1734                trial_cosigner,
1735            );
1736            trial_deploy.oracle_id
1737        }
1738        Some(oracle_id) => {
1739            let current_trial_impl =
1740                TrialTradingAccountOracle::new(oracle_id, deployer_wallet.clone())
1741                    .methods()
1742                    .get_trial_account_impl()
1743                    .simulate(Execution::state_read_only())
1744                    .await?
1745                    .value;
1746            if current_trial_impl.is_none()
1747                || should_upgrade_bytecode
1748                || trial_cosigner.is_some()
1749            {
1750                let trial_deploy = TrialTradeAccountDeploy::deploy_to_oracle(
1751                    &deployer_wallet,
1752                    oracle_id,
1753                    &deploy_config,
1754                )
1755                .await?;
1756                tracing::info!(
1757                    "Trial implementation {:?} deployed to oracle {} (cosigner {:?})",
1758                    trial_deploy.trial_trade_account_blob_id,
1759                    oracle_id,
1760                    trial_cosigner,
1761                );
1762            }
1763            oracle_id
1764        }
1765    };
1766
1767    Ok(oracle_id)
1768}
1769
1770async fn deploy_trade_account_registry<W>(
1771    deployer_wallet: W,
1772    should_upgrade_bytecode: bool,
1773    trade_account_deploy: TradeAccountDeploy<W>,
1774    trial_trade_account_oracle_id: ContractId,
1775    trade_account_registry_id: Option<ContractId>,
1776    salt: Salt,
1777) -> anyhow::Result<(TradeAccountRegistryManager<W>, ContractId)>
1778where
1779    W: Account + ViewOnlyAccount + Clone + 'static,
1780{
1781    let trade_account_oracle_id = trade_account_deploy.oracle_id;
1782    let trade_account_registry = match trade_account_registry_id {
1783        Some(trade_account_registry_contract_id) => TradeAccountRegistryManager::new(
1784            deployer_wallet.clone(),
1785            trade_account_registry_contract_id,
1786        ),
1787        None => {
1788            let trade_account_registry_deploy_config = TradeAccountRegistryDeployConfig {
1789                salt,
1790                ..Default::default()
1791            };
1792            TradeAccountRegistryManager::deploy(
1793                &deployer_wallet,
1794                trade_account_oracle_id,
1795                trial_trade_account_oracle_id,
1796                &trade_account_registry_deploy_config,
1797            )
1798            .await?
1799        }
1800    };
1801    tracing::info!(
1802        "TradeAccountRegistry: {}",
1803        trade_account_registry.contract_id
1804    );
1805    let mut trade_account_registry_blob_id = match trade_account_registry
1806        .registry_proxy
1807        .methods()
1808        .proxy_target()
1809        .simulate(Execution::state_read_only())
1810        .await?
1811        .value
1812    {
1813        Some(blob_id) => blob_id,
1814        None => {
1815            tracing::info!("TradeAccountRegistry proxy target not set, initializing...");
1816            // Call initialize_proxy() to write INITIAL_OWNER and INITIAL_TARGET
1817            // from configurables to storage (upgrade/set_proxy_target would fail
1818            // because the owner is not yet in storage)
1819            trade_account_registry
1820                .registry_proxy
1821                .methods()
1822                .initialize_proxy()
1823                .call()
1824                .await?;
1825            trade_account_registry
1826                .registry
1827                .methods()
1828                .initialize()
1829                .call()
1830                .await?;
1831            trade_account_registry
1832                .registry_proxy
1833                .methods()
1834                .proxy_target()
1835                .simulate(Execution::state_read_only())
1836                .await?
1837                .value
1838                .context("TradeAccountRegistry proxy target should be set after initialization")?
1839        }
1840    };
1841
1842    if should_upgrade_bytecode {
1843        // The rebuilt blob carries whatever this config says and DEFAULTS
1844        // everything else — so the prop wiring has to be repeated here or
1845        // the upgrade silently zeroes it. See `live_prop_registry_config`.
1846        let trade_account_registry_deploy_config = TradeAccountRegistryDeployConfig {
1847            registry_config: trade_account_registry
1848                .live_prop_config(TradeAccountRegistryConfigurables::default())
1849                .await?,
1850            ..Default::default()
1851        };
1852        let trade_account_proxy_blob = TradeAccountRegistryManager::register_proxy_blob(
1853            &deployer_wallet,
1854            &trade_account_registry_deploy_config,
1855        )
1856        .await?;
1857        let trial_trade_account_proxy_blob =
1858            TradeAccountRegistryManager::register_trial_proxy_blob(
1859                &deployer_wallet,
1860                &trade_account_registry_deploy_config,
1861            )
1862            .await?;
1863
1864        let trade_account_register_blob = TradeAccountRegistryManager::register_blob(
1865            &deployer_wallet,
1866            trade_account_oracle_id,
1867            trial_trade_account_oracle_id,
1868            trade_account_proxy_blob.id,
1869            trial_trade_account_proxy_blob.id,
1870            &trade_account_registry_deploy_config,
1871        )
1872        .await?;
1873
1874        if trade_account_registry_blob_id
1875            != ContractId::from(trade_account_register_blob.id)
1876        {
1877            tracing::info!(
1878                "Upgrade TradeAccountRegistry blob from {:?} to {:?}",
1879                trade_account_registry.contract_id,
1880                ContractId::from(trade_account_register_blob.id)
1881            );
1882            // The SAME config the blob above was computed from: `upgrade`
1883            // rebuilds the blob internally, so passing a different one
1884            // would retarget the proxy at something we never compared.
1885            trade_account_registry
1886                .upgrade(
1887                    trade_account_oracle_id,
1888                    trial_trade_account_oracle_id,
1889                    &trade_account_registry_deploy_config,
1890                )
1891                .await?;
1892            trade_account_registry_blob_id = trade_account_register_blob.id.into();
1893        }
1894    }
1895    Ok((trade_account_registry, trade_account_registry_blob_id))
1896}
1897
1898async fn deploy_order_book_registry<W>(
1899    deployer_wallet: W,
1900    should_upgrade_bytecode: bool,
1901    order_book_registry_id: Option<ContractId>,
1902    salt: Salt,
1903) -> anyhow::Result<(OrderBookRegistryManager<W>, ContractId)>
1904where
1905    W: Account + ViewOnlyAccount + Clone + 'static,
1906{
1907    let order_book_registry = match order_book_registry_id {
1908        Some(registry_contract_id) => {
1909            OrderBookRegistryManager::new(deployer_wallet.clone(), registry_contract_id)
1910        }
1911        None => {
1912            OrderBookRegistryManager::deploy(
1913                &deployer_wallet,
1914                &OrderBookRegistryDeployConfig {
1915                    salt,
1916                    ..Default::default()
1917                },
1918            )
1919            .await?
1920        }
1921    };
1922    tracing::info!("OrderBookRegistry: {}", order_book_registry.contract_id);
1923    let mut order_book_registry_blob_id = match order_book_registry
1924        .registry_proxy
1925        .methods()
1926        .proxy_target()
1927        .simulate(Execution::state_read_only())
1928        .await?
1929        .value
1930    {
1931        Some(blob_id) => blob_id,
1932        None => {
1933            tracing::info!("OrderBookRegistry proxy target not set, initializing...");
1934            // Call initialize_proxy() to write INITIAL_OWNER and INITIAL_TARGET
1935            // from configurables to storage (upgrade/set_proxy_target would fail
1936            // because the owner is not yet in storage)
1937            order_book_registry
1938                .registry_proxy
1939                .methods()
1940                .initialize_proxy()
1941                .call()
1942                .await?;
1943            order_book_registry
1944                .registry
1945                .methods()
1946                .initialize()
1947                .call()
1948                .await?;
1949            order_book_registry
1950                .registry_proxy
1951                .methods()
1952                .proxy_target()
1953                .simulate(Execution::state_read_only())
1954                .await?
1955                .value
1956                .context(
1957                    "OrderBookRegistry proxy target should be set after initialization",
1958                )?
1959        }
1960    };
1961
1962    if should_upgrade_bytecode {
1963        let order_book_register_deploy_config = OrderBookRegistryDeployConfig::default();
1964        let order_book_register_blob = OrderBookRegistryManager::register_blob(
1965            &deployer_wallet,
1966            &order_book_register_deploy_config,
1967        )
1968        .await?;
1969        if order_book_registry_blob_id != order_book_register_blob.id.into() {
1970            tracing::info!(
1971                "Upgrade OrderBookRegistry blob from {:?} to {:?}",
1972                order_book_registry.contract_id,
1973                ContractId::from(order_book_register_blob.id)
1974            );
1975            order_book_registry
1976                .upgrade(&order_book_register_deploy_config)
1977                .await?;
1978            order_book_registry_blob_id = order_book_register_blob.id.into();
1979        }
1980    }
1981
1982    Ok((order_book_registry, order_book_registry_blob_id))
1983}
1984
1985async fn deploy_order_books<W>(
1986    deployer_wallet: W,
1987    should_upgrade_bytecode: bool,
1988    order_book_blacklist_id: Option<ContractId>,
1989    order_book_whitelist_id: Option<ContractId>,
1990    order_book_registry: OrderBookRegistryManager<W>,
1991    order_book_configs: &mut [OrderBookConfig],
1992    ownership_options: OwnershipTransferOptions,
1993) -> anyhow::Result<Vec<OrderBookConfig>>
1994where
1995    W: Account + ViewOnlyAccount + Clone + 'static,
1996{
1997    let mut pairs: Vec<OrderBookConfig> = Vec::with_capacity(order_book_configs.len());
1998
1999    for order_book_config in order_book_configs.iter_mut() {
2000        let pair = deploy_single_order_book(
2001            &deployer_wallet,
2002            should_upgrade_bytecode,
2003            order_book_blacklist_id,
2004            order_book_whitelist_id,
2005            &order_book_registry,
2006            order_book_config,
2007            &ownership_options,
2008        )
2009        .await?;
2010        pairs.push(pair);
2011    }
2012
2013    Ok(pairs)
2014}
2015
2016async fn deploy_single_order_book<W>(
2017    deployer_wallet: &W,
2018    should_upgrade_bytecode: bool,
2019    order_book_blacklist_id: Option<ContractId>,
2020    order_book_whitelist_id: Option<ContractId>,
2021    order_book_registry: &OrderBookRegistryManager<W>,
2022    order_book_config: &mut OrderBookConfig,
2023    ownership_options: &OwnershipTransferOptions,
2024) -> anyhow::Result<OrderBookConfig>
2025where
2026    W: Account + ViewOnlyAccount + Clone + 'static,
2027{
2028    let market_symbol = format!(
2029        "{}/{}",
2030        order_book_config.base.symbol, order_book_config.quote.symbol
2031    );
2032    let market_id = MarketIdAssets {
2033        base_asset: order_book_config.base.asset,
2034        quote_asset: order_book_config.quote.asset,
2035    };
2036    let order_book_configurables = build_order_book_configurables(
2037        order_book_config,
2038        order_book_blacklist_id,
2039        order_book_whitelist_id,
2040        deployer_wallet,
2041    )?;
2042
2043    let order_book = load_or_deploy_order_book(
2044        deployer_wallet,
2045        order_book_registry,
2046        &market_id,
2047        &market_symbol,
2048        &order_book_configurables,
2049        order_book_config,
2050    )
2051    .await?;
2052
2053    tracing::info!(
2054        "[{}] OrderBook: {}",
2055        market_symbol,
2056        order_book.contract.contract_id()
2057    );
2058
2059    let order_book_blob_id = maybe_upgrade_order_book(
2060        deployer_wallet,
2061        should_upgrade_bytecode,
2062        &order_book,
2063        order_book_config,
2064        order_book_configurables,
2065        &market_symbol,
2066    )
2067    .await?;
2068
2069    // Set the maintainer while the deployer is still the owner, i.e. before any
2070    // ownership transfer below (`owner_grant_role`/`owner_revoke_role` are
2071    // `only_owner`).
2072    set_order_book_maintainer(&order_book, ownership_options, &market_symbol).await?;
2073
2074    transfer_order_book_ownership(&order_book, ownership_options, &market_symbol).await?;
2075
2076    order_book_config.contract_id = Some(order_book.contract.contract_id());
2077    order_book_config.blob_id = order_book_blob_id.into();
2078
2079    Ok(order_book_config.clone())
2080}
2081
2082fn build_order_book_configurables<W: ViewOnlyAccount>(
2083    config: &OrderBookConfig,
2084    order_book_blacklist_id: Option<ContractId>,
2085    order_book_whitelist_id: Option<ContractId>,
2086    deployer_wallet: &W,
2087) -> anyhow::Result<OrderBookConfigurables> {
2088    let price_precision = config
2089        .quote
2090        .decimals
2091        .checked_sub(config.quote.max_precision)
2092        .ok_or_else(|| {
2093            anyhow::anyhow!(
2094                "quote max_precision ({}) exceeds decimals ({})",
2095                config.quote.max_precision,
2096                config.quote.decimals
2097            )
2098        })?;
2099    let quantity_precision = config
2100        .base
2101        .decimals
2102        .checked_sub(config.base.max_precision)
2103        .ok_or_else(|| {
2104            anyhow::anyhow!(
2105                "base max_precision ({}) exceeds decimals ({})",
2106                config.base.max_precision,
2107                config.base.decimals
2108            )
2109        })?;
2110
2111    Ok(OrderBookConfigurables::default()
2112        .with_MIN_ORDER(config.min_order)?
2113        .with_ALLOW_FRACTIONAL_PRICE(config.allow_fractional_price)?
2114        .with_TAKER_FEE(config.taker_fee.into())?
2115        .with_MAKER_FEE(config.maker_fee.into())?
2116        .with_DUST(config.dust)?
2117        .with_PRICE_WINDOW(config.price_window as u64)?
2118        .with_BASE_DECIMALS(10u64.pow(config.base.decimals as u32))?
2119        .with_QUOTE_DECIMALS(10u64.pow(config.quote.decimals as u32))?
2120        .with_BASE_SYMBOL(SizedAsciiString::new_with_right_whitespace_padding(
2121            config.base.symbol.clone(),
2122        )?)?
2123        .with_QUOTE_SYMBOL(SizedAsciiString::new_with_right_whitespace_padding(
2124            config.quote.symbol.clone(),
2125        )?)?
2126        .with_PRICE_PRECISION(10u64.pow(price_precision as u32))?
2127        .with_QUANTITY_PRECISION(10u64.pow(quantity_precision as u32))?
2128        .with_INITIAL_OWNER(o2_tools::order_book_deploy::State::Initialized(
2129            Identity::Address(ViewOnlyAccount::address(deployer_wallet)),
2130        ))?
2131        .with_WHITE_LIST_CONTRACT(order_book_whitelist_id)?
2132        .with_BLACK_LIST_CONTRACT(order_book_blacklist_id)?)
2133}
2134
2135async fn load_or_deploy_order_book<W>(
2136    deployer_wallet: &W,
2137    order_book_registry: &OrderBookRegistryManager<W>,
2138    market_id: &MarketIdAssets,
2139    market_symbol: &str,
2140    order_book_configurables: &OrderBookConfigurables,
2141    order_book_config: &OrderBookConfig,
2142) -> anyhow::Result<OrderBookManager<W>>
2143where
2144    W: Account + ViewOnlyAccount + Clone + 'static,
2145{
2146    let register_contract_id = order_book_registry
2147        .registry
2148        .methods()
2149        .get_order_book(to_registry_market_id(market_id))
2150        .simulate(Execution::state_read_only())
2151        .await?
2152        .value;
2153
2154    match register_contract_id {
2155        Some(contract_id) => {
2156            let order_book_deploy = OrderBookDeploy::new(
2157                deployer_wallet.clone(),
2158                contract_id,
2159                market_id.base_asset,
2160                market_id.quote_asset,
2161            );
2162            // Handle partially deployed contracts (e.g. previous deploy failed
2163            // after registering but before initializing the proxy)
2164            let proxy_target = order_book_deploy
2165                .order_book_proxy
2166                .methods()
2167                .proxy_target()
2168                .simulate(Execution::state_read_only())
2169                .await?
2170                .value;
2171            if proxy_target.is_none() {
2172                tracing::info!(
2173                    "[{}] Proxy target not set, initializing...",
2174                    market_symbol
2175                );
2176                order_book_deploy.initialize().await?;
2177            }
2178            Ok(OrderBookManager::new(
2179                deployer_wallet,
2180                10u64.pow(order_book_config.base.decimals as u32),
2181                10u64.pow(order_book_config.quote.decimals as u32),
2182                &order_book_deploy,
2183            ))
2184        }
2185        None => {
2186            let (order_book_deployment, initialization_required) =
2187                OrderBookDeploy::deploy_without_initialization(
2188                    deployer_wallet,
2189                    market_id.base_asset,
2190                    market_id.quote_asset,
2191                    &OrderBookDeployConfig {
2192                        order_book_configurables: order_book_configurables.clone(),
2193                        salt: Salt::from(*order_book_registry.contract_id),
2194                        ..Default::default()
2195                    },
2196                )
2197                .await?;
2198
2199            order_book_registry
2200                .register_order_book(
2201                    to_registry_market_id(market_id),
2202                    order_book_deployment.contract_id,
2203                )
2204                .await?;
2205
2206            if initialization_required {
2207                order_book_deployment.initialize().await?;
2208            }
2209            Ok(OrderBookManager::new(
2210                deployer_wallet,
2211                10u64.pow(order_book_config.base.decimals as u32),
2212                10u64.pow(order_book_config.quote.decimals as u32),
2213                &order_book_deployment,
2214            ))
2215        }
2216    }
2217}
2218
2219async fn maybe_upgrade_order_book<W>(
2220    deployer_wallet: &W,
2221    should_upgrade_bytecode: bool,
2222    order_book: &OrderBookManager<W>,
2223    order_book_config: &OrderBookConfig,
2224    order_book_configurables: OrderBookConfigurables,
2225    market_symbol: &str,
2226) -> anyhow::Result<ContractId>
2227where
2228    W: Account + ViewOnlyAccount + Clone + 'static,
2229{
2230    let mut order_book_blob_id = order_book
2231        .proxy
2232        .methods()
2233        .proxy_target()
2234        .simulate(Execution::state_read_only())
2235        .await?
2236        .value
2237        .context("Order book proxy target should be set after initialization")?;
2238
2239    if should_upgrade_bytecode {
2240        let order_book_deploy_config = OrderBookDeployConfig {
2241            order_book_configurables,
2242            ..Default::default()
2243        };
2244        let order_book_deploy = OrderBookDeploy::new(
2245            deployer_wallet.clone(),
2246            order_book.contract.contract_id(),
2247            order_book_config.base.asset,
2248            order_book_config.quote.asset,
2249        );
2250        let order_book_manager = OrderBookManager::new(
2251            deployer_wallet,
2252            10u64.pow(order_book_config.base.decimals as u32),
2253            10u64.pow(order_book_config.quote.decimals as u32),
2254            &order_book_deploy,
2255        );
2256        let order_book_blob = OrderBookDeploy::order_book_blob(
2257            deployer_wallet,
2258            order_book_config.base.asset,
2259            order_book_config.quote.asset,
2260            &order_book_deploy_config,
2261        )
2262        .await?;
2263
2264        if order_book_blob_id != order_book_blob.id.into() {
2265            tracing::info!(
2266                "[{}] Upgrade OrderBook blob from {:?} to {:?}",
2267                market_symbol,
2268                order_book_blob_id,
2269                ContractId::from(order_book_blob.id)
2270            );
2271            order_book_manager
2272                .upgrade(&order_book_deploy_config)
2273                .await?;
2274            tracing::info!(
2275                "[{}] Emit new configuration event for {}",
2276                market_symbol,
2277                order_book.contract.contract_id()
2278            );
2279            order_book_manager.emit_config().await?;
2280            order_book_blob_id = order_book_blob.id.into();
2281        }
2282    }
2283
2284    Ok(order_book_blob_id)
2285}
2286
2287async fn set_order_book_maintainer<W>(
2288    order_book: &OrderBookManager<W>,
2289    ownership_options: &OwnershipTransferOptions,
2290    market_symbol: &str,
2291) -> anyhow::Result<()>
2292where
2293    W: Account + ViewOnlyAccount + Clone + 'static,
2294{
2295    // Revoke the maintainer role from the previous holders first, so the role is
2296    // rotated rather than accumulating holders across upgrades. Revoking an
2297    // account that does not hold the role is a no-op on-chain.
2298    for account in &ownership_options.revoke_orderbook_maintainers {
2299        tracing::info!(
2300            "[{}] Revoking ORDERBOOK_MAINTAINER_ROLE from {}",
2301            market_symbol,
2302            account
2303        );
2304        order_book
2305            .contract
2306            .methods()
2307            .owner_revoke_role(ORDERBOOK_MAINTAINER_ROLE, Identity::Address(*account))
2308            .call()
2309            .await?;
2310    }
2311
2312    for account in &ownership_options.new_orderbook_maintainers {
2313        tracing::info!(
2314            "[{}] Granting ORDERBOOK_MAINTAINER_ROLE to {}",
2315            market_symbol,
2316            account
2317        );
2318        order_book
2319            .contract
2320            .methods()
2321            .owner_grant_role(ORDERBOOK_MAINTAINER_ROLE, Identity::Address(*account))
2322            .call()
2323            .await?;
2324    }
2325
2326    Ok(())
2327}
2328
2329async fn transfer_order_book_ownership<W>(
2330    order_book: &OrderBookManager<W>,
2331    ownership_options: &OwnershipTransferOptions,
2332    market_symbol: &str,
2333) -> anyhow::Result<()>
2334where
2335    W: Account + ViewOnlyAccount + Clone + 'static,
2336{
2337    if let Some(new_owner) = ownership_options.new_proxy_owner {
2338        let new_identity = Identity::Address(new_owner);
2339        tracing::info!(
2340            "[{}] Transferring OrderBook proxy ownership to {}",
2341            market_symbol,
2342            new_owner
2343        );
2344        order_book
2345            .proxy
2346            .methods()
2347            .set_owner(new_identity)
2348            .call()
2349            .await?;
2350    }
2351
2352    if let Some(new_owner) = ownership_options.new_contract_owner {
2353        let new_identity = Identity::Address(new_owner);
2354        tracing::info!(
2355            "[{}] Transferring OrderBook contract ownership to {}",
2356            market_symbol,
2357            new_owner
2358        );
2359        order_book
2360            .contract
2361            .methods()
2362            .transfer_ownership(new_identity)
2363            .call()
2364            .await?;
2365    }
2366
2367    Ok(())
2368}
2369
2370#[cfg(test)]
2371mod tests {
2372    use super::*;
2373
2374    #[test]
2375    fn load_config_empty_path_returns_default() {
2376        let result: MarketsConfigPartial = load_config_from_file("").unwrap();
2377        assert!(result.pairs.is_empty());
2378    }
2379
2380    #[test]
2381    fn load_config_missing_file_errors() {
2382        let result: Result<MarketsConfigPartial, _> =
2383            load_config_from_file("nonexistent_file_12345.json");
2384        assert!(result.is_err());
2385    }
2386
2387    #[test]
2388    fn checked_sub_catches_overflow() {
2389        // Validates that our checked_sub pattern works correctly
2390        let decimals: u32 = 6;
2391        let max_precision: u32 = 8; // greater than decimals
2392
2393        let result = decimals.checked_sub(max_precision);
2394        assert!(
2395            result.is_none(),
2396            "should return None when max_precision > decimals"
2397        );
2398
2399        // Normal case
2400        let result = 9u32.checked_sub(6);
2401        assert_eq!(result, Some(3));
2402    }
2403
2404    #[test]
2405    fn markets_config_partial_default_has_empty_pairs() {
2406        let config = MarketsConfigPartial::default();
2407        assert!(config.pairs.is_empty());
2408    }
2409}
2410
2411#[cfg(test)]
2412mod fee_amount_tests {
2413    use super::collateral_amount;
2414
2415    fn at9(value: &str) -> anyhow::Result<u64> {
2416        collateral_amount(value, 9, "tier 1 open_fee")
2417    }
2418
2419    #[test]
2420    fn whole_and_fractional_units_scale_by_the_asset_decimals() {
2421        assert_eq!(at9("7.5").unwrap(), 7_500_000_000);
2422        assert_eq!(at9("7").unwrap(), 7_000_000_000);
2423        assert_eq!(at9("0").unwrap(), 0);
2424        assert_eq!(at9("0.000000001").unwrap(), 1);
2425        assert_eq!(at9("13.5").unwrap(), 13_500_000_000);
2426        // The case a binary float gets wrong: `7.1 * 1e9` is 7099999999.99…
2427        assert_eq!(at9("7.1").unwrap(), 7_100_000_000);
2428    }
2429
2430    #[test]
2431    fn a_figure_the_asset_cannot_represent_is_refused_not_rounded() {
2432        let err = at9("7.0000000001").unwrap_err().to_string();
2433        assert!(err.contains("10 decimal places"), "{err}");
2434        assert!(err.contains("tier 1 open_fee"), "{err}");
2435    }
2436
2437    #[test]
2438    fn junk_is_refused() {
2439        for bad in ["", "   ", "-1", "1.2.3", "abc", "1e9", ".5", "1_000"] {
2440            assert!(at9(bad).is_err(), "`{bad}` should not parse");
2441        }
2442    }
2443
2444    #[test]
2445    fn an_amount_past_u64_is_refused_rather_than_wrapping() {
2446        assert!(at9("18446744074").is_err());
2447        assert_eq!(at9("18446744073.709551615").unwrap(), u64::MAX);
2448    }
2449
2450    #[test]
2451    fn other_decimal_scales_are_honoured() {
2452        assert_eq!(collateral_amount("7.5", 6, "f").unwrap(), 7_500_000);
2453        assert_eq!(collateral_amount("7.5", 1, "f").unwrap(), 75);
2454        assert!(collateral_amount("7.5", 0, "f").is_err());
2455        assert_eq!(collateral_amount("7", 0, "f").unwrap(), 7);
2456    }
2457}