Skip to main content

o2_deploy/
lib.rs

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