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