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