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