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