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