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