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