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