Skip to main content

o2_deploy/
lib.rs

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