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/// Access-control role on the prop margin pool that authorizes
81/// `set_user_discount` - the ONLY caller that function accepts, the admin
82/// included, so whoever prices user discounts must hold it.
83///
84/// Must match `DISCOUNT_MANAGER_ROLE` in
85/// `contracts/prop-margin-pool/src/main.sw`.
86const DISCOUNT_MANAGER_ROLE: u64 = 1;
87
88/// Deploy the independent prop-account system.
89///
90/// This is additive to the existing exchange deployment: callers can choose
91/// when to deploy the direct account oracle, mock price feed, proxied registry,
92/// and proxied margin pool.
93pub async fn deploy_prop_system<W>(
94    wallet: &W,
95    config: &PropDeployConfig,
96) -> anyhow::Result<PropDeployment<W>>
97where
98    W: Account + Clone,
99{
100    PropDeployment::deploy(wallet, config).await
101}
102
103fn to_registry_market_id(m: &MarketIdAssets) -> o2_tools::order_book_registry::MarketId {
104    o2_tools::order_book_registry::MarketId {
105        base_asset: m.base_asset,
106        quote_asset: m.quote_asset,
107    }
108}
109
110// ---------------------------------------------------------------------------
111// Types
112// ---------------------------------------------------------------------------
113
114#[serde_as]
115#[derive(Debug, serde::Serialize, Clone, Default)]
116pub struct MarketsConfigOutput {
117    pub starting_height: u32,
118    #[serde_as(as = "HexDisplayFromStr")]
119    pub trade_account_registry_id: ContractId,
120    #[serde_as(as = "HexDisplayFromStr")]
121    pub trade_account_registry_blob_id: ContractId,
122    #[serde_as(as = "HexDisplayFromStr")]
123    pub trade_account_oracle_id: ContractId,
124    #[serde_as(as = "HexDisplayFromStr")]
125    pub trial_trade_account_oracle_id: ContractId,
126    #[serde_as(as = "HexDisplayFromStr")]
127    pub trade_account_root: ContractId,
128    #[serde_as(as = "HexDisplayFromStr")]
129    pub trade_account_proxy: ContractId,
130    #[serde_as(as = "HexDisplayFromStr")]
131    pub trade_account_blob_id: ContractId,
132    #[serde_as(as = "Option<HexDisplayFromStr>")]
133    pub order_book_whitelist_id: Option<ContractId>,
134    #[serde_as(as = "Option<HexDisplayFromStr>")]
135    pub order_book_blacklist_id: Option<ContractId>,
136    #[serde_as(as = "HexDisplayFromStr")]
137    pub order_book_registry_id: ContractId,
138    #[serde_as(as = "HexDisplayFromStr")]
139    pub order_book_registry_blob_id: ContractId,
140    #[serde_as(as = "Option<HexDisplayFromStr>")]
141    pub fast_bridge_asset_registry_proxy_id: Option<ContractId>,
142    #[serde_as(as = "Option<HexDisplayFromStr>")]
143    pub price_feed_id: Option<ContractId>,
144    #[serde_as(as = "Option<HexDisplayFromStr>")]
145    pub margin_pool_id: Option<ContractId>,
146    #[serde_as(as = "Option<HexDisplayFromStr>")]
147    pub margin_oracle_id: Option<ContractId>,
148    /// The `margin` section, echoed back with the ids this run resolved
149    /// folded in. CI writes the output OVER the deploy config, so anything
150    /// the output drops is destroyed: without this the first margin deploy
151    /// would erase the tier catalogue, the pool config, the publishers and
152    /// the initial prices, and every later run would silently do no margin
153    /// work at all.
154    #[serde(skip_serializing_if = "Option::is_none")]
155    pub margin: Option<MarginConfig>,
156    pub pairs: Vec<OrderBookConfig>,
157}
158
159/// Intermediate type for deserializing order book configs with string-encoded numbers.
160#[serde_as]
161#[derive(Debug, Clone, serde::Deserialize)]
162struct OrderBookConfigDeHelper {
163    #[serde_as(as = "Option<serde_with::DisplayFromStr>")]
164    blob_id: Option<ContractId>,
165    #[serde_as(as = "Option<serde_with::DisplayFromStr>")]
166    contract_id: Option<ContractId>,
167    #[serde_as(as = "serde_with::DisplayFromStr")]
168    taker_fee: u64,
169    #[serde_as(as = "serde_with::DisplayFromStr")]
170    maker_fee: u64,
171    #[serde_as(as = "serde_with::DisplayFromStr")]
172    min_order: u64,
173    #[serde_as(as = "serde_with::DisplayFromStr")]
174    dust: u64,
175    price_window: u8,
176    #[serde(default)]
177    allow_fractional_price: bool,
178    base: AssetConfig,
179    quote: AssetConfig,
180}
181
182impl From<OrderBookConfigDeHelper> for OrderBookConfig {
183    fn from(h: OrderBookConfigDeHelper) -> Self {
184        let ids = MarketIdAssets {
185            base_asset: h.base.asset,
186            quote_asset: h.quote.asset,
187        };
188        let market_id = ids.market_id();
189        OrderBookConfig {
190            contract_id: h.contract_id,
191            blob_id: h.blob_id,
192            market_id,
193            taker_fee: h.taker_fee,
194            maker_fee: h.maker_fee,
195            min_order: h.min_order,
196            dust: h.dust,
197            price_window: h.price_window,
198            allow_fractional_price: h.allow_fractional_price,
199            base: h.base,
200            quote: h.quote,
201        }
202    }
203}
204
205#[derive(Debug, Clone, Default, serde::Serialize)]
206pub struct MarketsConfigPartial {
207    pub starting_height: u32,
208    pub trade_account_registry_id: Option<ContractId>,
209    pub order_book_registry_id: Option<ContractId>,
210    pub trade_account_oracle_id: Option<ContractId>,
211    pub trial_trade_account_oracle_id: Option<ContractId>,
212    pub order_book_whitelist_id: Option<ContractId>,
213    pub order_book_blacklist_id: Option<ContractId>,
214    pub fast_bridge_asset_registry_proxy_id: Option<ContractId>,
215    pub pairs: Vec<OrderBookConfig>,
216    /// Optional `margin` section: the prop-account margin stack (price
217    /// feed, pool, account oracle, in-place registry upgrade) plus the
218    /// tier catalogue, deployed/upgraded idempotently after the books.
219    pub margin: Option<MarginConfig>,
220}
221
222impl<'de> serde::Deserialize<'de> for MarketsConfigPartial {
223    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
224    where
225        D: serde::Deserializer<'de>,
226    {
227        #[derive(serde::Deserialize, Default)]
228        struct Helper {
229            #[serde(default)]
230            starting_height: u32,
231            trade_account_registry_id: Option<ContractId>,
232            order_book_registry_id: Option<ContractId>,
233            trade_account_oracle_id: Option<ContractId>,
234            trial_trade_account_oracle_id: Option<ContractId>,
235            order_book_whitelist_id: Option<ContractId>,
236            order_book_blacklist_id: Option<ContractId>,
237            fast_bridge_asset_registry_proxy_id: Option<ContractId>,
238            #[serde(default)]
239            pairs: Vec<OrderBookConfigDeHelper>,
240            #[serde(default)]
241            margin: Option<MarginConfig>,
242        }
243        let h = Helper::deserialize(deserializer)?;
244        if let Some(margin) = &h.margin
245            && margin.price_feed_id.is_some()
246        {
247            return Err(serde::de::Error::custom(
248                "`margin.price_feed_id` moved into `margin.price_feed.id`. \
249                 Refusing to load a config that uses the retired spelling: it \
250                 would be ignored, a fresh feed would be derived, and the pool \
251                 would be repointed at an oracle with no publisher. Move the id \
252                 (and `publishers` / `supported_assets`) under \
253                 `margin.price_feed`.",
254            ));
255        }
256        Ok(MarketsConfigPartial {
257            starting_height: h.starting_height,
258            trade_account_registry_id: h.trade_account_registry_id,
259            order_book_registry_id: h.order_book_registry_id,
260            trade_account_oracle_id: h.trade_account_oracle_id,
261            trial_trade_account_oracle_id: h.trial_trade_account_oracle_id,
262            order_book_whitelist_id: h.order_book_whitelist_id,
263            order_book_blacklist_id: h.order_book_blacklist_id,
264            fast_bridge_asset_registry_proxy_id: h.fast_bridge_asset_registry_proxy_id,
265            pairs: h.pairs.into_iter().map(Into::into).collect(),
266            margin: h.margin,
267        })
268    }
269}
270
271/// One tier of the margin catalogue. Mirrors the on-chain `TierParams`
272/// plus the tier id and the markets (order books) it covers. `line`
273/// follows the config convention of u64 amounts as strings.
274#[serde_as]
275#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
276#[serde(deny_unknown_fields)]
277pub struct MarginTierConfig {
278    pub tier_id: u64,
279    /// Credit line in collateral-asset base units, as a decimal string.
280    #[serde_as(as = "serde_with::DisplayFromStr")]
281    pub line: u64,
282    pub leverage: u64,
283    pub duration: u64,
284    pub maintenance_bps: u64,
285    pub open_buffer_bps: u64,
286    pub liq_price_factor: u64,
287    /// Flat fee for one prolongation of each period, in WHOLE collateral
288    /// units as a decimal string - `"13.5"` at 9 decimals means
289    /// `13_500_000_000` base units. Indexed six hours, day, week, month; a
290    /// zero means the period is not offered.
291    pub prolong_fee: [String; 4],
292    pub max_credit_line_bps: u64,
293    pub max_price_age: u64,
294    /// Flat fee charged once at open, in WHOLE collateral units as a decimal
295    /// string. Same form as `prolong_fee`.
296    pub open_fee: String,
297    pub profit_share_bps: u64,
298    pub price_band_bps: u64,
299    /// Markets in this tier, referencing already-configured pairs by
300    /// "BASE/QUOTE" symbol or by hex market id.
301    pub markets: Vec<String>,
302    /// The prepaid ("Turbo") sidecar. Its PRESENCE is what makes the tier
303    /// prepaid: the version is published with `publish_tier_v2` and carries
304    /// terms, and every reader keys the cohort off exactly that. Absent is
305    /// the legacy tier, published as it always was.
306    ///
307    /// Immutable once published - no setter exists on chain - so a change
308    /// here is a NEW VERSION, exactly like a change to the economics.
309    #[serde(default, skip_serializing_if = "Option::is_none")]
310    pub turbo: Option<MarginTurboConfig>,
311    /// Whether the tier SELLS. Gates `open_session` only: a live session
312    /// keeps every lifecycle call it was sold with, which is what lets an
313    /// old catalogue be retired without stranding anybody on it.
314    ///
315    /// Defaults to `true`, so an existing config keeps selling exactly what
316    /// it sold before this field existed.
317    #[serde(default = "tier_enabled_default")]
318    pub enabled: bool,
319}
320
321/// Default for [`MarginTierConfig::enabled`] - a tier with no opinion sells.
322fn tier_enabled_default() -> bool {
323    true
324}
325
326/// The prepaid terms attached to a tier version, mirroring the pool's
327/// `TurboTerms`. Published ATOMICALLY with the economics and the books, and
328/// immutable afterwards.
329#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
330#[serde(deny_unknown_fields)]
331pub struct MarginTurboConfig {
332    /// The entry is a PRICE, not a deposit: whatever of the funded
333    /// collateral survives the session goes to the platform rather than
334    /// back to the parent.
335    pub forfeit_collateral: bool,
336    /// The drawdown ESCALATOR in bps of `line`, indexed by rollovers used
337    /// and clamped at the last entry. All zero leaves the tier's static
338    /// floor alone; any non-zero entry opts the tier in.
339    pub max_loss_bps: [u64; 4],
340    /// Profit that EARNS one free rollover, in bps of `line`. Zero means
341    /// the tier offers no rollover.
342    pub rollover_profit_bps: u64,
343    /// Consecutive free rollovers one session may earn.
344    pub max_rollovers: u64,
345}
346
347impl From<&MarginTurboConfig> for o2_tools::prop::TurboTerms {
348    fn from(turbo: &MarginTurboConfig) -> Self {
349        Self {
350            forfeit_collateral: turbo.forfeit_collateral,
351            max_loss_bps: turbo.max_loss_bps,
352            rollover_profit_bps: turbo.rollover_profit_bps,
353            max_rollovers: turbo.max_rollovers,
354        }
355    }
356}
357
358/// One asset the price feed must know the decimals of. Registering the
359/// decimals is what makes an asset SUPPORTED by the feed; publishing a
360/// price for it is a separate, later act. Declaring assets here therefore
361/// lets a freshly deployed feed be configured up front, before any
362/// publisher has produced a single quote.
363#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
364pub struct MarginSupportedAsset {
365    pub asset: fuels::types::AssetId,
366    pub decimals: u8,
367}
368
369/// The `price_feed` sub-section: everything about the ORACLE the pool values
370/// against, kept apart from the pool's own settings because it is a separate
371/// contract with its own lifecycle, ownership and roles.
372#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
373pub struct MarginPriceFeedConfig {
374    /// Reuse an existing feed. `None` deploys the production price feed
375    /// behind its own SRC-14 proxy.
376    pub id: Option<ContractId>,
377    /// Submission-age bound in seconds, reconciled compare-first. `None`
378    /// leaves whatever the feed enforces today.
379    pub max_offchain_age_seconds: Option<u64>,
380    /// Identities that must HOLD the submitter role, each prefixed with its
381    /// kind (`address:0x..` for a wallet, `contract:0x..` for a publishing
382    /// contract). Reconciled compare-first: an identity that already holds it
383    /// costs no transaction. When empty, nothing is granted and only the
384    /// feed's owner may publish.
385    #[serde(default)]
386    pub publishers: Vec<String>,
387    /// Identities that must NOT hold the submitter role, same prefixed form,
388    /// revoked only if currently held.
389    ///
390    /// An explicit list on purpose. The feed stores roles as a bitmap keyed
391    /// BY ACCOUNT with no reverse index, so neither the contract nor this
392    /// deploy can enumerate who holds the role - "revoke everyone not in
393    /// `publishers`" is not a question that can be asked on chain, and
394    /// inferring it from config absence would silently kick out a publisher
395    /// granted out of band.
396    ///
397    /// Revoking stops FUTURE writes only. A price the publisher already
398    /// pushed stays live until it ages out or is invalidated explicitly.
399    #[serde(default)]
400    pub remove_publishers: Vec<String>,
401    /// Assets the feed supports, registered by their decimals before any price
402    /// exists. Reconciled compare-first: an asset the feed already knows at the
403    /// SAME decimals is skipped, an unknown one is registered, and a
404    /// DISAGREEMENT is refused rather than overwritten. The pool pins an
405    /// asset's decimals on first use and rejects a later change, so silently
406    /// moving them here would strand it.
407    #[serde(default)]
408    pub supported_assets: Vec<MarginSupportedAsset>,
409}
410
411/// Optional `margin` section of the deploy config: the prop margin pool,
412/// the prop account oracle, the price feed and the tier catalogue.
413#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
414pub struct MarginConfig {
415    /// The price feed: its own contract, its own section.
416    #[serde(default)]
417    pub price_feed: MarginPriceFeedConfig,
418    /// The RETIRED flat spelling of `price_feed.id`, present only so that a
419    /// stale config is REJECTED rather than silently ignored.
420    ///
421    /// Silence here is catastrophic rather than untidy: an unread feed id
422    /// means the run derives a FRESH feed, and the pool-feed reconcile then
423    /// moves a live pool onto an oracle nobody publishes to. Every
424    /// valuation, liquidation and session open fails afterwards.
425    ///
426    /// Never serialized, so the write-back drops it once migrated.
427    #[serde(default, skip_serializing)]
428    pub price_feed_id: Option<ContractId>,
429    /// TIER-ONLY mode: run no system deploy, only reconcile the tier
430    /// catalogue (and the feed's roster/assets when configured) against
431    /// this already-deployed pool.
432    pub margin_pool_id: Option<ContractId>,
433    /// Identity receiving the platform profit share, prefixed with its
434    /// kind (`address:0x..` / `contract:0x..`). Defaults to the deployer.
435    pub platform_payout: Option<String>,
436    /// Identities that must HOLD the pool's discount-manager role - the
437    /// only role `set_user_discount` accepts, the admin included - each
438    /// prefixed with its kind (`address:0x..` / `contract:0x..`).
439    /// Reconciled compare-first: an identity that already holds it costs
440    /// no transaction. When empty, nothing is granted and NOBODY can
441    /// price user discounts.
442    ///
443    /// A ROSTER, so it lives in the config beside the feed's `publishers`
444    /// rather than in a deploy flag: the cosigner and the liquidator are
445    /// flags because they name the operator's own key and payee, while
446    /// this is a list the deployment owns and carries forward run to run.
447    #[serde(default)]
448    pub discount_managers: Vec<String>,
449    /// Identities that must NOT hold the discount-manager role, same
450    /// prefixed form, revoked only if currently held.
451    ///
452    /// An explicit list for the same reason `remove_publishers` is one:
453    /// roles are a bitmap keyed BY ACCOUNT with no reverse index, so
454    /// "revoke everyone not in `discount_managers`" is not a question
455    /// that can be asked on chain, and inferring it from config absence
456    /// would silently kick out a manager granted out of band.
457    ///
458    /// Revoking stops FUTURE writes only: discounts the manager already
459    /// granted stay in force until cleared.
460    #[serde(default)]
461    pub remove_discount_managers: Vec<String>,
462    /// Collateral asset of the pool. Defaults to the quote asset of the
463    /// first configured pair.
464    pub collateral_asset: Option<fuels::types::AssetId>,
465    /// Collateral asset decimals. REQUIRED: a wrong figure misprices every
466    /// valuation the pool makes, so there is no default to fall back to.
467    pub collateral_decimals: Option<u8>,
468    /// Maximum order books one tier version may hold. Defaults to 80.
469    pub max_tier_books: Option<u64>,
470    /// Fee charged by `repay_base_from_collateral`, in PARTS PER MILLION of the
471    /// quote value of the debt closed - the order book's `TAKER_FEE` scale, so
472    /// `100` is one basis point. Defaults to 100.
473    ///
474    /// It is a pool CONFIGURABLE, so changing it here only takes effect on a
475    /// deploy or an implementation upgrade (`--upgrade-bytecode`), never on a
476    /// tier-only reconcile against an already-deployed pool.
477    pub base_repay_fee_ppm: Option<u64>,
478    /// The desired tier catalogue; reconciled idempotently (§ tier engine):
479    /// unchanged tiers are untouched, grown book lists use `add_books`,
480    /// any param change publishes a NEW version.
481    #[serde(default)]
482    pub tiers: Vec<MarginTierConfig>,
483}
484
485#[derive(Debug, Clone, Default)]
486pub struct OwnershipTransferOptions {
487    pub new_proxy_owner: Option<fuels::types::Address>,
488    pub new_contract_owner: Option<fuels::types::Address>,
489    /// Accounts to revoke `ORDERBOOK_MAINTAINER_ROLE` from on each order book
490    /// (via `owner_revoke_role`) before granting the new maintainers, so the role
491    /// is rotated rather than accumulating holders across upgrades.
492    pub revoke_orderbook_maintainers: Vec<fuels::types::Address>,
493    /// Accounts to grant `ORDERBOOK_MAINTAINER_ROLE` to on each order book (via
494    /// `owner_grant_role`) before ownership is transferred away.
495    pub new_orderbook_maintainers: Vec<fuels::types::Address>,
496}
497
498/// Parameters for a deploy invocation.
499#[derive(Debug, Clone)]
500pub struct DeployParams {
501    pub deploy_config: MarketsConfigPartial,
502    pub output: Option<String>,
503    pub deploy_whitelist: bool,
504    pub deploy_blacklist: bool,
505    pub upgrade_bytecode: bool,
506    pub new_proxy_owner: Option<fuels::types::Address>,
507    pub new_contract_owner: Option<fuels::types::Address>,
508    /// Cosigner address for trial trade accounts. When set, the trial trade
509    /// account implementation is (re)deployed on the trial oracle and this
510    /// cosigner is configured on it; when absent, the configured cosigner is
511    /// left untouched (the implementation still follows the regular
512    /// seed/upgrade lifecycle).
513    pub trial_cosigner: Option<fuels::types::Address>,
514    /// Identity allowed to register (activate) trial trade accounts on the
515    /// registry. When set, it is written via
516    /// `set_trial_trade_account_creator` if it differs from the current one
517    /// — registries upgraded in place start with the creator unset, which
518    /// denies all trial registrations. When absent, the creator is left
519    /// untouched.
520    pub trial_creator: Option<Identity>,
521    /// Reconcile margin CONFIGURATION only, leaving BYTECODE — the pool, the
522    /// oracle, the price-feed implementation — and the registry's prop wiring
523    /// untouched.
524    ///
525    /// It does still reconcile the things that are configuration rather than
526    /// code: the tier catalogue, the feed's publisher roster, its supported
527    /// assets, its submission-age bound, and the pool's feed pointer. That is
528    /// the point of the mode — rotating a publisher must not need a redeploy.
529    ///
530    /// An explicit switch rather than an inference from the markets
531    /// config: the mode decides WHETHER the system phase runs, which is
532    /// unrelated to WHICH pool the config names. Conflating the two meant
533    /// the ordinary steady state disabled the phase that owns the
534    /// registry's prop configurables, so an upgrade that dropped them was
535    /// never repaired.
536    pub margin_tier_only: bool,
537    /// Cosigner address for prop/margin accounts. When set, it is written to
538    /// the prop account oracle if it differs from the live one; when absent,
539    /// the configured cosigner is left untouched. Deliberately NOT sourced
540    /// from the deploy config and NOT defaulted to the deployer: a cosigner
541    /// the backend does not hold the key for makes margin silently inert.
542    pub margin_cosigner: Option<fuels::types::Address>,
543    /// Recipient of the residue from forced margin exits (liquidation and
544    /// expiry). Same opt-in rule as `margin_cosigner`: set it and it is
545    /// reconciled, omit it and the live value stands. Never defaulted to the
546    /// deployer - the deploy key must not silently become the payee.
547    pub margin_liquidator: Option<Identity>,
548    pub revoke_orderbook_maintainers: Vec<fuels::types::Address>,
549    pub new_orderbook_maintainers: Vec<fuels::types::Address>,
550}
551
552// ---------------------------------------------------------------------------
553// Helpers
554// ---------------------------------------------------------------------------
555
556/// Load a JSON config file, returning `T::default()` when the path is empty.
557pub fn load_config_from_file<T>(config_path: &str) -> anyhow::Result<T>
558where
559    T: Default + serde::de::DeserializeOwned,
560{
561    if config_path.is_empty() {
562        return Ok(T::default());
563    }
564    let current_dir = std::env::current_dir()?;
565    let path = current_dir.join(config_path);
566    tracing::info!("Loading config from {}", path.display());
567    let file = std::fs::File::open(&path)?;
568    let config: T = serde_json::from_reader(file)?;
569    Ok(config)
570}
571
572// ---------------------------------------------------------------------------
573// Core deploy logic
574// ---------------------------------------------------------------------------
575
576/// Deploy (or upgrade) the full set of O2 contracts.
577///
578/// The wallet type `W` must implement `Account + Clone + Signer` (e.g.
579/// `fuels::prelude::WalletUnlocked` or the KMS-backed `O2Wallet` from the API).
580pub async fn deploy<W>(
581    wallet: W,
582    params: DeployParams,
583) -> anyhow::Result<MarketsConfigOutput>
584where
585    W: Account + ViewOnlyAccount + Clone + 'static,
586{
587    tracing::info!("Starting Fuel o2 Registries and Markets");
588    let mut markets_config_partial = params.deploy_config.clone();
589    let starting_height: BlockHeight = markets_config_partial.starting_height.into();
590    let trade_account_oracle_id = markets_config_partial.trade_account_oracle_id;
591    let trial_trade_account_oracle_id =
592        markets_config_partial.trial_trade_account_oracle_id;
593    let order_book_registry_id = markets_config_partial.order_book_registry_id;
594    let trade_account_registry_id = markets_config_partial.trade_account_registry_id;
595    let fast_bridge_asset_registry_proxy_id =
596        markets_config_partial.fast_bridge_asset_registry_proxy_id;
597
598    let mut salt = Salt::zeroed();
599    salt.deref_mut()[..4].copy_from_slice(&starting_height.deref().to_be_bytes());
600
601    let (trade_account_oracle_deploy, trade_account_blob_id) =
602        deploy_trade_account_oracle(
603            wallet.clone(),
604            params.upgrade_bytecode,
605            trade_account_oracle_id,
606            salt,
607        )
608        .await?;
609    // The trial oracle is a dedicated contract (already-deployed trade
610    // account oracles are immutable and cannot gain the trial functions), so
611    // it deploys separately and the registry references both oracle ids.
612    let trial_trade_account_oracle_id = deploy_trial_trade_account_oracle(
613        wallet.clone(),
614        params.upgrade_bytecode,
615        trial_trade_account_oracle_id,
616        params.trial_cosigner,
617        salt,
618    )
619    .await?;
620    let (trade_account_registry, trade_account_registry_blob_id) =
621        deploy_trade_account_registry(
622            wallet.clone(),
623            params.upgrade_bytecode,
624            trade_account_oracle_deploy.clone(),
625            trial_trade_account_oracle_id,
626            trade_account_registry_id,
627            salt,
628        )
629        .await?;
630    let order_book_blacklist_id = deploy_order_book_blacklist(
631        wallet.clone(),
632        params.deploy_blacklist,
633        markets_config_partial.order_book_blacklist_id,
634        salt,
635    )
636    .await?;
637    let order_book_whitelist_id = deploy_order_book_whitelist(
638        wallet.clone(),
639        params.deploy_whitelist,
640        markets_config_partial.order_book_whitelist_id,
641        salt,
642    )
643    .await?;
644    let (order_book_registry, order_book_registry_blob_id) = deploy_order_book_registry(
645        wallet.clone(),
646        params.upgrade_bytecode,
647        order_book_registry_id,
648        salt,
649    )
650    .await?;
651    let pairs = deploy_order_books(
652        wallet.clone(),
653        params.upgrade_bytecode,
654        order_book_blacklist_id,
655        order_book_whitelist_id,
656        order_book_registry.clone(),
657        &mut markets_config_partial.pairs,
658        OwnershipTransferOptions {
659            new_proxy_owner: params.new_proxy_owner,
660            new_contract_owner: params.new_contract_owner,
661            revoke_orderbook_maintainers: params.revoke_orderbook_maintainers.clone(),
662            new_orderbook_maintainers: params.new_orderbook_maintainers.clone(),
663        },
664    )
665    .await?;
666
667    let order_book_registry_id = order_book_registry.contract_id;
668    let trade_account_registry_id = trade_account_registry.contract_id;
669    let trade_account_oracle_id = trade_account_oracle_deploy.oracle_id;
670
671    let trade_account_proxy = trade_account_registry
672        .registry
673        .methods()
674        .default_bytecode()
675        .simulate(Execution::state_read_only())
676        .await?
677        .value
678        .context(
679            "Trade account registry default bytecode should exist after initialization",
680        )?;
681    let trade_account_root = trade_account_registry
682        .registry
683        .methods()
684        .factory_bytecode_root()
685        .simulate(Execution::state_read_only())
686        .await?
687        .value
688        .context("Trade account registry factory bytecode root should exist after initialization")?;
689
690    // The margin phase runs BEFORE ownership transfer: the in-place
691    // registry upgrade and the pool/tier governance calls need the deployer
692    // to still hold the proxies and the pool admin role.
693    let margin_ids = match &markets_config_partial.margin {
694        Some(margin) => Some(
695            deploy_margin(
696                &wallet,
697                margin,
698                salt,
699                params.margin_tier_only,
700                trade_account_registry_id,
701                trade_account_oracle_id,
702                trial_trade_account_oracle_id,
703                &pairs,
704                params.margin_cosigner,
705                params.margin_liquidator,
706                params.upgrade_bytecode,
707            )
708            .await?,
709        ),
710        None => None,
711    };
712
713    // Registries upgraded in place start with the trial creator unset (the
714    // configurable seed only applies on the first initialize), so the deploy
715    // writes it explicitly when provided. Must run before ownership transfer.
716    if let Some(trial_creator) = params.trial_creator {
717        let current_creator = trade_account_registry
718            .get_trial_trade_account_creator()
719            .await?;
720        if current_creator != trial_creator {
721            tracing::info!("Setting trial trade account creator to {trial_creator:?}");
722            trade_account_registry
723                .set_trial_trade_account_creator(trial_creator)
724                .await?;
725        }
726    }
727
728    transfer_ownership(
729        &wallet,
730        &params,
731        &order_book_registry,
732        &trade_account_registry,
733        &trade_account_oracle_deploy,
734        trial_trade_account_oracle_id,
735        order_book_blacklist_id,
736        order_book_whitelist_id,
737    )
738    .await?;
739
740    let deploy_result = MarketsConfigOutput {
741        starting_height: starting_height.into(),
742        trade_account_registry_id,
743        trade_account_registry_blob_id,
744        trade_account_proxy,
745        trade_account_blob_id,
746        trade_account_root: ContractId::from(trade_account_root.0),
747        trade_account_oracle_id,
748        trial_trade_account_oracle_id,
749        order_book_whitelist_id,
750        order_book_blacklist_id,
751        order_book_registry_id,
752        order_book_registry_blob_id,
753        pairs,
754        fast_bridge_asset_registry_proxy_id,
755        price_feed_id: margin_ids.map(|ids| ids.price_feed_id),
756        margin_pool_id: margin_ids.map(|ids| ids.margin_pool_id),
757        margin_oracle_id: margin_ids.and_then(|ids| ids.margin_oracle_id),
758        // Carry the section through, with the resolved ids written back so a
759        // re-run reads them and drops into tier-only mode against the pool it
760        // already deployed. A DRY RUN must not: its ids are predictions for
761        // contracts that were never created, and writing them would send the
762        // next real run into tier-only mode against a pool that does not
763        // exist, skipping the system deploy entirely.
764        margin: markets_config_partial.margin.clone().map(|mut margin| {
765            if let Some(ids) = margin_ids {
766                margin.price_feed.id = Some(ids.price_feed_id);
767                margin.margin_pool_id = Some(ids.margin_pool_id);
768            }
769            margin
770        }),
771    };
772
773    if let Some(output_path) = params.output {
774        let json = serde_json::to_string_pretty(&deploy_result)?;
775        tracing::info!("Deploy result saved to {}", output_path);
776        std::fs::write(output_path, json)?;
777    }
778
779    Ok(deploy_result)
780}
781
782// ---------------------------------------------------------------------------
783// Margin (prop) deployment
784// ---------------------------------------------------------------------------
785
786/// The margin stack's resolved ids, echoed on the deploy output.
787#[derive(Debug, Clone, Copy)]
788pub struct MarginIds {
789    pub price_feed_id: ContractId,
790    pub margin_pool_id: ContractId,
791    pub margin_oracle_id: Option<ContractId>,
792}
793
794/// The margin phase of [`deploy`]: the prop system deployed/resumed
795/// deterministically (salted ids, exists-checked steps), the SHARED
796/// trade-account registry upgraded IN PLACE to carry the prop
797/// configurables, and the tier catalogue reconciled. With `tier_only` the
798/// system deploy is skipped and only the feed and tier reconciliation runs
799/// against the pool the config names.
800///
801/// Every step is compare-first: it reads the live value and sends nothing
802/// when the chain already agrees. A settled system therefore costs a run of
803/// reads and no transactions, which is what a rehearsal used to be for.
804#[allow(clippy::too_many_arguments)]
805async fn deploy_margin<W>(
806    wallet: &W,
807    margin: &MarginConfig,
808    salt: fuels::types::Salt,
809    tier_only: bool,
810    trade_account_registry_id: ContractId,
811    trade_account_oracle_id: ContractId,
812    trial_trade_account_oracle_id: ContractId,
813    pairs: &[OrderBookConfig],
814    margin_cosigner: Option<fuels::types::Address>,
815    margin_liquidator: Option<Identity>,
816    upgrade_bytecode: bool,
817) -> anyhow::Result<MarginIds>
818where
819    W: Account + ViewOnlyAccount + Clone + 'static,
820{
821    use o2_tools::prop::{
822        PriceFeedContract,
823        PropAccountOracleContract,
824        PropMarginPoolContract,
825    };
826
827    let collateral_asset = match margin.collateral_asset {
828        Some(collateral_asset) => collateral_asset,
829        None => {
830            pairs
831                .first()
832                .context(
833                    "margin.collateral_asset is not set and there are no pairs to \
834                     default it from",
835                )?
836                .quote
837                .asset
838        }
839    };
840    // The cosigner and the liquidator arrive as DEPLOY PARAMETERS, not from
841    // the config file: both name a key or a payee that belongs to the
842    // operator running the deploy, and both are opt-in - provide one and it
843    // is reconciled, omit it and whatever is live stands.
844    let cosigner = margin_cosigner;
845    let platform_payout = margin
846        .platform_payout
847        .as_deref()
848        .map(parse_margin_identity)
849        .transpose()?;
850    let liquidator = margin_liquidator;
851
852    let mut prop_config = PropDeployConfig::new(collateral_asset);
853    // No default: a wrong decimals figure misprices every valuation the pool
854    // makes, and it is invisible until money moves.
855    prop_config.collateral_decimals = margin.collateral_decimals.context(
856        "margin.collateral_decimals is required - set it to the collateral \
857         asset's decimals in the deploy config",
858    )?;
859    prop_config.max_tier_books = margin.max_tier_books.unwrap_or(80);
860    prop_config.base_repay_fee_ppm = margin.base_repay_fee_ppm.unwrap_or(100);
861    prop_config.cosigner = cosigner;
862    prop_config.platform_payout = platform_payout;
863    prop_config.liquidator = liquidator;
864    prop_config.salt = salt;
865    prop_config.existing_price_feed = margin.price_feed.id;
866    prop_config.max_offchain_age_seconds = margin.price_feed.max_offchain_age_seconds;
867    // The config names WHICH pool; it no longer decides whether the system
868    // phase runs. Pinning it also keeps an upgrade from forking the pool
869    // when a release changes the pool bytecode.
870    prop_config.existing_pool = margin.margin_pool_id;
871    prop_config.existing_registry = Some(ExistingRegistry {
872        registry_id: trade_account_registry_id,
873        trade_account_oracle_id,
874        trial_trade_account_oracle_id,
875    });
876
877    anyhow::ensure!(
878        !(tier_only && margin.margin_pool_id.is_none()),
879        "--margin-tier-only was requested but the markets config names no \
880         `margin.margin_pool_id`: there is no pool to reconcile tiers against"
881    );
882
883    // Resolve the system: tier-only mode, or a real deploy-or-resume.
884    let (price_feed_id, margin_pool_id, margin_oracle_id) = if let (
885        true,
886        Some(margin_pool_id),
887    ) =
888        (tier_only, margin.margin_pool_id)
889    {
890        tracing::info!("Margin: tier-only mode against pool {margin_pool_id}");
891        let price_feed_id = match margin.price_feed.id {
892            Some(price_feed_id) => price_feed_id,
893            None => {
894                PropMarginPoolContract::new(margin_pool_id, wallet.clone())
895                    .methods()
896                    .price_feed()
897                    .simulate(Execution::state_read_only())
898                    .await
899                    .context("read the pool's price feed")?
900                    .value
901            }
902        };
903        // Read the oracle back off the registry rather than reporting
904        // `None`. The caller writes what it gets straight into
905        // markets.json, so a `None` here NULLS a live, known-good id —
906        // the same class of mistake as writing ids a rehearsal never
907        // deployed.
908        let margin_oracle_id =
909            TradeAccountRegistryManager::new(wallet.clone(), trade_account_registry_id)
910                .registry
911                .methods()
912                .get_prop_oracle_id()
913                .simulate(Execution::state_read_only())
914                .await
915                .map(|result| result.value)
916                .ok()
917                .filter(|oracle_id| *oracle_id != ContractId::zeroed());
918        (price_feed_id, margin_pool_id, margin_oracle_id)
919    } else {
920        let deployment = deploy_prop_system(wallet, &prop_config).await?;
921        tracing::info!(
922            "Margin: oracle {}, pool {}, feed {} (shared registry {} upgraded in place)",
923            deployment.oracle_id,
924            deployment.pool_id,
925            deployment.price_feed_id,
926            deployment.registry_id,
927        );
928        (
929            deployment.price_feed_id,
930            deployment.pool_id,
931            Some(deployment.oracle_id),
932        )
933    };
934
935    // -- the payout parties ----------------------------------------------
936    // Reconciled compare-first against the LIVE pool, so a re-run is a
937    // no-op and an absent config key never overwrites what the pool has.
938    // A pool not deployed yet gets both values through its INITIAL_*
939    // configurables on the real run.
940    let pool_deployed = wallet
941        .try_provider()?
942        .contract_exists(&margin_pool_id)
943        .await?;
944    if pool_deployed {
945        let pool = PropMarginPoolContract::new(margin_pool_id, wallet.clone());
946        if let Some(platform_payout) = platform_payout {
947            let current = pool
948                .methods()
949                .platform_payout()
950                .simulate(Execution::state_read_only())
951                .await
952                .context("read the pool's platform payout")?
953                .value;
954            if current != platform_payout {
955                tracing::info!(
956                    "Margin: platform payout {current:?} -> {platform_payout:?}"
957                );
958                pool.methods()
959                    .set_platform_payout(platform_payout)
960                    .call()
961                    .await?;
962            }
963        }
964        if let Some(liquidator) = liquidator {
965            let current = pool
966                .methods()
967                .liquidator()
968                .simulate(Execution::state_read_only())
969                .await
970                .context("read the pool's liquidator")?
971                .value;
972            if current != liquidator {
973                tracing::info!("Margin: liquidator {current:?} -> {liquidator:?}");
974                pool.methods().set_liquidator(liquidator).call().await?;
975            }
976        }
977
978        // -- the discount managers -----------------------------------------
979        // The feed's publisher roster, part for part: granted and revoked
980        // only where the pool disagrees with the config, so a settled
981        // roster sends nothing at all.
982        //
983        // An identity in BOTH lists would be granted and then revoked in
984        // the same run, silently, decided by iteration order. Refuse the
985        // ambiguity.
986        for entry in &margin.discount_managers {
987            anyhow::ensure!(
988                !margin.remove_discount_managers.contains(entry),
989                "discount manager {entry} is listed in both \
990                 `discount_managers` and `remove_discount_managers`"
991            );
992        }
993        for (entry, should_hold) in margin
994            .discount_managers
995            .iter()
996            .map(|entry| (entry, true))
997            .chain(
998                margin
999                    .remove_discount_managers
1000                    .iter()
1001                    .map(|entry| (entry, false)),
1002            )
1003        {
1004            let manager = parse_margin_identity(entry)?;
1005            let holds = pool
1006                .methods()
1007                .has_role(DISCOUNT_MANAGER_ROLE, manager)
1008                .simulate(Execution::state_read_only())
1009                .await
1010                .context("read a discount manager's role")?
1011                .value;
1012            if holds == should_hold {
1013                continue;
1014            }
1015            if should_hold {
1016                tracing::info!("Margin: adding discount manager {manager:?}");
1017                pool.methods()
1018                    .grant_role(DISCOUNT_MANAGER_ROLE, manager)
1019                    .call()
1020                    .await
1021                    .context("grant a discount manager its role")?;
1022            } else {
1023                tracing::info!("Margin: removing discount manager {manager:?}");
1024                pool.methods()
1025                    .revoke_role(DISCOUNT_MANAGER_ROLE, manager)
1026                    .call()
1027                    .await
1028                    .context("revoke a discount manager's role")?;
1029            }
1030        }
1031    }
1032
1033    // Tier-only mode resolves no oracle of its own - ask the live registry,
1034    // which is the same lookup the backend performs at boot.
1035    let margin_oracle_id = match margin_oracle_id {
1036        Some(oracle_id) => Some(oracle_id),
1037        None if cosigner.is_some() => {
1038            let registry = o2_tools::trade_account_registry::TradeAccountRegistry::new(
1039                trade_account_registry_id,
1040                wallet.clone(),
1041            );
1042            let oracle_id = registry
1043                .methods()
1044                .get_prop_oracle_id()
1045                .simulate(Execution::state_read_only())
1046                .await
1047                .context("read the registry's prop account oracle")?
1048                .value;
1049            (oracle_id != ContractId::zeroed()).then_some(oracle_id)
1050        }
1051        None => None,
1052    };
1053
1054    // The cosigner lives on the ORACLE, not the pool, and only reaches
1055    // storage through INITIAL_COSIGNER on the very first initialize - so an
1056    // already-initialized oracle needs this explicit reconciliation, or
1057    // "set it if provided" would silently mean "only on a fresh deploy".
1058    if let (Some(cosigner), Some(oracle_id)) = (cosigner, margin_oracle_id)
1059        && wallet.try_provider()?.contract_exists(&oracle_id).await?
1060    {
1061        let oracle = PropAccountOracleContract::new(oracle_id, wallet.clone());
1062        let current = oracle
1063            .methods()
1064            .get_cosigner()
1065            .simulate(Execution::state_read_only())
1066            .await
1067            .context("read the prop account oracle's cosigner")?
1068            .value;
1069        if current != Some(cosigner) {
1070            tracing::info!("Margin: cosigner {current:?} -> {cosigner:?}");
1071            oracle.methods().set_cosigner(cosigner).call().await?;
1072        }
1073    }
1074
1075    // -- who may publish --------------------------------------------------
1076    // Compare-first, like every other reconcile here: the role is granted or
1077    // revoked only when the feed disagrees with the config, so a settled
1078    // roster sends nothing at all.
1079    //
1080    // An identity in BOTH lists would be granted and then revoked in the same
1081    // run, silently, decided by iteration order. Refuse the ambiguity.
1082    for entry in &margin.price_feed.publishers {
1083        anyhow::ensure!(
1084            !margin.price_feed.remove_publishers.contains(entry),
1085            "price feed publisher {entry} is listed in both `publishers` and \
1086             `remove_publishers`"
1087        );
1088    }
1089    let price_feed = PriceFeedContract::new(price_feed_id, wallet.clone());
1090    for (entry, should_hold) in margin
1091        .price_feed
1092        .publishers
1093        .iter()
1094        .map(|entry| (entry, true))
1095        .chain(
1096            margin
1097                .price_feed
1098                .remove_publishers
1099                .iter()
1100                .map(|entry| (entry, false)),
1101        )
1102    {
1103        let publisher = parse_margin_identity(entry)?;
1104        let holds = price_feed
1105            .methods()
1106            .has_role(o2_tools::prop_deploy::PRICE_SUBMITTER_ROLE, publisher)
1107            .simulate(Execution::state_read_only())
1108            .await
1109            .context("read a price feed publisher's submitter role")?
1110            .value;
1111        if holds == should_hold {
1112            continue;
1113        }
1114        if should_hold {
1115            tracing::info!("Margin: adding price feed publisher {publisher:?}");
1116            price_feed
1117                .methods()
1118                .add_publisher(publisher)
1119                .call()
1120                .await
1121                .context("grant a price feed publisher the submitter role")?;
1122        } else {
1123            tracing::info!("Margin: removing price feed publisher {publisher:?}");
1124            price_feed
1125                .methods()
1126                .remove_publisher(publisher)
1127                .call()
1128                .await
1129                .context("revoke a price feed publisher's submitter role")?;
1130        }
1131    }
1132    // -- the feed's implementation ---------------------------------------
1133    // Same contract as the order books: under `--upgrade-bytecode`, rebuild
1134    // the configured blob and retarget the proxy only when it differs, so a
1135    // feed already on this bytecode costs no transaction.
1136    //
1137    // Never in tier-only mode: that mode reconciles configuration, never
1138    // bytecode. Rotating a publisher must not also swap the implementation
1139    // every margin valuation reads.
1140    if upgrade_bytecode && !tier_only {
1141        o2_tools::prop_deploy::upgrade_price_feed_implementation(
1142            wallet,
1143            price_feed_id,
1144            // The blob's owner configurable must match what the fresh deploy
1145            // derived, or the retarget would chase a different blob id.
1146            prop_config
1147                .owner
1148                .unwrap_or(Identity::Address(wallet.address())),
1149            &prop_config,
1150        )
1151        .await
1152        .context("upgrade the price feed implementation")?;
1153    }
1154
1155    // -- the feed's submission-age bound ----------------------------------
1156    // Also reconciled here, not just through the system deploy, so tier-only
1157    // mode honours it. Compare-first, so the settled case sends nothing and
1158    // the double-apply on a full deploy is free.
1159    if let Some(seconds) = margin.price_feed.max_offchain_age_seconds {
1160        let current = price_feed
1161            .methods()
1162            .get_max_offchain_age()
1163            .simulate(Execution::state_read_only())
1164            .await
1165            .context("read the price feed's submission-age bound")?
1166            .value;
1167        if current != seconds {
1168            tracing::info!("Margin: submission-age bound {current} -> {seconds}");
1169            price_feed
1170                .methods()
1171                .set_max_offchain_age(seconds)
1172                .call()
1173                .await
1174                .context("set the price feed's submission-age bound")?;
1175        }
1176    }
1177
1178    // -- the feed's supported assets -------------------------------------
1179    // Registering decimals is what makes an asset known to the feed, and it
1180    // is independent of ever having a price: a tier catalogue publishes
1181    // against a feed configured this way even when no quote has ever been
1182    // seeded.
1183    for supported in &margin.price_feed.supported_assets {
1184        let current = price_feed
1185            .methods()
1186            .get_asset_decimals(supported.asset)
1187            .simulate(Execution::state_read_only())
1188            .await
1189            .context("read the price feed's asset decimals")?
1190            .value;
1191        match current {
1192            Some(decimals) if decimals == supported.decimals => continue,
1193            // Refused, not overwritten: the pool pins an asset's oracle
1194            // decimals on first use and reverts on a later change, so an
1195            // asset already live at other decimals would be stranded.
1196            Some(decimals) => anyhow::bail!(
1197                "price feed already knows {} at {decimals} decimals, config says \
1198                 {} - refusing to move it, since the pool pins decimals on first \
1199                 use and rejects a change",
1200                supported.asset,
1201                supported.decimals,
1202            ),
1203            None => {
1204                tracing::info!(
1205                    "Margin: registering {} at {} decimals on the feed",
1206                    supported.asset,
1207                    supported.decimals,
1208                );
1209                price_feed
1210                    .methods()
1211                    .set_asset_decimals(supported.asset, supported.decimals)
1212                    .call()
1213                    .await
1214                    .context("register a supported asset on the price feed")?;
1215            }
1216        }
1217    }
1218
1219    let pool = PropMarginPoolContract::new(margin_pool_id, wallet.clone());
1220    let pool_reachable = wallet
1221        .try_provider()?
1222        .contract_exists(&margin_pool_id)
1223        .await?;
1224
1225    // -- the feed the POOL values against ---------------------------------
1226    // `INITIAL_PRICE_FEED` only reaches storage through the pool's one-time
1227    // `initialize`, so a pool that is already live keeps its old feed no
1228    // matter what the config says. Reconcile it explicitly, or "point the
1229    // pool at this feed" would silently mean "only on a fresh deploy".
1230    //
1231    // Runs AFTER the supported-assets step on purpose: `set_price_feed`
1232    // validates that the replacement already knows every admitted asset at
1233    // the pinned decimals, which is exactly what that step registers.
1234    if pool_reachable {
1235        let live_feed = pool
1236            .methods()
1237            .price_feed()
1238            .simulate(Execution::state_read_only())
1239            .await
1240            .context("read the pool's price feed")?
1241            .value;
1242        if live_feed != price_feed_id {
1243            tracing::info!("Margin: pool price feed {live_feed} -> {price_feed_id}");
1244            pool.methods()
1245                .set_price_feed(price_feed_id)
1246                // `validate_replacement_feed` calls INTO the new feed once per
1247                // admitted asset, so it must ride as an input.
1248                .with_contract_ids(&[price_feed_id])
1249                .call()
1250                .await
1251                .context(
1252                    "point the pool at the configured price feed - the \
1253                     replacement must already know every admitted asset at the \
1254                     decimals the pool pinned",
1255                )?;
1256        }
1257    }
1258
1259    // -- the tier catalogue ---------------------------------------------
1260    // The pool is unreachable only when the system deploy did not run, which
1261    // means tier-only mode against a pool the config NAMES but the chain does
1262    // not have. The guard above only checks that a pool is named, so refuse
1263    // here rather than skip: a run that publishes no tiers and reports
1264    // success is the failure an operator cannot see.
1265    anyhow::ensure!(
1266        pool_reachable || margin.tiers.is_empty(),
1267        "margin pool {margin_pool_id} is not on chain, so its {} configured \
1268         tier(s) cannot be reconciled",
1269        margin.tiers.len()
1270    );
1271    if pool_reachable {
1272        reconcile_margin_tiers(
1273            &pool,
1274            price_feed_id,
1275            pairs,
1276            &margin.tiers,
1277            prop_config.collateral_decimals,
1278        )
1279        .await?;
1280    }
1281
1282    Ok(MarginIds {
1283        price_feed_id,
1284        margin_pool_id,
1285        margin_oracle_id,
1286    })
1287}
1288
1289/// The tier engine: reconcile the DECLARED catalogue against the LIVE
1290/// pool, idempotently.
1291///
1292/// - no live version              -> publish version 1;
1293/// - params differ from the live  -> publish a NEW version;
1294/// - params equal, books grew     -> `add_books` with the missing books;
1295/// - params equal, books covered  -> untouched (books are append-only:
1296///   a SHRUNK declared list cannot be enacted and is reported).
1297async fn reconcile_margin_tiers<W>(
1298    pool: &o2_tools::prop::PropMarginPoolContract<W>,
1299    price_feed_id: ContractId,
1300    pairs: &[OrderBookConfig],
1301    tiers: &[MarginTierConfig],
1302    collateral_decimals: u8,
1303) -> anyhow::Result<()>
1304where
1305    W: Account + ViewOnlyAccount + Clone + 'static,
1306{
1307    for tier in tiers {
1308        let books = tier
1309            .markets
1310            .iter()
1311            .map(|market| resolve_tier_market(market, pairs))
1312            .collect::<anyhow::Result<Vec<_>>>()?;
1313        let params = tier_params(tier, collateral_decimals)?;
1314        let terms = tier.turbo.as_ref().map(o2_tools::prop::TurboTerms::from);
1315        // Refused HERE rather than on chain: `publish_tier_v2` validates the
1316        // same invariants and reverts, which costs a transaction and reports
1317        // one undifferentiated error for nine distinct mistakes.
1318        validate_turbo_tier_config(tier, &params, terms.as_ref())?;
1319        let current_version = pool
1320            .methods()
1321            .current_tier_version(tier.tier_id)
1322            .simulate(Execution::state_read_only())
1323            .await?
1324            .value;
1325
1326        let mut contract_ids = vec![price_feed_id];
1327        contract_ids.extend(books.iter().copied());
1328
1329        let Some(version) = current_version else {
1330            let version = publish_margin_tier(
1331                pool,
1332                tier.tier_id,
1333                params,
1334                terms.clone(),
1335                books,
1336                &contract_ids,
1337            )
1338            .await?;
1339            tracing::info!(
1340                "Margin: tier {} published version {version}{}",
1341                tier.tier_id,
1342                if terms.is_some() { " (prepaid)" } else { "" }
1343            );
1344            reconcile_margin_tier_enabled(pool, tier).await?;
1345            continue;
1346        };
1347
1348        let live = pool
1349            .methods()
1350            .get_tier(tier.tier_id, version)
1351            .simulate(Execution::state_read_only())
1352            .await?
1353            .value
1354            .with_context(|| {
1355                format!("tier {} version {version} vanished mid-read", tier.tier_id)
1356            })?;
1357        // The terms are HALF the published version and are immutable once
1358        // written, so a change to them is a new version exactly as a change
1359        // to the economics is. Comparing only `params` would drop a re-tuned
1360        // escalator on the floor and report the tier as unchanged - and the
1361        // cohort switch (terms appearing or disappearing) rides this same
1362        // comparison, `None != Some` doing the work.
1363        let live_terms = pool
1364            .methods()
1365            .get_turbo_terms(tier.tier_id, version)
1366            .simulate(Execution::state_read_only())
1367            .await?
1368            .value;
1369        if live != params || live_terms != terms {
1370            let new_version = publish_margin_tier(
1371                pool,
1372                tier.tier_id,
1373                params,
1374                terms.clone(),
1375                books,
1376                &contract_ids,
1377            )
1378            .await?;
1379            tracing::info!(
1380                "Margin: tier {} republished as version {new_version} ({})",
1381                tier.tier_id,
1382                if live_terms != terms {
1383                    "terms changed"
1384                } else {
1385                    "economics changed"
1386                }
1387            );
1388            reconcile_margin_tier_enabled(pool, tier).await?;
1389            continue;
1390        }
1391        reconcile_margin_tier_enabled(pool, tier).await?;
1392
1393        let live_books = pool
1394            .methods()
1395            .tier_books(tier.tier_id, version)
1396            .simulate(Execution::state_read_only())
1397            .await?
1398            .value;
1399        let missing: Vec<ContractId> = books
1400            .iter()
1401            .copied()
1402            .filter(|book| !live_books.contains(book))
1403            .collect();
1404        let shrunk = live_books
1405            .iter()
1406            .filter(|book| !books.contains(book))
1407            .count();
1408        if shrunk > 0 {
1409            tracing::warn!(
1410                "Margin: tier {} declares {shrunk} fewer book(s) than live version \
1411                 {version}; books are append-only — publish a new version to drop \
1412                 markets",
1413                tier.tier_id
1414            );
1415        }
1416        if missing.is_empty() {
1417            tracing::info!(
1418                "Margin: tier {} version {version} matches the declared catalogue — \
1419                 unchanged",
1420                tier.tier_id
1421            );
1422            continue;
1423        }
1424        let mut add_contract_ids = vec![price_feed_id];
1425        add_contract_ids.extend(missing.iter().copied());
1426        pool.methods()
1427            .add_books(tier.tier_id, missing.clone())
1428            .with_contract_ids(&add_contract_ids)
1429            .call()
1430            .await?;
1431        tracing::info!(
1432            "Margin: tier {} version {version} gained {} book(s)",
1433            tier.tier_id,
1434            missing.len()
1435        );
1436    }
1437    Ok(())
1438}
1439
1440/// Publishes a tier version through the call that matches its KIND.
1441///
1442/// `publish_tier_v2` when the config declares terms, `publish_tier_version`
1443/// when it does not. There is no third option and no setter: which call
1444/// created a version IS that version's cohort, for the whole of its life.
1445async fn publish_margin_tier<W>(
1446    pool: &o2_tools::prop::PropMarginPoolContract<W>,
1447    tier_id: u64,
1448    params: o2_tools::prop::TierParams,
1449    terms: Option<o2_tools::prop::TurboTerms>,
1450    books: Vec<ContractId>,
1451    contract_ids: &[ContractId],
1452) -> anyhow::Result<u32>
1453where
1454    W: Account + ViewOnlyAccount + Clone + 'static,
1455{
1456    let version = match terms {
1457        Some(terms) => {
1458            pool.methods()
1459                .publish_tier_v2(tier_id, params, terms, books)
1460                .with_contract_ids(contract_ids)
1461                .call()
1462                .await?
1463                .value
1464        }
1465        None => {
1466            pool.methods()
1467                .publish_tier_version(tier_id, params, books)
1468                .with_contract_ids(contract_ids)
1469                .call()
1470                .await?
1471                .value
1472        }
1473    };
1474    Ok(version)
1475}
1476
1477/// Brings the tier's SALES switch to what the config declares.
1478///
1479/// Compare-first, like the discount-manager roster: a tier already in the
1480/// declared state costs no transaction, so a routine reconcile of an
1481/// unchanged catalogue sends nothing. Gates `open_session` only - a live
1482/// session on a switched-off tier keeps every call it was sold with, which
1483/// is what lets an old catalogue be retired without stranding anybody.
1484async fn reconcile_margin_tier_enabled<W>(
1485    pool: &o2_tools::prop::PropMarginPoolContract<W>,
1486    tier: &MarginTierConfig,
1487) -> anyhow::Result<()>
1488where
1489    W: Account + ViewOnlyAccount + Clone + 'static,
1490{
1491    let live = pool
1492        .methods()
1493        .is_tier_enabled(tier.tier_id)
1494        .simulate(Execution::state_read_only())
1495        .await?
1496        .value;
1497    if live == tier.enabled {
1498        return Ok(());
1499    }
1500    pool.methods()
1501        .set_tier_enabled(tier.tier_id, tier.enabled)
1502        .call()
1503        .await?;
1504    tracing::info!(
1505        "Margin: tier {} now {} for new sessions",
1506        tier.tier_id,
1507        if tier.enabled { "OPEN" } else { "CLOSED" }
1508    );
1509    Ok(())
1510}
1511
1512/// The pool's `validate_turbo_tier` / `validate_turbo_terms`, checked before
1513/// a transaction is spent rather than after one reverts.
1514///
1515/// Every rule here is enforced on chain too - this is not the authority, it
1516/// is the early and NAMED report of a config the chain would refuse with a
1517/// single undifferentiated error.
1518fn validate_turbo_tier_config(
1519    tier: &MarginTierConfig,
1520    params: &o2_tools::prop::TierParams,
1521    terms: Option<&o2_tools::prop::TurboTerms>,
1522) -> anyhow::Result<()> {
1523    let Some(terms) = terms else {
1524        return Ok(());
1525    };
1526    let id = tier.tier_id;
1527    anyhow::ensure!(
1528        params.duration == 0,
1529        "tier {id} is prepaid, so `duration` must be 0 - the term it sells is \
1530         the week bought at open, not a tier-wide base period"
1531    );
1532    anyhow::ensure!(
1533        params.prolong_fee.iter().all(|fee| *fee == 0),
1534        "tier {id} is prepaid, so every `prolong_fee` must be \"0\" - a \
1535         prepaid term is extended by the FREE rollover and by nothing else"
1536    );
1537    anyhow::ensure!(
1538        params.profit_share_bps == 0,
1539        "tier {id} is prepaid, so `profit_share_bps` must be 0 - the user \
1540         keeps 100% of profits and the platform's take is the forfeited entry"
1541    );
1542    anyhow::ensure!(
1543        terms.rollover_profit_bps <= 10_000,
1544        "tier {id} `rollover_profit_bps` must be <= 10000"
1545    );
1546    anyhow::ensure!(
1547        terms.max_loss_bps.iter().all(|bps| *bps <= 10_000),
1548        "tier {id} every `max_loss_bps` entry must be <= 10000 - a rate past \
1549         BPS would put the floor below zero for any anchor"
1550    );
1551    if terms.rollover_profit_bps != 0 {
1552        anyhow::ensure!(
1553            terms.max_rollovers != 0,
1554            "tier {id} offers a rollover, so `max_rollovers` must not be 0 - \
1555             an enabled feature must be a real one"
1556        );
1557    }
1558    if terms.max_loss_bps.iter().any(|bps| *bps != 0) {
1559        anyhow::ensure!(
1560            terms.rollover_profit_bps != 0,
1561            "tier {id} sells a drawdown escalator, which is measured from the \
1562             rollover anchor, so `rollover_profit_bps` must not be 0 - the \
1563             schedule would otherwise be silently dead"
1564        );
1565        // Every step the session can REACH, not just the first: a rollover
1566        // re-anchors to `V`, so a zero in a later step seizes the user on
1567        // the block after he earns that week - and the publication is
1568        // immutable, so the version can never be repaired.
1569        let last_step = terms.max_rollovers.min(3) as usize;
1570        for step in 0..=last_step {
1571            anyhow::ensure!(
1572                terms.max_loss_bps[step] != 0,
1573                "tier {id} sells a drawdown escalator whose step {step} is 0, \
1574                 which floors that term at the session's own anchor - a \
1575                 session reaching it is liquidatable immediately"
1576            );
1577            anyhow::ensure!(
1578                step == 0 || terms.max_loss_bps[step] >= terms.max_loss_bps[step - 1],
1579                "tier {id} drawdown step {step} is NARROWER than the step \
1580                 before it - the schedule is sold as an escalator and must \
1581                 never take back room a previous term already granted"
1582            );
1583        }
1584        // ...and the FIRST step must be at least the tier's buffer gap wide,
1585        // or every session it sells opens already frozen. An anchored
1586        // session's freeze line is `line - allowance + (open_buffer -
1587        // maintenance)` and `V` at open is exactly `line`, so a narrower
1588        // first step admits no risk-increasing action from block one: the
1589        // user forfeits a non-refundable entry having never placed a trade.
1590        // The pool refuses this too - failing here fails in CI, rather than
1591        // on a publication that is immutable.
1592        let line = u128::from(params.line);
1593        let bps_up = |bps: u64| (line * u128::from(bps)).div_ceil(10_000);
1594        let allowance = line * u128::from(terms.max_loss_bps[0]) / 10_000;
1595        let gap =
1596            bps_up(params.open_buffer_bps).saturating_sub(bps_up(params.maintenance_bps));
1597        anyhow::ensure!(
1598            gap <= allowance,
1599            "tier {id} first drawdown step ({} bps) is narrower than the \
1600             tier's own `open_buffer_bps - maintenance_bps` gap, so every \
1601             session it sells opens already FROZEN and can never trade",
1602            terms.max_loss_bps[0]
1603        );
1604    }
1605    Ok(())
1606}
1607
1608/// Whole collateral units (`"7.5"`) into base units, EXACTLY.
1609///
1610/// Never through `f64`: a binary float cannot hold most decimal fractions, so
1611/// `"7.1"` at 9 decimals would land on 7_099_999_999 - and this is money. The
1612/// integer is built from the digits instead.
1613///
1614/// More fractional digits than the asset has is an ERROR, not a rounding: a
1615/// figure the chain cannot represent is a figure the operator did not mean.
1616fn collateral_amount(value: &str, decimals: u8, field: &str) -> anyhow::Result<u64> {
1617    let raw = value.trim();
1618    anyhow::ensure!(!raw.is_empty(), "{field}: empty amount");
1619    anyhow::ensure!(
1620        !raw.starts_with('-'),
1621        "{field}: `{raw}` is negative; fees are unsigned"
1622    );
1623    let (whole, fraction) = match raw.split_once('.') {
1624        Some((whole, fraction)) => (whole, fraction),
1625        None => (raw, ""),
1626    };
1627    anyhow::ensure!(
1628        !fraction.contains('.'),
1629        "{field}: `{raw}` has more than one decimal point"
1630    );
1631    anyhow::ensure!(
1632        !whole.is_empty() && whole.bytes().all(|b| b.is_ascii_digit()),
1633        "{field}: `{raw}` is not a decimal amount"
1634    );
1635    anyhow::ensure!(
1636        fraction.bytes().all(|b| b.is_ascii_digit()),
1637        "{field}: `{raw}` is not a decimal amount"
1638    );
1639    let decimals = usize::from(decimals);
1640    anyhow::ensure!(
1641        fraction.len() <= decimals,
1642        "{field}: `{raw}` has {} decimal places, but the collateral asset has \
1643         only {decimals}; the chain cannot represent it",
1644        fraction.len()
1645    );
1646    let digits = format!("{whole}{fraction}{}", "0".repeat(decimals - fraction.len()));
1647    digits
1648        .parse::<u64>()
1649        .map_err(|e| anyhow::anyhow!("{field}: `{raw}` does not fit a u64: {e}"))
1650}
1651
1652fn tier_params(
1653    tier: &MarginTierConfig,
1654    collateral_decimals: u8,
1655) -> anyhow::Result<o2_tools::prop::TierParams> {
1656    let fee = |value: &str, what: &str| {
1657        collateral_amount(
1658            value,
1659            collateral_decimals,
1660            &format!("tier {} {what}", tier.tier_id),
1661        )
1662    };
1663    Ok(o2_tools::prop::TierParams {
1664        line: tier.line,
1665        leverage: tier.leverage,
1666        duration: tier.duration,
1667        maintenance_bps: tier.maintenance_bps,
1668        open_buffer_bps: tier.open_buffer_bps,
1669        liq_price_factor: tier.liq_price_factor,
1670        prolong_fee: [
1671            fee(&tier.prolong_fee[0], "prolong_fee[0]")?,
1672            fee(&tier.prolong_fee[1], "prolong_fee[1]")?,
1673            fee(&tier.prolong_fee[2], "prolong_fee[2]")?,
1674            fee(&tier.prolong_fee[3], "prolong_fee[3]")?,
1675        ],
1676        max_credit_line_bps: tier.max_credit_line_bps,
1677        max_price_age: tier.max_price_age,
1678        open_fee: fee(&tier.open_fee, "open_fee")?,
1679        profit_share_bps: tier.profit_share_bps,
1680        price_band_bps: tier.price_band_bps,
1681    })
1682}
1683
1684/// Resolve a tier `markets` entry — a "BASE/QUOTE" symbol pair or a hex
1685/// market id — to the deployed order book's contract id.
1686fn resolve_tier_market(
1687    market: &str,
1688    pairs: &[OrderBookConfig],
1689) -> anyhow::Result<ContractId> {
1690    let wanted = market.trim();
1691    let wanted_id = wanted
1692        .strip_prefix("0x")
1693        .unwrap_or(wanted)
1694        .to_ascii_lowercase();
1695    let pair = pairs
1696        .iter()
1697        .find(|pair| {
1698            let symbol = format!("{}/{}", pair.base.symbol, pair.quote.symbol);
1699            symbol.eq_ignore_ascii_case(wanted)
1700                || hex::encode(*pair.market_id) == wanted_id
1701        })
1702        .with_context(|| format!("margin tier references unknown market `{market}`"))?;
1703    pair.contract_id.with_context(|| {
1704        format!("margin tier market `{market}` has no deployed order book")
1705    })
1706}
1707
1708/// `address:0x..` or `contract:0x..` into an `Identity`.
1709fn parse_margin_identity(s: &str) -> anyhow::Result<Identity> {
1710    if let Some(hex_part) = s.strip_prefix("address:") {
1711        Ok(Identity::Address(fuels::types::Address::new(
1712            parse_margin_bytes32(hex_part)?,
1713        )))
1714    } else if let Some(hex_part) = s.strip_prefix("contract:") {
1715        Ok(Identity::ContractId(ContractId::new(parse_margin_bytes32(
1716            hex_part,
1717        )?)))
1718    } else {
1719        anyhow::bail!("expected `address:0x..` or `contract:0x..`, got `{s}`")
1720    }
1721}
1722
1723fn parse_margin_bytes32(s: &str) -> anyhow::Result<[u8; 32]> {
1724    let raw = s.trim().strip_prefix("0x").unwrap_or(s.trim());
1725    let bytes = hex::decode(raw)
1726        .map_err(|e| anyhow::anyhow!("expected 32 hex bytes, got `{s}`: {e}"))?;
1727    bytes
1728        .try_into()
1729        .map_err(|_| anyhow::anyhow!("expected 32 hex bytes, got `{s}`"))
1730}
1731
1732// ---------------------------------------------------------------------------
1733// Ownership transfer
1734// ---------------------------------------------------------------------------
1735
1736#[allow(clippy::too_many_arguments)]
1737async fn transfer_ownership<W>(
1738    wallet: &W,
1739    params: &DeployParams,
1740    order_book_registry: &OrderBookRegistryManager<W>,
1741    trade_account_registry: &TradeAccountRegistryManager<W>,
1742    trade_account_oracle_deploy: &TradeAccountDeploy<W>,
1743    trial_trade_account_oracle_id: ContractId,
1744    order_book_blacklist_id: Option<ContractId>,
1745    order_book_whitelist_id: Option<ContractId>,
1746) -> anyhow::Result<()>
1747where
1748    W: Account + ViewOnlyAccount + Clone + 'static,
1749{
1750    if let Some(new_proxy_owner) = params.new_proxy_owner {
1751        let new_identity = Identity::Address(new_proxy_owner);
1752        tracing::info!(
1753            "Transferring OrderBookRegistry proxy ownership to {}",
1754            new_proxy_owner
1755        );
1756        order_book_registry
1757            .registry_proxy
1758            .methods()
1759            .set_owner(new_identity)
1760            .call()
1761            .await?;
1762        tracing::info!(
1763            "Transferring TradeAccountRegistry proxy ownership to {}",
1764            new_proxy_owner
1765        );
1766        trade_account_registry
1767            .registry_proxy
1768            .methods()
1769            .set_owner(new_identity)
1770            .call()
1771            .await?;
1772    }
1773
1774    if let Some(new_contract_owner) = params.new_contract_owner {
1775        let new_identity = Identity::Address(new_contract_owner);
1776        tracing::info!(
1777            "Transferring TradeAccountOracle ownership to {}",
1778            new_contract_owner
1779        );
1780        trade_account_oracle_deploy
1781            .oracle
1782            .methods()
1783            .transfer_ownership(new_identity)
1784            .call()
1785            .await?;
1786        tracing::info!(
1787            "Transferring TrialTradeAccountOracle ownership to {}",
1788            new_contract_owner
1789        );
1790        TrialTradingAccountOracle::new(trial_trade_account_oracle_id, wallet.clone())
1791            .methods()
1792            .transfer_ownership(new_identity)
1793            .call()
1794            .await?;
1795        tracing::info!(
1796            "Transferring TradeAccountRegistry ownership to {}",
1797            new_contract_owner
1798        );
1799        trade_account_registry
1800            .registry
1801            .methods()
1802            .transfer_ownership(new_identity)
1803            .call()
1804            .await?;
1805        tracing::info!(
1806            "Transferring OrderBookRegistry ownership to {}",
1807            new_contract_owner
1808        );
1809        order_book_registry
1810            .registry
1811            .methods()
1812            .transfer_ownership(new_identity)
1813            .call()
1814            .await?;
1815        if let Some(blacklist_id) = order_book_blacklist_id {
1816            tracing::info!(
1817                "Transferring OrderBookBlacklist ownership to {}",
1818                new_contract_owner
1819            );
1820            OrderBookBlacklist::new(blacklist_id, wallet.clone())
1821                .methods()
1822                .transfer_ownership(new_identity)
1823                .call()
1824                .await?;
1825        }
1826        if let Some(whitelist_id) = order_book_whitelist_id {
1827            tracing::info!(
1828                "Transferring OrderBookWhitelist ownership to {}",
1829                new_contract_owner
1830            );
1831            OrderBookWhitelist::new(whitelist_id, wallet.clone())
1832                .methods()
1833                .transfer_ownership(new_identity)
1834                .call()
1835                .await?;
1836        }
1837    }
1838
1839    Ok(())
1840}
1841
1842// ---------------------------------------------------------------------------
1843// Internal deploy helpers
1844// ---------------------------------------------------------------------------
1845
1846async fn deploy_order_book_blacklist<W>(
1847    deployer_wallet: W,
1848    deploy_blacklist: bool,
1849    order_book_blacklist_id: Option<ContractId>,
1850    salt: Salt,
1851) -> anyhow::Result<Option<ContractId>>
1852where
1853    W: Account + ViewOnlyAccount + Clone + 'static,
1854{
1855    match order_book_blacklist_id {
1856        Some(order_book_blacklist_id) => {
1857            tracing::info!(
1858                "Using existing OrderBookBlacklist: {}",
1859                order_book_blacklist_id
1860            );
1861            Ok(Some(order_book_blacklist_id))
1862        }
1863        None => {
1864            if !deploy_blacklist {
1865                return Ok(None);
1866            }
1867            tracing::info!("Deploying OrderBookBlacklist");
1868            let order_book_blacklist = OrderBookDeploy::deploy_order_book_blacklist(
1869                &deployer_wallet,
1870                &Identity::Address(ViewOnlyAccount::address(&deployer_wallet)),
1871                &OrderBookDeployConfig {
1872                    salt,
1873                    ..Default::default()
1874                },
1875            )
1876            .await?;
1877            tracing::info!("OrderBookBlacklist: {}", order_book_blacklist.contract_id());
1878            Ok(Some(order_book_blacklist.contract_id()))
1879        }
1880    }
1881}
1882
1883async fn deploy_order_book_whitelist<W>(
1884    deployer_wallet: W,
1885    deploy_whitelist: bool,
1886    order_book_whitelist_id: Option<ContractId>,
1887    salt: Salt,
1888) -> anyhow::Result<Option<ContractId>>
1889where
1890    W: Account + ViewOnlyAccount + Clone + 'static,
1891{
1892    match (order_book_whitelist_id, deploy_whitelist) {
1893        (Some(order_book_whitelist_id), false)
1894        | (Some(order_book_whitelist_id), true) => {
1895            tracing::info!(
1896                "Using existing OrderBookWhitelist: {}",
1897                order_book_whitelist_id
1898            );
1899            Ok(Some(order_book_whitelist_id))
1900        }
1901        (None, false) => Ok(None),
1902        (None, true) => {
1903            tracing::info!("Deploying OrderBookWhitelist");
1904            let trade_account_whitelist = OrderBookDeploy::deploy_order_book_whitelist(
1905                &deployer_wallet,
1906                &Identity::Address(ViewOnlyAccount::address(&deployer_wallet)),
1907                &OrderBookDeployConfig {
1908                    salt,
1909                    ..Default::default()
1910                },
1911            )
1912            .await?;
1913            tracing::info!(
1914                "OrderBookWhitelist: {}",
1915                trade_account_whitelist.contract_id()
1916            );
1917            Ok(Some(trade_account_whitelist.contract_id()))
1918        }
1919    }
1920}
1921
1922/// Load an existing oracle and recover from partial deployment if needed.
1923/// Unlike `TradeAccountDeploy::from_oracle_id`, this does not error when
1924/// the trade account implementation is missing — it deploys and sets it.
1925async fn load_or_recover_trade_account_oracle<W>(
1926    deployer_wallet: &W,
1927    oracle_id: ContractId,
1928) -> anyhow::Result<(TradeAccountDeploy<W>, ContractId)>
1929where
1930    W: Account + ViewOnlyAccount + Clone + 'static,
1931{
1932    let oracle = TradingAccountOracle::new(oracle_id, deployer_wallet.clone());
1933    let impl_id = oracle
1934        .methods()
1935        .get_trade_account_impl()
1936        .simulate(Execution::state_read_only())
1937        .await?
1938        .value;
1939
1940    let blob_id = match impl_id {
1941        Some(id) => id,
1942        None => {
1943            tracing::info!(
1944                "Trade account implementation not set on oracle {}, deploying...",
1945                oracle_id
1946            );
1947            let blob = TradeAccountDeploy::trade_account_blob(
1948                deployer_wallet,
1949                &Default::default(),
1950            )
1951            .await?;
1952            TradeAccountDeploy::deploy_trade_account_blob(
1953                deployer_wallet,
1954                &DeployConfig::Latest(Default::default()),
1955            )
1956            .await?;
1957            oracle
1958                .methods()
1959                .set_trade_account_impl(ContractId::from(blob.id))
1960                .call()
1961                .await?;
1962            ContractId::from(blob.id)
1963        }
1964    };
1965
1966    let deploy = TradeAccountDeploy {
1967        oracle,
1968        oracle_id,
1969        trade_account_blob_id: blob_id.into(),
1970        deployer_wallet: deployer_wallet.clone(),
1971        proxy: None,
1972        proxy_id: None,
1973    };
1974    Ok((deploy, blob_id))
1975}
1976
1977async fn deploy_trade_account_oracle<W>(
1978    deployer_wallet: W,
1979    should_upgrade_bytecode: bool,
1980    trade_account_oracle_id: Option<ContractId>,
1981    salt: Salt,
1982) -> anyhow::Result<(TradeAccountDeploy<W>, ContractId)>
1983where
1984    W: Account + ViewOnlyAccount + Clone + 'static,
1985{
1986    let (trade_account_oracle_deploy, mut trade_account_blob_id) =
1987        match trade_account_oracle_id {
1988            Some(oracle_id) => {
1989                load_or_recover_trade_account_oracle(&deployer_wallet, oracle_id).await?
1990            }
1991            None => {
1992                let deploy = TradeAccountDeploy::deploy(
1993                    &deployer_wallet,
1994                    &DeployConfig::Latest(TradeAccountDeployConfig {
1995                        salt,
1996                        ..Default::default()
1997                    }),
1998                )
1999                .await?;
2000                let blob_id = deploy
2001                    .oracle
2002                    .methods()
2003                    .get_trade_account_impl()
2004                    .simulate(Execution::state_read_only())
2005                    .await?
2006                    .value
2007                    .context("Trade account impl should exist after fresh deploy")?;
2008                (deploy, blob_id)
2009            }
2010        };
2011    tracing::info!(
2012        "TradeAccountOracle: {}",
2013        trade_account_oracle_deploy.oracle_id
2014    );
2015
2016    if should_upgrade_bytecode {
2017        let trade_account_blob =
2018            TradeAccountDeploy::trade_account_blob(&deployer_wallet, &Default::default())
2019                .await?;
2020        if ContractId::from(trade_account_blob.id) != trade_account_blob_id {
2021            tracing::info!(
2022                "Update TradeAccountImpl on Oracle from {:?} to new blob {:?}",
2023                trade_account_blob_id,
2024                ContractId::from(trade_account_blob.id)
2025            );
2026            TradeAccountDeploy::deploy_trade_account_blob(
2027                &deployer_wallet,
2028                &DeployConfig::Latest(Default::default()),
2029            )
2030            .await?;
2031            trade_account_oracle_deploy
2032                .oracle
2033                .methods()
2034                .set_trade_account_impl(ContractId::from(trade_account_blob.id))
2035                .call()
2036                .await?;
2037            trade_account_blob_id = ContractId::from(trade_account_blob.id);
2038        }
2039    }
2040
2041    Ok((trade_account_oracle_deploy, trade_account_blob_id))
2042}
2043
2044/// Deploy (or load) the dedicated trial trade account oracle and keep its
2045/// implementation current. The implementation follows the trade-account
2046/// implementation's lifecycle: seeded when the oracle has none, upgraded
2047/// under `should_upgrade_bytecode`. The cosigner is opt-in: providing one
2048/// also (re)deploys the implementation and configures the cosigner; without
2049/// one, the configured cosigner is left untouched. This runs before
2050/// ownership transfer since it writes to the oracle.
2051async fn deploy_trial_trade_account_oracle<W>(
2052    deployer_wallet: W,
2053    should_upgrade_bytecode: bool,
2054    trial_trade_account_oracle_id: Option<ContractId>,
2055    trial_cosigner: Option<fuels::types::Address>,
2056    salt: Salt,
2057) -> anyhow::Result<ContractId>
2058where
2059    W: Account + ViewOnlyAccount + Clone + 'static,
2060{
2061    let mut trial_deploy_config = TrialTradeAccountDeployConfig {
2062        salt,
2063        ..Default::default()
2064    };
2065    if let Some(cosigner) = trial_cosigner {
2066        trial_deploy_config = trial_deploy_config.with_cosigner(cosigner);
2067    }
2068    let deploy_config = TrialDeployConfig::Latest(trial_deploy_config);
2069
2070    let oracle_id = match trial_trade_account_oracle_id {
2071        None => {
2072            let trial_deploy =
2073                TrialTradeAccountDeploy::deploy(&deployer_wallet, &deploy_config).await?;
2074            tracing::info!(
2075                "TrialTradeAccountOracle: {} (implementation {:?}, cosigner {:?})",
2076                trial_deploy.oracle_id,
2077                trial_deploy.trial_trade_account_blob_id,
2078                trial_cosigner,
2079            );
2080            trial_deploy.oracle_id
2081        }
2082        Some(oracle_id) => {
2083            let current_trial_impl =
2084                TrialTradingAccountOracle::new(oracle_id, deployer_wallet.clone())
2085                    .methods()
2086                    .get_trial_account_impl()
2087                    .simulate(Execution::state_read_only())
2088                    .await?
2089                    .value;
2090            if current_trial_impl.is_none()
2091                || should_upgrade_bytecode
2092                || trial_cosigner.is_some()
2093            {
2094                let trial_deploy = TrialTradeAccountDeploy::deploy_to_oracle(
2095                    &deployer_wallet,
2096                    oracle_id,
2097                    &deploy_config,
2098                )
2099                .await?;
2100                tracing::info!(
2101                    "Trial implementation {:?} deployed to oracle {} (cosigner {:?})",
2102                    trial_deploy.trial_trade_account_blob_id,
2103                    oracle_id,
2104                    trial_cosigner,
2105                );
2106            }
2107            oracle_id
2108        }
2109    };
2110
2111    Ok(oracle_id)
2112}
2113
2114async fn deploy_trade_account_registry<W>(
2115    deployer_wallet: W,
2116    should_upgrade_bytecode: bool,
2117    trade_account_deploy: TradeAccountDeploy<W>,
2118    trial_trade_account_oracle_id: ContractId,
2119    trade_account_registry_id: Option<ContractId>,
2120    salt: Salt,
2121) -> anyhow::Result<(TradeAccountRegistryManager<W>, ContractId)>
2122where
2123    W: Account + ViewOnlyAccount + Clone + 'static,
2124{
2125    let trade_account_oracle_id = trade_account_deploy.oracle_id;
2126    let trade_account_registry = match trade_account_registry_id {
2127        Some(trade_account_registry_contract_id) => TradeAccountRegistryManager::new(
2128            deployer_wallet.clone(),
2129            trade_account_registry_contract_id,
2130        ),
2131        None => {
2132            let trade_account_registry_deploy_config = TradeAccountRegistryDeployConfig {
2133                salt,
2134                ..Default::default()
2135            };
2136            TradeAccountRegistryManager::deploy(
2137                &deployer_wallet,
2138                trade_account_oracle_id,
2139                trial_trade_account_oracle_id,
2140                &trade_account_registry_deploy_config,
2141            )
2142            .await?
2143        }
2144    };
2145    tracing::info!(
2146        "TradeAccountRegistry: {}",
2147        trade_account_registry.contract_id
2148    );
2149    let mut trade_account_registry_blob_id = match trade_account_registry
2150        .registry_proxy
2151        .methods()
2152        .proxy_target()
2153        .simulate(Execution::state_read_only())
2154        .await?
2155        .value
2156    {
2157        Some(blob_id) => blob_id,
2158        None => {
2159            tracing::info!("TradeAccountRegistry proxy target not set, initializing...");
2160            // Call initialize_proxy() to write INITIAL_OWNER and INITIAL_TARGET
2161            // from configurables to storage (upgrade/set_proxy_target would fail
2162            // because the owner is not yet in storage)
2163            trade_account_registry
2164                .registry_proxy
2165                .methods()
2166                .initialize_proxy()
2167                .call()
2168                .await?;
2169            trade_account_registry
2170                .registry
2171                .methods()
2172                .initialize()
2173                .call()
2174                .await?;
2175            trade_account_registry
2176                .registry_proxy
2177                .methods()
2178                .proxy_target()
2179                .simulate(Execution::state_read_only())
2180                .await?
2181                .value
2182                .context("TradeAccountRegistry proxy target should be set after initialization")?
2183        }
2184    };
2185
2186    if should_upgrade_bytecode {
2187        // The rebuilt blob carries whatever this config says and DEFAULTS
2188        // everything else — so the prop wiring has to be repeated here or
2189        // the upgrade silently zeroes it. See `live_prop_registry_config`.
2190        let trade_account_registry_deploy_config = TradeAccountRegistryDeployConfig {
2191            registry_config: trade_account_registry
2192                .live_prop_config(TradeAccountRegistryConfigurables::default())
2193                .await?,
2194            ..Default::default()
2195        };
2196        let trade_account_proxy_blob = TradeAccountRegistryManager::register_proxy_blob(
2197            &deployer_wallet,
2198            &trade_account_registry_deploy_config,
2199        )
2200        .await?;
2201        let trial_trade_account_proxy_blob =
2202            TradeAccountRegistryManager::register_trial_proxy_blob(
2203                &deployer_wallet,
2204                &trade_account_registry_deploy_config,
2205            )
2206            .await?;
2207
2208        let trade_account_register_blob = TradeAccountRegistryManager::register_blob(
2209            &deployer_wallet,
2210            trade_account_oracle_id,
2211            trial_trade_account_oracle_id,
2212            trade_account_proxy_blob.id,
2213            trial_trade_account_proxy_blob.id,
2214            &trade_account_registry_deploy_config,
2215        )
2216        .await?;
2217
2218        if trade_account_registry_blob_id
2219            != ContractId::from(trade_account_register_blob.id)
2220        {
2221            tracing::info!(
2222                "Upgrade TradeAccountRegistry blob from {:?} to {:?}",
2223                trade_account_registry.contract_id,
2224                ContractId::from(trade_account_register_blob.id)
2225            );
2226            // The SAME config the blob above was computed from: `upgrade`
2227            // rebuilds the blob internally, so passing a different one
2228            // would retarget the proxy at something we never compared.
2229            trade_account_registry
2230                .upgrade(
2231                    trade_account_oracle_id,
2232                    trial_trade_account_oracle_id,
2233                    &trade_account_registry_deploy_config,
2234                )
2235                .await?;
2236            trade_account_registry_blob_id = trade_account_register_blob.id.into();
2237        }
2238    }
2239    Ok((trade_account_registry, trade_account_registry_blob_id))
2240}
2241
2242async fn deploy_order_book_registry<W>(
2243    deployer_wallet: W,
2244    should_upgrade_bytecode: bool,
2245    order_book_registry_id: Option<ContractId>,
2246    salt: Salt,
2247) -> anyhow::Result<(OrderBookRegistryManager<W>, ContractId)>
2248where
2249    W: Account + ViewOnlyAccount + Clone + 'static,
2250{
2251    let order_book_registry = match order_book_registry_id {
2252        Some(registry_contract_id) => {
2253            OrderBookRegistryManager::new(deployer_wallet.clone(), registry_contract_id)
2254        }
2255        None => {
2256            OrderBookRegistryManager::deploy(
2257                &deployer_wallet,
2258                &OrderBookRegistryDeployConfig {
2259                    salt,
2260                    ..Default::default()
2261                },
2262            )
2263            .await?
2264        }
2265    };
2266    tracing::info!("OrderBookRegistry: {}", order_book_registry.contract_id);
2267    let mut order_book_registry_blob_id = match order_book_registry
2268        .registry_proxy
2269        .methods()
2270        .proxy_target()
2271        .simulate(Execution::state_read_only())
2272        .await?
2273        .value
2274    {
2275        Some(blob_id) => blob_id,
2276        None => {
2277            tracing::info!("OrderBookRegistry proxy target not set, initializing...");
2278            // Call initialize_proxy() to write INITIAL_OWNER and INITIAL_TARGET
2279            // from configurables to storage (upgrade/set_proxy_target would fail
2280            // because the owner is not yet in storage)
2281            order_book_registry
2282                .registry_proxy
2283                .methods()
2284                .initialize_proxy()
2285                .call()
2286                .await?;
2287            order_book_registry
2288                .registry
2289                .methods()
2290                .initialize()
2291                .call()
2292                .await?;
2293            order_book_registry
2294                .registry_proxy
2295                .methods()
2296                .proxy_target()
2297                .simulate(Execution::state_read_only())
2298                .await?
2299                .value
2300                .context(
2301                    "OrderBookRegistry proxy target should be set after initialization",
2302                )?
2303        }
2304    };
2305
2306    if should_upgrade_bytecode {
2307        let order_book_register_deploy_config = OrderBookRegistryDeployConfig::default();
2308        let order_book_register_blob = OrderBookRegistryManager::register_blob(
2309            &deployer_wallet,
2310            &order_book_register_deploy_config,
2311        )
2312        .await?;
2313        if order_book_registry_blob_id != order_book_register_blob.id.into() {
2314            tracing::info!(
2315                "Upgrade OrderBookRegistry blob from {:?} to {:?}",
2316                order_book_registry.contract_id,
2317                ContractId::from(order_book_register_blob.id)
2318            );
2319            order_book_registry
2320                .upgrade(&order_book_register_deploy_config)
2321                .await?;
2322            order_book_registry_blob_id = order_book_register_blob.id.into();
2323        }
2324    }
2325
2326    Ok((order_book_registry, order_book_registry_blob_id))
2327}
2328
2329async fn deploy_order_books<W>(
2330    deployer_wallet: W,
2331    should_upgrade_bytecode: bool,
2332    order_book_blacklist_id: Option<ContractId>,
2333    order_book_whitelist_id: Option<ContractId>,
2334    order_book_registry: OrderBookRegistryManager<W>,
2335    order_book_configs: &mut [OrderBookConfig],
2336    ownership_options: OwnershipTransferOptions,
2337) -> anyhow::Result<Vec<OrderBookConfig>>
2338where
2339    W: Account + ViewOnlyAccount + Clone + 'static,
2340{
2341    // Pre-flight every pair before deploying any of them. A precision budget
2342    // breach is a config mistake, and the loop below deploys and registers as it
2343    // goes — failing on pair N would otherwise leave 1..N-1 live on chain, with
2344    // an append-only registry that cannot take them back.
2345    audit_order_book_precision(order_book_configs)?;
2346
2347    let mut pairs: Vec<OrderBookConfig> = Vec::with_capacity(order_book_configs.len());
2348
2349    for order_book_config in order_book_configs.iter_mut() {
2350        let pair = deploy_single_order_book(
2351            &deployer_wallet,
2352            should_upgrade_bytecode,
2353            order_book_blacklist_id,
2354            order_book_whitelist_id,
2355            &order_book_registry,
2356            order_book_config,
2357            &ownership_options,
2358        )
2359        .await?;
2360        pairs.push(pair);
2361    }
2362
2363    Ok(pairs)
2364}
2365
2366async fn deploy_single_order_book<W>(
2367    deployer_wallet: &W,
2368    should_upgrade_bytecode: bool,
2369    order_book_blacklist_id: Option<ContractId>,
2370    order_book_whitelist_id: Option<ContractId>,
2371    order_book_registry: &OrderBookRegistryManager<W>,
2372    order_book_config: &mut OrderBookConfig,
2373    ownership_options: &OwnershipTransferOptions,
2374) -> anyhow::Result<OrderBookConfig>
2375where
2376    W: Account + ViewOnlyAccount + Clone + 'static,
2377{
2378    let market_symbol = format!(
2379        "{}/{}",
2380        order_book_config.base.symbol, order_book_config.quote.symbol
2381    );
2382    let market_id = MarketIdAssets {
2383        base_asset: order_book_config.base.asset,
2384        quote_asset: order_book_config.quote.asset,
2385    };
2386    let order_book_configurables = build_order_book_configurables(
2387        order_book_config,
2388        order_book_blacklist_id,
2389        order_book_whitelist_id,
2390        deployer_wallet,
2391    )?;
2392
2393    let order_book = load_or_deploy_order_book(
2394        deployer_wallet,
2395        order_book_registry,
2396        &market_id,
2397        &market_symbol,
2398        &order_book_configurables,
2399        order_book_config,
2400    )
2401    .await?;
2402
2403    tracing::info!(
2404        "[{}] OrderBook: {}",
2405        market_symbol,
2406        order_book.contract.contract_id()
2407    );
2408
2409    let order_book_blob_id = maybe_upgrade_order_book(
2410        deployer_wallet,
2411        should_upgrade_bytecode,
2412        &order_book,
2413        order_book_config,
2414        order_book_configurables,
2415        &market_symbol,
2416    )
2417    .await?;
2418
2419    // Set the maintainer while the deployer is still the owner, i.e. before any
2420    // ownership transfer below (`owner_grant_role`/`owner_revoke_role` are
2421    // `only_owner`).
2422    set_order_book_maintainer(&order_book, ownership_options, &market_symbol).await?;
2423
2424    transfer_order_book_ownership(&order_book, ownership_options, &market_symbol).await?;
2425
2426    order_book_config.contract_id = Some(order_book.contract.contract_id());
2427    order_book_config.blob_id = order_book_blob_id.into();
2428
2429    Ok(order_book_config.clone())
2430}
2431
2432/// Checks every pair against its precision budget, naming the first offender.
2433///
2434/// The deploy path calls this before it touches the chain; it is public so a
2435/// config can be audited without one — CI, a linter, or `o2-api`'s
2436/// `validate_markets_config` example — against the same rule the deploy
2437/// enforces rather than a re-derived copy of it. See
2438/// [`order_book_precision_exponents`] for what the budget is and why.
2439pub fn audit_order_book_precision(configs: &[OrderBookConfig]) -> anyhow::Result<()> {
2440    for config in configs {
2441        order_book_precision_exponents(config)?;
2442    }
2443    Ok(())
2444}
2445
2446/// The exponents behind `PRICE_PRECISION` and `QUANTITY_PRECISION`, after
2447/// checking the pair fits its precision budget.
2448///
2449/// The book settles a fill as `quantity * price / BASE_DECIMALS`
2450/// (`quote_coins_from_quantity`). A price on a
2451/// `10^(quote.decimals - quote.max_precision)` grid and a quantity on a
2452/// `10^(base.decimals - base.max_precision)` grid therefore put the notional on
2453/// a grid of `10^(quote.decimals - quote.max_precision - base.max_precision)`
2454/// quote atoms — so the two `max_precision` values are spending one shared
2455/// budget of `quote.decimals` digits.
2456///
2457/// Overspend it and the notional is a fraction of an atom the quote asset
2458/// cannot hold. Under `ALLOW_FRACTIONAL_PRICE = false` `quote_would_truncate`
2459/// turns that into a `FractionalPrice` revert on *every* order the book ever
2460/// receives; with fractional prices allowed it silently truncates instead.
2461///
2462/// Nothing downstream catches this: an over-budget book deploys, registers and
2463/// serves from `/v1/markets` looking healthy, and only fails when someone tries
2464/// to trade it.
2465fn order_book_precision_exponents(config: &OrderBookConfig) -> anyhow::Result<(u8, u8)> {
2466    let price_precision = config
2467        .quote
2468        .decimals
2469        .checked_sub(config.quote.max_precision)
2470        .ok_or_else(|| {
2471            anyhow::anyhow!(
2472                "quote max_precision ({}) exceeds decimals ({})",
2473                config.quote.max_precision,
2474                config.quote.decimals
2475            )
2476        })?;
2477    let quantity_precision = config
2478        .base
2479        .decimals
2480        .checked_sub(config.base.max_precision)
2481        .ok_or_else(|| {
2482            anyhow::anyhow!(
2483                "base max_precision ({}) exceeds decimals ({})",
2484                config.base.max_precision,
2485                config.base.decimals
2486            )
2487        })?;
2488
2489    // Widen before summing: the checks above bound each side by its own
2490    // decimals, which still leaves room to wrap a u8.
2491    let spent = config.base.max_precision as u16 + config.quote.max_precision as u16;
2492    let budget = config.quote.decimals as u16;
2493
2494    if config.allow_fractional_price {
2495        if spent > budget {
2496            tracing::warn!(
2497                "{}/{}: base max_precision ({}) + quote max_precision ({}) = {spent} \
2498                 exceeds the quote's {budget} decimals; ALLOW_FRACTIONAL_PRICE is set, \
2499                 so notionals will silently truncate rather than revert",
2500                config.base.symbol,
2501                config.quote.symbol,
2502                config.base.max_precision,
2503                config.quote.max_precision,
2504            );
2505        }
2506    } else {
2507        anyhow::ensure!(
2508            spent <= budget,
2509            "{}/{}: base max_precision ({}) + quote max_precision ({}) = {spent} \
2510             exceeds the quote's {budget} decimals, so every order would revert \
2511             with FractionalPrice. Lower one of the two until they sum to {budget} \
2512             or less.",
2513            config.base.symbol,
2514            config.quote.symbol,
2515            config.base.max_precision,
2516            config.quote.max_precision,
2517        );
2518    }
2519
2520    Ok((price_precision, quantity_precision))
2521}
2522
2523fn build_order_book_configurables<W: ViewOnlyAccount>(
2524    config: &OrderBookConfig,
2525    order_book_blacklist_id: Option<ContractId>,
2526    order_book_whitelist_id: Option<ContractId>,
2527    deployer_wallet: &W,
2528) -> anyhow::Result<OrderBookConfigurables> {
2529    let (price_precision, quantity_precision) = order_book_precision_exponents(config)?;
2530
2531    Ok(OrderBookConfigurables::default()
2532        .with_MIN_ORDER(config.min_order)?
2533        .with_ALLOW_FRACTIONAL_PRICE(config.allow_fractional_price)?
2534        .with_TAKER_FEE(config.taker_fee.into())?
2535        .with_MAKER_FEE(config.maker_fee.into())?
2536        .with_DUST(config.dust)?
2537        .with_PRICE_WINDOW(config.price_window as u64)?
2538        .with_BASE_DECIMALS(10u64.pow(config.base.decimals as u32))?
2539        .with_QUOTE_DECIMALS(10u64.pow(config.quote.decimals as u32))?
2540        .with_BASE_SYMBOL(SizedAsciiString::new_with_right_whitespace_padding(
2541            config.base.symbol.clone(),
2542        )?)?
2543        .with_QUOTE_SYMBOL(SizedAsciiString::new_with_right_whitespace_padding(
2544            config.quote.symbol.clone(),
2545        )?)?
2546        .with_PRICE_PRECISION(10u64.pow(price_precision as u32))?
2547        .with_QUANTITY_PRECISION(10u64.pow(quantity_precision as u32))?
2548        .with_INITIAL_OWNER(o2_tools::order_book_deploy::State::Initialized(
2549            Identity::Address(ViewOnlyAccount::address(deployer_wallet)),
2550        ))?
2551        .with_WHITE_LIST_CONTRACT(order_book_whitelist_id)?
2552        .with_BLACK_LIST_CONTRACT(order_book_blacklist_id)?)
2553}
2554
2555async fn load_or_deploy_order_book<W>(
2556    deployer_wallet: &W,
2557    order_book_registry: &OrderBookRegistryManager<W>,
2558    market_id: &MarketIdAssets,
2559    market_symbol: &str,
2560    order_book_configurables: &OrderBookConfigurables,
2561    order_book_config: &OrderBookConfig,
2562) -> anyhow::Result<OrderBookManager<W>>
2563where
2564    W: Account + ViewOnlyAccount + Clone + 'static,
2565{
2566    let register_contract_id = order_book_registry
2567        .registry
2568        .methods()
2569        .get_order_book(to_registry_market_id(market_id))
2570        .simulate(Execution::state_read_only())
2571        .await?
2572        .value;
2573
2574    match register_contract_id {
2575        Some(contract_id) => {
2576            let order_book_deploy = OrderBookDeploy::new(
2577                deployer_wallet.clone(),
2578                contract_id,
2579                market_id.base_asset,
2580                market_id.quote_asset,
2581            );
2582            // Handle partially deployed contracts (e.g. previous deploy failed
2583            // after registering but before initializing the proxy)
2584            let proxy_target = order_book_deploy
2585                .order_book_proxy
2586                .methods()
2587                .proxy_target()
2588                .simulate(Execution::state_read_only())
2589                .await?
2590                .value;
2591            if proxy_target.is_none() {
2592                tracing::info!(
2593                    "[{}] Proxy target not set, initializing...",
2594                    market_symbol
2595                );
2596                order_book_deploy.initialize().await?;
2597            }
2598            Ok(OrderBookManager::new(
2599                deployer_wallet,
2600                10u64.pow(order_book_config.base.decimals as u32),
2601                10u64.pow(order_book_config.quote.decimals as u32),
2602                &order_book_deploy,
2603            ))
2604        }
2605        None => {
2606            let (order_book_deployment, initialization_required) =
2607                OrderBookDeploy::deploy_without_initialization(
2608                    deployer_wallet,
2609                    market_id.base_asset,
2610                    market_id.quote_asset,
2611                    &OrderBookDeployConfig {
2612                        order_book_configurables: order_book_configurables.clone(),
2613                        salt: Salt::from(*order_book_registry.contract_id),
2614                        ..Default::default()
2615                    },
2616                )
2617                .await?;
2618
2619            order_book_registry
2620                .register_order_book(
2621                    to_registry_market_id(market_id),
2622                    order_book_deployment.contract_id,
2623                )
2624                .await?;
2625
2626            if initialization_required {
2627                order_book_deployment.initialize().await?;
2628            }
2629            Ok(OrderBookManager::new(
2630                deployer_wallet,
2631                10u64.pow(order_book_config.base.decimals as u32),
2632                10u64.pow(order_book_config.quote.decimals as u32),
2633                &order_book_deployment,
2634            ))
2635        }
2636    }
2637}
2638
2639async fn maybe_upgrade_order_book<W>(
2640    deployer_wallet: &W,
2641    should_upgrade_bytecode: bool,
2642    order_book: &OrderBookManager<W>,
2643    order_book_config: &OrderBookConfig,
2644    order_book_configurables: OrderBookConfigurables,
2645    market_symbol: &str,
2646) -> anyhow::Result<ContractId>
2647where
2648    W: Account + ViewOnlyAccount + Clone + 'static,
2649{
2650    let mut order_book_blob_id = order_book
2651        .proxy
2652        .methods()
2653        .proxy_target()
2654        .simulate(Execution::state_read_only())
2655        .await?
2656        .value
2657        .context("Order book proxy target should be set after initialization")?;
2658
2659    if should_upgrade_bytecode {
2660        let order_book_deploy_config = OrderBookDeployConfig {
2661            order_book_configurables,
2662            ..Default::default()
2663        };
2664        let order_book_deploy = OrderBookDeploy::new(
2665            deployer_wallet.clone(),
2666            order_book.contract.contract_id(),
2667            order_book_config.base.asset,
2668            order_book_config.quote.asset,
2669        );
2670        let order_book_manager = OrderBookManager::new(
2671            deployer_wallet,
2672            10u64.pow(order_book_config.base.decimals as u32),
2673            10u64.pow(order_book_config.quote.decimals as u32),
2674            &order_book_deploy,
2675        );
2676        let order_book_blob = OrderBookDeploy::order_book_blob(
2677            deployer_wallet,
2678            order_book_config.base.asset,
2679            order_book_config.quote.asset,
2680            &order_book_deploy_config,
2681        )
2682        .await?;
2683
2684        if order_book_blob_id != order_book_blob.id.into() {
2685            tracing::info!(
2686                "[{}] Upgrade OrderBook blob from {:?} to {:?}",
2687                market_symbol,
2688                order_book_blob_id,
2689                ContractId::from(order_book_blob.id)
2690            );
2691            order_book_manager
2692                .upgrade(&order_book_deploy_config)
2693                .await?;
2694            tracing::info!(
2695                "[{}] Emit new configuration event for {}",
2696                market_symbol,
2697                order_book.contract.contract_id()
2698            );
2699            order_book_manager.emit_config().await?;
2700            order_book_blob_id = order_book_blob.id.into();
2701        }
2702    }
2703
2704    Ok(order_book_blob_id)
2705}
2706
2707async fn set_order_book_maintainer<W>(
2708    order_book: &OrderBookManager<W>,
2709    ownership_options: &OwnershipTransferOptions,
2710    market_symbol: &str,
2711) -> anyhow::Result<()>
2712where
2713    W: Account + ViewOnlyAccount + Clone + 'static,
2714{
2715    // Revoke the maintainer role from the previous holders first, so the role is
2716    // rotated rather than accumulating holders across upgrades. Revoking an
2717    // account that does not hold the role is a no-op on-chain.
2718    for account in &ownership_options.revoke_orderbook_maintainers {
2719        tracing::info!(
2720            "[{}] Revoking ORDERBOOK_MAINTAINER_ROLE from {}",
2721            market_symbol,
2722            account
2723        );
2724        order_book
2725            .contract
2726            .methods()
2727            .owner_revoke_role(ORDERBOOK_MAINTAINER_ROLE, Identity::Address(*account))
2728            .call()
2729            .await?;
2730    }
2731
2732    for account in &ownership_options.new_orderbook_maintainers {
2733        tracing::info!(
2734            "[{}] Granting ORDERBOOK_MAINTAINER_ROLE to {}",
2735            market_symbol,
2736            account
2737        );
2738        order_book
2739            .contract
2740            .methods()
2741            .owner_grant_role(ORDERBOOK_MAINTAINER_ROLE, Identity::Address(*account))
2742            .call()
2743            .await?;
2744    }
2745
2746    Ok(())
2747}
2748
2749async fn transfer_order_book_ownership<W>(
2750    order_book: &OrderBookManager<W>,
2751    ownership_options: &OwnershipTransferOptions,
2752    market_symbol: &str,
2753) -> anyhow::Result<()>
2754where
2755    W: Account + ViewOnlyAccount + Clone + 'static,
2756{
2757    if let Some(new_owner) = ownership_options.new_proxy_owner {
2758        let new_identity = Identity::Address(new_owner);
2759        tracing::info!(
2760            "[{}] Transferring OrderBook proxy ownership to {}",
2761            market_symbol,
2762            new_owner
2763        );
2764        order_book
2765            .proxy
2766            .methods()
2767            .set_owner(new_identity)
2768            .call()
2769            .await?;
2770    }
2771
2772    if let Some(new_owner) = ownership_options.new_contract_owner {
2773        let new_identity = Identity::Address(new_owner);
2774        tracing::info!(
2775            "[{}] Transferring OrderBook contract ownership to {}",
2776            market_symbol,
2777            new_owner
2778        );
2779        order_book
2780            .contract
2781            .methods()
2782            .transfer_ownership(new_identity)
2783            .call()
2784            .await?;
2785    }
2786
2787    Ok(())
2788}
2789
2790#[cfg(test)]
2791mod tests {
2792    use super::*;
2793
2794    #[test]
2795    fn load_config_empty_path_returns_default() {
2796        let result: MarketsConfigPartial = load_config_from_file("").unwrap();
2797        assert!(result.pairs.is_empty());
2798    }
2799
2800    #[test]
2801    fn load_config_missing_file_errors() {
2802        let result: Result<MarketsConfigPartial, _> =
2803            load_config_from_file("nonexistent_file_12345.json");
2804        assert!(result.is_err());
2805    }
2806
2807    #[test]
2808    fn checked_sub_catches_overflow() {
2809        // Validates that our checked_sub pattern works correctly
2810        let decimals: u32 = 6;
2811        let max_precision: u32 = 8; // greater than decimals
2812
2813        let result = decimals.checked_sub(max_precision);
2814        assert!(
2815            result.is_none(),
2816            "should return None when max_precision > decimals"
2817        );
2818
2819        // Normal case
2820        let result = 9u32.checked_sub(6);
2821        assert_eq!(result, Some(3));
2822    }
2823
2824    #[test]
2825    fn markets_config_partial_default_has_empty_pairs() {
2826        let config = MarketsConfigPartial::default();
2827        assert!(config.pairs.is_empty());
2828    }
2829
2830    /// A tier verbatim from `networks/testnet/markets.json`, to prove
2831    /// `deny_unknown_fields` did not just invalidate every shipped config.
2832    const LIVE_LEGACY_TIER: &str = r#"{
2833        "tier_id": 11,
2834        "line": "2500000000000",
2835        "leverage": 40,
2836        "duration": 0,
2837        "maintenance_bps": 150,
2838        "open_buffer_bps": 151,
2839        "liq_price_factor": 9900,
2840        "prolong_fee": ["10", "18", "60", "200"],
2841        "max_credit_line_bps": 20000,
2842        "max_price_age": 300,
2843        "open_fee": "0",
2844        "profit_share_bps": 0,
2845        "price_band_bps": 85,
2846        "markets": ["fwBTC/fUSDC", "fETH/fUSDC", "fXAUT/fUSDC"]
2847    }"#;
2848
2849    fn turbo_tier(turbo: &str) -> String {
2850        format!(
2851            r#"{{
2852                "tier_id": 33,
2853                "line": "10000000000000",
2854                "leverage": 40,
2855                "duration": 0,
2856                "maintenance_bps": 100,
2857                "open_buffer_bps": 101,
2858                "liq_price_factor": 9900,
2859                "prolong_fee": ["0", "0", "0", "0"],
2860                "max_credit_line_bps": 20000,
2861                "max_price_age": 300,
2862                "open_fee": "0",
2863                "profit_share_bps": 0,
2864                "price_band_bps": 100,
2865                "markets": ["fwBTC/fUSDC"],
2866                "turbo": {turbo}
2867            }}"#
2868        )
2869    }
2870
2871    const SHIPPED_TERMS: &str = r#"{
2872        "forfeit_collateral": true,
2873        "max_loss_bps": [150, 200, 250, 300],
2874        "rollover_profit_bps": 900,
2875        "max_rollovers": 3
2876    }"#;
2877
2878    /// The shipped catalogue keeps parsing, and a tier that says nothing
2879    /// about the two new fields is legacy and SELLING - exactly what it was
2880    /// before they existed.
2881    #[test]
2882    fn a_live_legacy_tier_still_parses_and_defaults_to_selling() {
2883        let tier: MarginTierConfig = serde_json::from_str(LIVE_LEGACY_TIER).unwrap();
2884        assert_eq!(tier.tier_id, 11);
2885        assert!(tier.turbo.is_none(), "no terms means the legacy cohort");
2886        assert!(tier.enabled, "a tier with no opinion sells");
2887    }
2888
2889    /// The whole point of the flag: a typo is a REFUSAL, not a silently
2890    /// dropped field. Before this, `max_los_bps` would have published a tier
2891    /// with no escalator at all.
2892    #[test]
2893    fn a_misspelled_tier_key_is_refused_rather_than_ignored() {
2894        let typo = LIVE_LEGACY_TIER.replace("\"leverage\"", "\"leverag\"");
2895        let err = serde_json::from_str::<MarginTierConfig>(&typo).unwrap_err();
2896        assert!(err.to_string().contains("leverag"), "{err}");
2897
2898        let typo = turbo_tier(&SHIPPED_TERMS.replace("max_loss_bps", "max_los_bps"));
2899        let err = serde_json::from_str::<MarginTierConfig>(&typo).unwrap_err();
2900        assert!(err.to_string().contains("max_los_bps"), "{err}");
2901    }
2902
2903    #[test]
2904    fn a_prepaid_tier_carries_its_terms_through_to_the_pools_shape() {
2905        let tier: MarginTierConfig =
2906            serde_json::from_str(&turbo_tier(SHIPPED_TERMS)).unwrap();
2907        let turbo = tier.turbo.as_ref().expect("the tier declares terms");
2908        let terms = o2_tools::prop::TurboTerms::from(turbo);
2909        assert!(terms.forfeit_collateral);
2910        assert_eq!(terms.max_loss_bps, [150, 200, 250, 300]);
2911        assert_eq!(terms.rollover_profit_bps, 900);
2912        assert_eq!(terms.max_rollovers, 3);
2913
2914        let params = tier_params(&tier, 9).unwrap();
2915        validate_turbo_tier_config(&tier, &params, Some(&terms))
2916            .expect("the shipped shape is publishable");
2917    }
2918
2919    /// Each refusal the pool would make, made here instead - with a sentence
2920    /// naming which rule was broken rather than one revert code for all of
2921    /// them.
2922    #[test]
2923    fn a_config_the_pool_would_refuse_is_refused_before_it_costs_a_transaction() {
2924        let check = |mutate: &dyn Fn(&mut MarginTierConfig), expect: &str| {
2925            let mut tier: MarginTierConfig =
2926                serde_json::from_str(&turbo_tier(SHIPPED_TERMS)).unwrap();
2927            mutate(&mut tier);
2928            let params = tier_params(&tier, 9).unwrap();
2929            let terms = tier.turbo.as_ref().map(o2_tools::prop::TurboTerms::from);
2930            let err = validate_turbo_tier_config(&tier, &params, terms.as_ref())
2931                .expect_err("must be refused");
2932            assert!(
2933                err.to_string().contains(expect),
2934                "expected {expect:?} in: {err}"
2935            );
2936        };
2937
2938        check(&|t| t.duration = 604_800, "duration");
2939        check(&|t| t.prolong_fee[2] = "60".to_owned(), "prolong_fee");
2940        check(&|t| t.profit_share_bps = 500, "profit_share_bps");
2941        // A drawdown whose first step is zero opens every session already on
2942        // its liquidation line.
2943        check(
2944            &|t| t.turbo.as_mut().unwrap().max_loss_bps = [0, 200, 250, 300],
2945            "step 0 is 0",
2946        );
2947        // The gap the first cut of this guard left open: a zero in a LATER
2948        // reachable step seizes the user on the block after he earns that
2949        // week.
2950        check(
2951            &|t| t.turbo.as_mut().unwrap().max_loss_bps = [150, 0, 250, 300],
2952            "step 1 is 0",
2953        );
2954        check(
2955            &|t| t.turbo.as_mut().unwrap().max_loss_bps = [150, 200, 250, 0],
2956            "step 3 is 0",
2957        );
2958        // An escalator must never narrow.
2959        check(
2960            &|t| t.turbo.as_mut().unwrap().max_loss_bps = [150, 100, 250, 300],
2961            "NARROWER",
2962        );
2963        // ...but a step it can never reach is nobody's business.
2964        {
2965            let mut tier: MarginTierConfig =
2966                serde_json::from_str(&turbo_tier(SHIPPED_TERMS)).unwrap();
2967            let turbo = tier.turbo.as_mut().unwrap();
2968            turbo.max_rollovers = 1;
2969            turbo.max_loss_bps = [150, 200, 0, 0];
2970            let params = tier_params(&tier, 9).unwrap();
2971            let terms = tier.turbo.as_ref().map(o2_tools::prop::TurboTerms::from);
2972            validate_turbo_tier_config(&tier, &params, terms.as_ref())
2973                .expect("only reachable steps are policed");
2974        }
2975        // An escalator with no anchor to measure from is dead protection.
2976        check(
2977            &|t| t.turbo.as_mut().unwrap().rollover_profit_bps = 0,
2978            "rollover_profit_bps` must not be 0",
2979        );
2980        // A rollover nobody can ever consume.
2981        check(
2982            &|t| {
2983                let turbo = t.turbo.as_mut().unwrap();
2984                turbo.max_rollovers = 0;
2985            },
2986            "max_rollovers",
2987        );
2988        check(
2989            &|t| t.turbo.as_mut().unwrap().max_loss_bps = [150, 200, 250, 10_001],
2990            "<= 10000",
2991        );
2992    }
2993
2994    /// A legacy tier is never measured against the prepaid rules - it has
2995    /// prolong fees and a duration precisely because it is not prepaid.
2996    #[test]
2997    fn a_legacy_tier_is_not_held_to_the_prepaid_invariants() {
2998        let tier: MarginTierConfig = serde_json::from_str(LIVE_LEGACY_TIER).unwrap();
2999        let params = tier_params(&tier, 9).unwrap();
3000        validate_turbo_tier_config(&tier, &params, None).unwrap();
3001    }
3002}
3003
3004#[cfg(test)]
3005mod precision_budget_tests {
3006    use super::*;
3007
3008    /// A pair shaped like every listed o2 market: `decimals: 9` on both sides,
3009    /// fractional prices off.
3010    fn pair(base_max: u8, quote_max: u8) -> OrderBookConfig {
3011        let asset = |symbol: &str, max_precision: u8| AssetConfig {
3012            symbol: symbol.to_string(),
3013            asset: Default::default(),
3014            decimals: 9,
3015            min_precision: 0,
3016            max_precision,
3017        };
3018        OrderBookConfig {
3019            contract_id: None,
3020            blob_id: None,
3021            market_id: Default::default(),
3022            taker_fee: 100,
3023            maker_fee: 0,
3024            min_order: 1_000_000_000,
3025            dust: 1_000,
3026            price_window: 0,
3027            allow_fractional_price: false,
3028            base: asset("TKN", base_max),
3029            quote: asset("USDC", quote_max),
3030        }
3031    }
3032
3033    #[test]
3034    fn a_pair_that_spends_its_whole_budget_yields_the_contract_exponents() {
3035        // ENA/USDC: base 2 + quote 7 == 9, right on the limit.
3036        let (price, quantity) = order_book_precision_exponents(&pair(2, 7)).unwrap();
3037        assert_eq!(price, 2, "PRICE_PRECISION = 10^(9-7)");
3038        assert_eq!(quantity, 7, "QUANTITY_PRECISION = 10^(9-2)");
3039    }
3040
3041    #[test]
3042    fn one_digit_over_budget_is_refused() {
3043        // The USDT/USDC trap: base 4 leaves room for 5 price digits, not 6.
3044        let err = order_book_precision_exponents(&pair(4, 6))
3045            .unwrap_err()
3046            .to_string();
3047        assert!(err.contains("FractionalPrice"), "{err}");
3048        assert!(err.contains("TKN/USDC"), "{err}");
3049        assert!(err.contains("= 10"), "{err}");
3050    }
3051
3052    #[test]
3053    fn fractional_prices_downgrade_the_breach_to_a_warning() {
3054        // ALLOW_FRACTIONAL_PRICE truncates the notional rather than reverting,
3055        // so overspending is a smell there, not a hard failure.
3056        let mut config = pair(4, 6);
3057        config.allow_fractional_price = true;
3058        assert!(order_book_precision_exponents(&config).is_ok());
3059    }
3060
3061    #[test]
3062    fn max_precision_beyond_the_asset_decimals_is_still_refused() {
3063        let err = order_book_precision_exponents(&pair(0, 10))
3064            .unwrap_err()
3065            .to_string();
3066        assert!(err.contains("exceeds decimals"), "{err}");
3067    }
3068
3069    #[test]
3070    fn the_budget_tracks_the_quote_decimals_not_a_hardcoded_nine() {
3071        let mut config = pair(4, 2);
3072        config.quote.decimals = 6;
3073        assert!(
3074            order_book_precision_exponents(&config).is_ok(),
3075            "4 + 2 fits a 6-decimal quote"
3076        );
3077
3078        config.quote.max_precision = 3;
3079        assert!(
3080            order_book_precision_exponents(&config).is_err(),
3081            "4 + 3 does not"
3082        );
3083    }
3084}
3085
3086#[cfg(test)]
3087mod fee_amount_tests {
3088    use super::collateral_amount;
3089
3090    fn at9(value: &str) -> anyhow::Result<u64> {
3091        collateral_amount(value, 9, "tier 1 open_fee")
3092    }
3093
3094    #[test]
3095    fn whole_and_fractional_units_scale_by_the_asset_decimals() {
3096        assert_eq!(at9("7.5").unwrap(), 7_500_000_000);
3097        assert_eq!(at9("7").unwrap(), 7_000_000_000);
3098        assert_eq!(at9("0").unwrap(), 0);
3099        assert_eq!(at9("0.000000001").unwrap(), 1);
3100        assert_eq!(at9("13.5").unwrap(), 13_500_000_000);
3101        // The case a binary float gets wrong: `7.1 * 1e9` is 7099999999.99…
3102        assert_eq!(at9("7.1").unwrap(), 7_100_000_000);
3103    }
3104
3105    #[test]
3106    fn a_figure_the_asset_cannot_represent_is_refused_not_rounded() {
3107        let err = at9("7.0000000001").unwrap_err().to_string();
3108        assert!(err.contains("10 decimal places"), "{err}");
3109        assert!(err.contains("tier 1 open_fee"), "{err}");
3110    }
3111
3112    #[test]
3113    fn junk_is_refused() {
3114        for bad in ["", "   ", "-1", "1.2.3", "abc", "1e9", ".5", "1_000"] {
3115            assert!(at9(bad).is_err(), "`{bad}` should not parse");
3116        }
3117    }
3118
3119    #[test]
3120    fn an_amount_past_u64_is_refused_rather_than_wrapping() {
3121        assert!(at9("18446744074").is_err());
3122        assert_eq!(at9("18446744073.709551615").unwrap(), u64::MAX);
3123    }
3124
3125    #[test]
3126    fn other_decimal_scales_are_honoured() {
3127        assert_eq!(collateral_amount("7.5", 6, "f").unwrap(), 7_500_000);
3128        assert_eq!(collateral_amount("7.5", 1, "f").unwrap(), 75);
3129        assert!(collateral_amount("7.5", 0, "f").is_err());
3130        assert_eq!(collateral_amount("7", 0, "f").unwrap(), 7);
3131    }
3132}