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