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