1use 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
74const ORDERBOOK_MAINTAINER_ROLE: u64 = 1;
79
80pub async fn deploy_prop_system<W>(
86 wallet: &W,
87 config: &PropDeployConfig,
88) -> anyhow::Result<PropDeployment<W>>
89where
90 W: Account + Clone,
91{
92 PropDeployment::deploy(wallet, config).await
93}
94
95fn to_registry_market_id(m: &MarketIdAssets) -> o2_tools::order_book_registry::MarketId {
96 o2_tools::order_book_registry::MarketId {
97 base_asset: m.base_asset,
98 quote_asset: m.quote_asset,
99 }
100}
101
102#[serde_as]
107#[derive(Debug, serde::Serialize, Clone, Default)]
108pub struct MarketsConfigOutput {
109 pub starting_height: u32,
110 #[serde_as(as = "HexDisplayFromStr")]
111 pub trade_account_registry_id: ContractId,
112 #[serde_as(as = "HexDisplayFromStr")]
113 pub trade_account_registry_blob_id: ContractId,
114 #[serde_as(as = "HexDisplayFromStr")]
115 pub trade_account_oracle_id: ContractId,
116 #[serde_as(as = "HexDisplayFromStr")]
117 pub trial_trade_account_oracle_id: ContractId,
118 #[serde_as(as = "HexDisplayFromStr")]
119 pub trade_account_root: ContractId,
120 #[serde_as(as = "HexDisplayFromStr")]
121 pub trade_account_proxy: ContractId,
122 #[serde_as(as = "HexDisplayFromStr")]
123 pub trade_account_blob_id: ContractId,
124 #[serde_as(as = "Option<HexDisplayFromStr>")]
125 pub order_book_whitelist_id: Option<ContractId>,
126 #[serde_as(as = "Option<HexDisplayFromStr>")]
127 pub order_book_blacklist_id: Option<ContractId>,
128 #[serde_as(as = "HexDisplayFromStr")]
129 pub order_book_registry_id: ContractId,
130 #[serde_as(as = "HexDisplayFromStr")]
131 pub order_book_registry_blob_id: ContractId,
132 #[serde_as(as = "Option<HexDisplayFromStr>")]
133 pub fast_bridge_asset_registry_proxy_id: Option<ContractId>,
134 #[serde_as(as = "Option<HexDisplayFromStr>")]
135 pub price_feed_id: Option<ContractId>,
136 #[serde_as(as = "Option<HexDisplayFromStr>")]
137 pub margin_pool_id: Option<ContractId>,
138 #[serde_as(as = "Option<HexDisplayFromStr>")]
139 pub margin_oracle_id: Option<ContractId>,
140 #[serde(skip_serializing_if = "Option::is_none")]
147 pub margin: Option<MarginConfig>,
148 pub pairs: Vec<OrderBookConfig>,
149}
150
151#[serde_as]
153#[derive(Debug, Clone, serde::Deserialize)]
154struct OrderBookConfigDeHelper {
155 #[serde_as(as = "Option<serde_with::DisplayFromStr>")]
156 blob_id: Option<ContractId>,
157 #[serde_as(as = "Option<serde_with::DisplayFromStr>")]
158 contract_id: Option<ContractId>,
159 #[serde_as(as = "serde_with::DisplayFromStr")]
160 taker_fee: u64,
161 #[serde_as(as = "serde_with::DisplayFromStr")]
162 maker_fee: u64,
163 #[serde_as(as = "serde_with::DisplayFromStr")]
164 min_order: u64,
165 #[serde_as(as = "serde_with::DisplayFromStr")]
166 dust: u64,
167 price_window: u8,
168 #[serde(default)]
169 allow_fractional_price: bool,
170 base: AssetConfig,
171 quote: AssetConfig,
172}
173
174impl From<OrderBookConfigDeHelper> for OrderBookConfig {
175 fn from(h: OrderBookConfigDeHelper) -> Self {
176 let ids = MarketIdAssets {
177 base_asset: h.base.asset,
178 quote_asset: h.quote.asset,
179 };
180 let market_id = ids.market_id();
181 OrderBookConfig {
182 contract_id: h.contract_id,
183 blob_id: h.blob_id,
184 market_id,
185 taker_fee: h.taker_fee,
186 maker_fee: h.maker_fee,
187 min_order: h.min_order,
188 dust: h.dust,
189 price_window: h.price_window,
190 allow_fractional_price: h.allow_fractional_price,
191 base: h.base,
192 quote: h.quote,
193 }
194 }
195}
196
197#[derive(Debug, Clone, Default, serde::Serialize)]
198pub struct MarketsConfigPartial {
199 pub starting_height: u32,
200 pub trade_account_registry_id: Option<ContractId>,
201 pub order_book_registry_id: Option<ContractId>,
202 pub trade_account_oracle_id: Option<ContractId>,
203 pub trial_trade_account_oracle_id: Option<ContractId>,
204 pub order_book_whitelist_id: Option<ContractId>,
205 pub order_book_blacklist_id: Option<ContractId>,
206 pub fast_bridge_asset_registry_proxy_id: Option<ContractId>,
207 pub pairs: Vec<OrderBookConfig>,
208 pub margin: Option<MarginConfig>,
212}
213
214impl<'de> serde::Deserialize<'de> for MarketsConfigPartial {
215 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
216 where
217 D: serde::Deserializer<'de>,
218 {
219 #[derive(serde::Deserialize, Default)]
220 struct Helper {
221 #[serde(default)]
222 starting_height: u32,
223 trade_account_registry_id: Option<ContractId>,
224 order_book_registry_id: Option<ContractId>,
225 trade_account_oracle_id: Option<ContractId>,
226 trial_trade_account_oracle_id: Option<ContractId>,
227 order_book_whitelist_id: Option<ContractId>,
228 order_book_blacklist_id: Option<ContractId>,
229 fast_bridge_asset_registry_proxy_id: Option<ContractId>,
230 #[serde(default)]
231 pairs: Vec<OrderBookConfigDeHelper>,
232 #[serde(default)]
233 margin: Option<MarginConfig>,
234 }
235 let h = Helper::deserialize(deserializer)?;
236 if let Some(margin) = &h.margin
237 && margin.price_feed_id.is_some()
238 {
239 return Err(serde::de::Error::custom(
240 "`margin.price_feed_id` moved into `margin.price_feed.id`. \
241 Refusing to load a config that uses the retired spelling: it \
242 would be ignored, a fresh feed would be derived, and the pool \
243 would be repointed at an oracle with no publisher. Move the id \
244 (and `publishers` / `supported_assets`) under \
245 `margin.price_feed`.",
246 ));
247 }
248 Ok(MarketsConfigPartial {
249 starting_height: h.starting_height,
250 trade_account_registry_id: h.trade_account_registry_id,
251 order_book_registry_id: h.order_book_registry_id,
252 trade_account_oracle_id: h.trade_account_oracle_id,
253 trial_trade_account_oracle_id: h.trial_trade_account_oracle_id,
254 order_book_whitelist_id: h.order_book_whitelist_id,
255 order_book_blacklist_id: h.order_book_blacklist_id,
256 fast_bridge_asset_registry_proxy_id: h.fast_bridge_asset_registry_proxy_id,
257 pairs: h.pairs.into_iter().map(Into::into).collect(),
258 margin: h.margin,
259 })
260 }
261}
262
263#[serde_as]
267#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
268pub struct MarginTierConfig {
269 pub tier_id: u64,
270 #[serde_as(as = "serde_with::DisplayFromStr")]
272 pub line: u64,
273 pub leverage: u64,
274 pub duration: u64,
275 pub maintenance_bps: u64,
276 pub open_buffer_bps: u64,
277 pub liq_price_factor: u64,
278 pub prolong_fee: [String; 4],
283 pub max_credit_line_bps: u64,
284 pub max_price_age: u64,
285 pub open_fee: String,
288 pub profit_share_bps: u64,
289 pub price_band_bps: u64,
290 pub markets: Vec<String>,
293}
294
295#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
301pub struct MarginSupportedAsset {
302 pub asset: fuels::types::AssetId,
303 pub decimals: u8,
304}
305
306#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
310pub struct MarginPriceFeedConfig {
311 pub id: Option<ContractId>,
314 pub max_offchain_age_seconds: Option<u64>,
317 #[serde(default)]
323 pub publishers: Vec<String>,
324 #[serde(default)]
337 pub remove_publishers: Vec<String>,
338 #[serde(default)]
345 pub supported_assets: Vec<MarginSupportedAsset>,
346}
347
348#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
351pub struct MarginConfig {
352 #[serde(default)]
354 pub price_feed: MarginPriceFeedConfig,
355 #[serde(default, skip_serializing)]
365 pub price_feed_id: Option<ContractId>,
366 pub margin_pool_id: Option<ContractId>,
370 pub platform_payout: Option<String>,
373 pub collateral_asset: Option<fuels::types::AssetId>,
376 pub collateral_decimals: Option<u8>,
379 pub max_tier_books: Option<u64>,
381 pub base_repay_fee_ppm: Option<u64>,
389 #[serde(default)]
393 pub tiers: Vec<MarginTierConfig>,
394}
395
396#[derive(Debug, Clone, Default)]
397pub struct OwnershipTransferOptions {
398 pub new_proxy_owner: Option<fuels::types::Address>,
399 pub new_contract_owner: Option<fuels::types::Address>,
400 pub revoke_orderbook_maintainers: Vec<fuels::types::Address>,
404 pub new_orderbook_maintainers: Vec<fuels::types::Address>,
407}
408
409#[derive(Debug, Clone)]
411pub struct DeployParams {
412 pub deploy_config: MarketsConfigPartial,
413 pub output: Option<String>,
414 pub deploy_whitelist: bool,
415 pub deploy_blacklist: bool,
416 pub upgrade_bytecode: bool,
417 pub new_proxy_owner: Option<fuels::types::Address>,
418 pub new_contract_owner: Option<fuels::types::Address>,
419 pub trial_cosigner: Option<fuels::types::Address>,
425 pub trial_creator: Option<Identity>,
432 pub margin_tier_only: bool,
448 pub margin_cosigner: Option<fuels::types::Address>,
454 pub margin_liquidator: Option<Identity>,
459 pub revoke_orderbook_maintainers: Vec<fuels::types::Address>,
460 pub new_orderbook_maintainers: Vec<fuels::types::Address>,
461}
462
463pub fn load_config_from_file<T>(config_path: &str) -> anyhow::Result<T>
469where
470 T: Default + serde::de::DeserializeOwned,
471{
472 if config_path.is_empty() {
473 return Ok(T::default());
474 }
475 let current_dir = std::env::current_dir()?;
476 let path = current_dir.join(config_path);
477 tracing::info!("Loading config from {}", path.display());
478 let file = std::fs::File::open(&path)?;
479 let config: T = serde_json::from_reader(file)?;
480 Ok(config)
481}
482
483pub async fn deploy<W>(
492 wallet: W,
493 params: DeployParams,
494) -> anyhow::Result<MarketsConfigOutput>
495where
496 W: Account + ViewOnlyAccount + Clone + 'static,
497{
498 tracing::info!("Starting Fuel o2 Registries and Markets");
499 let mut markets_config_partial = params.deploy_config.clone();
500 let starting_height: BlockHeight = markets_config_partial.starting_height.into();
501 let trade_account_oracle_id = markets_config_partial.trade_account_oracle_id;
502 let trial_trade_account_oracle_id =
503 markets_config_partial.trial_trade_account_oracle_id;
504 let order_book_registry_id = markets_config_partial.order_book_registry_id;
505 let trade_account_registry_id = markets_config_partial.trade_account_registry_id;
506 let fast_bridge_asset_registry_proxy_id =
507 markets_config_partial.fast_bridge_asset_registry_proxy_id;
508
509 let mut salt = Salt::zeroed();
510 salt.deref_mut()[..4].copy_from_slice(&starting_height.deref().to_be_bytes());
511
512 let (trade_account_oracle_deploy, trade_account_blob_id) =
513 deploy_trade_account_oracle(
514 wallet.clone(),
515 params.upgrade_bytecode,
516 trade_account_oracle_id,
517 salt,
518 )
519 .await?;
520 let trial_trade_account_oracle_id = deploy_trial_trade_account_oracle(
524 wallet.clone(),
525 params.upgrade_bytecode,
526 trial_trade_account_oracle_id,
527 params.trial_cosigner,
528 salt,
529 )
530 .await?;
531 let (trade_account_registry, trade_account_registry_blob_id) =
532 deploy_trade_account_registry(
533 wallet.clone(),
534 params.upgrade_bytecode,
535 trade_account_oracle_deploy.clone(),
536 trial_trade_account_oracle_id,
537 trade_account_registry_id,
538 salt,
539 )
540 .await?;
541 let order_book_blacklist_id = deploy_order_book_blacklist(
542 wallet.clone(),
543 params.deploy_blacklist,
544 markets_config_partial.order_book_blacklist_id,
545 salt,
546 )
547 .await?;
548 let order_book_whitelist_id = deploy_order_book_whitelist(
549 wallet.clone(),
550 params.deploy_whitelist,
551 markets_config_partial.order_book_whitelist_id,
552 salt,
553 )
554 .await?;
555 let (order_book_registry, order_book_registry_blob_id) = deploy_order_book_registry(
556 wallet.clone(),
557 params.upgrade_bytecode,
558 order_book_registry_id,
559 salt,
560 )
561 .await?;
562 let pairs = deploy_order_books(
563 wallet.clone(),
564 params.upgrade_bytecode,
565 order_book_blacklist_id,
566 order_book_whitelist_id,
567 order_book_registry.clone(),
568 &mut markets_config_partial.pairs,
569 OwnershipTransferOptions {
570 new_proxy_owner: params.new_proxy_owner,
571 new_contract_owner: params.new_contract_owner,
572 revoke_orderbook_maintainers: params.revoke_orderbook_maintainers.clone(),
573 new_orderbook_maintainers: params.new_orderbook_maintainers.clone(),
574 },
575 )
576 .await?;
577
578 let order_book_registry_id = order_book_registry.contract_id;
579 let trade_account_registry_id = trade_account_registry.contract_id;
580 let trade_account_oracle_id = trade_account_oracle_deploy.oracle_id;
581
582 let trade_account_proxy = trade_account_registry
583 .registry
584 .methods()
585 .default_bytecode()
586 .simulate(Execution::state_read_only())
587 .await?
588 .value
589 .context(
590 "Trade account registry default bytecode should exist after initialization",
591 )?;
592 let trade_account_root = trade_account_registry
593 .registry
594 .methods()
595 .factory_bytecode_root()
596 .simulate(Execution::state_read_only())
597 .await?
598 .value
599 .context("Trade account registry factory bytecode root should exist after initialization")?;
600
601 let margin_ids = match &markets_config_partial.margin {
605 Some(margin) => Some(
606 deploy_margin(
607 &wallet,
608 margin,
609 salt,
610 params.margin_tier_only,
611 trade_account_registry_id,
612 trade_account_oracle_id,
613 trial_trade_account_oracle_id,
614 &pairs,
615 params.margin_cosigner,
616 params.margin_liquidator,
617 params.upgrade_bytecode,
618 )
619 .await?,
620 ),
621 None => None,
622 };
623
624 if let Some(trial_creator) = params.trial_creator {
628 let current_creator = trade_account_registry
629 .get_trial_trade_account_creator()
630 .await?;
631 if current_creator != trial_creator {
632 tracing::info!("Setting trial trade account creator to {trial_creator:?}");
633 trade_account_registry
634 .set_trial_trade_account_creator(trial_creator)
635 .await?;
636 }
637 }
638
639 transfer_ownership(
640 &wallet,
641 ¶ms,
642 &order_book_registry,
643 &trade_account_registry,
644 &trade_account_oracle_deploy,
645 trial_trade_account_oracle_id,
646 order_book_blacklist_id,
647 order_book_whitelist_id,
648 )
649 .await?;
650
651 let deploy_result = MarketsConfigOutput {
652 starting_height: starting_height.into(),
653 trade_account_registry_id,
654 trade_account_registry_blob_id,
655 trade_account_proxy,
656 trade_account_blob_id,
657 trade_account_root: ContractId::from(trade_account_root.0),
658 trade_account_oracle_id,
659 trial_trade_account_oracle_id,
660 order_book_whitelist_id,
661 order_book_blacklist_id,
662 order_book_registry_id,
663 order_book_registry_blob_id,
664 pairs,
665 fast_bridge_asset_registry_proxy_id,
666 price_feed_id: margin_ids.map(|ids| ids.price_feed_id),
667 margin_pool_id: margin_ids.map(|ids| ids.margin_pool_id),
668 margin_oracle_id: margin_ids.and_then(|ids| ids.margin_oracle_id),
669 margin: markets_config_partial.margin.clone().map(|mut margin| {
676 if let Some(ids) = margin_ids {
677 margin.price_feed.id = Some(ids.price_feed_id);
678 margin.margin_pool_id = Some(ids.margin_pool_id);
679 }
680 margin
681 }),
682 };
683
684 if let Some(output_path) = params.output {
685 let json = serde_json::to_string_pretty(&deploy_result)?;
686 tracing::info!("Deploy result saved to {}", output_path);
687 std::fs::write(output_path, json)?;
688 }
689
690 Ok(deploy_result)
691}
692
693#[derive(Debug, Clone, Copy)]
699pub struct MarginIds {
700 pub price_feed_id: ContractId,
701 pub margin_pool_id: ContractId,
702 pub margin_oracle_id: Option<ContractId>,
703}
704
705#[allow(clippy::too_many_arguments)]
716async fn deploy_margin<W>(
717 wallet: &W,
718 margin: &MarginConfig,
719 salt: fuels::types::Salt,
720 tier_only: bool,
721 trade_account_registry_id: ContractId,
722 trade_account_oracle_id: ContractId,
723 trial_trade_account_oracle_id: ContractId,
724 pairs: &[OrderBookConfig],
725 margin_cosigner: Option<fuels::types::Address>,
726 margin_liquidator: Option<Identity>,
727 upgrade_bytecode: bool,
728) -> anyhow::Result<MarginIds>
729where
730 W: Account + ViewOnlyAccount + Clone + 'static,
731{
732 use o2_tools::prop::{
733 PriceFeedContract,
734 PropAccountOracleContract,
735 PropMarginPoolContract,
736 };
737
738 let collateral_asset = match margin.collateral_asset {
739 Some(collateral_asset) => collateral_asset,
740 None => {
741 pairs
742 .first()
743 .context(
744 "margin.collateral_asset is not set and there are no pairs to \
745 default it from",
746 )?
747 .quote
748 .asset
749 }
750 };
751 let cosigner = margin_cosigner;
756 let platform_payout = margin
757 .platform_payout
758 .as_deref()
759 .map(parse_margin_identity)
760 .transpose()?;
761 let liquidator = margin_liquidator;
762
763 let mut prop_config = PropDeployConfig::new(collateral_asset);
764 prop_config.collateral_decimals = margin.collateral_decimals.context(
767 "margin.collateral_decimals is required - set it to the collateral \
768 asset's decimals in the deploy config",
769 )?;
770 prop_config.max_tier_books = margin.max_tier_books.unwrap_or(80);
771 prop_config.base_repay_fee_ppm = margin.base_repay_fee_ppm.unwrap_or(100);
772 prop_config.cosigner = cosigner;
773 prop_config.platform_payout = platform_payout;
774 prop_config.liquidator = liquidator;
775 prop_config.salt = salt;
776 prop_config.existing_price_feed = margin.price_feed.id;
777 prop_config.max_offchain_age_seconds = margin.price_feed.max_offchain_age_seconds;
778 prop_config.existing_pool = margin.margin_pool_id;
782 prop_config.existing_registry = Some(ExistingRegistry {
783 registry_id: trade_account_registry_id,
784 trade_account_oracle_id,
785 trial_trade_account_oracle_id,
786 });
787
788 anyhow::ensure!(
789 !(tier_only && margin.margin_pool_id.is_none()),
790 "--margin-tier-only was requested but the markets config names no \
791 `margin.margin_pool_id`: there is no pool to reconcile tiers against"
792 );
793
794 let (price_feed_id, margin_pool_id, margin_oracle_id) = if let (
796 true,
797 Some(margin_pool_id),
798 ) =
799 (tier_only, margin.margin_pool_id)
800 {
801 tracing::info!("Margin: tier-only mode against pool {margin_pool_id}");
802 let price_feed_id = match margin.price_feed.id {
803 Some(price_feed_id) => price_feed_id,
804 None => {
805 PropMarginPoolContract::new(margin_pool_id, wallet.clone())
806 .methods()
807 .price_feed()
808 .simulate(Execution::state_read_only())
809 .await
810 .context("read the pool's price feed")?
811 .value
812 }
813 };
814 let margin_oracle_id =
820 TradeAccountRegistryManager::new(wallet.clone(), trade_account_registry_id)
821 .registry
822 .methods()
823 .get_prop_oracle_id()
824 .simulate(Execution::state_read_only())
825 .await
826 .map(|result| result.value)
827 .ok()
828 .filter(|oracle_id| *oracle_id != ContractId::zeroed());
829 (price_feed_id, margin_pool_id, margin_oracle_id)
830 } else {
831 let deployment = deploy_prop_system(wallet, &prop_config).await?;
832 tracing::info!(
833 "Margin: oracle {}, pool {}, feed {} (shared registry {} upgraded in place)",
834 deployment.oracle_id,
835 deployment.pool_id,
836 deployment.price_feed_id,
837 deployment.registry_id,
838 );
839 (
840 deployment.price_feed_id,
841 deployment.pool_id,
842 Some(deployment.oracle_id),
843 )
844 };
845
846 let pool_deployed = wallet
852 .try_provider()?
853 .contract_exists(&margin_pool_id)
854 .await?;
855 if pool_deployed {
856 let pool = PropMarginPoolContract::new(margin_pool_id, wallet.clone());
857 if let Some(platform_payout) = platform_payout {
858 let current = pool
859 .methods()
860 .platform_payout()
861 .simulate(Execution::state_read_only())
862 .await
863 .context("read the pool's platform payout")?
864 .value;
865 if current != platform_payout {
866 tracing::info!(
867 "Margin: platform payout {current:?} -> {platform_payout:?}"
868 );
869 pool.methods()
870 .set_platform_payout(platform_payout)
871 .call()
872 .await?;
873 }
874 }
875 if let Some(liquidator) = liquidator {
876 let current = pool
877 .methods()
878 .liquidator()
879 .simulate(Execution::state_read_only())
880 .await
881 .context("read the pool's liquidator")?
882 .value;
883 if current != liquidator {
884 tracing::info!("Margin: liquidator {current:?} -> {liquidator:?}");
885 pool.methods().set_liquidator(liquidator).call().await?;
886 }
887 }
888 }
889
890 let margin_oracle_id = match margin_oracle_id {
893 Some(oracle_id) => Some(oracle_id),
894 None if cosigner.is_some() => {
895 let registry = o2_tools::trade_account_registry::TradeAccountRegistry::new(
896 trade_account_registry_id,
897 wallet.clone(),
898 );
899 let oracle_id = registry
900 .methods()
901 .get_prop_oracle_id()
902 .simulate(Execution::state_read_only())
903 .await
904 .context("read the registry's prop account oracle")?
905 .value;
906 (oracle_id != ContractId::zeroed()).then_some(oracle_id)
907 }
908 None => None,
909 };
910
911 if let (Some(cosigner), Some(oracle_id)) = (cosigner, margin_oracle_id)
916 && wallet.try_provider()?.contract_exists(&oracle_id).await?
917 {
918 let oracle = PropAccountOracleContract::new(oracle_id, wallet.clone());
919 let current = oracle
920 .methods()
921 .get_cosigner()
922 .simulate(Execution::state_read_only())
923 .await
924 .context("read the prop account oracle's cosigner")?
925 .value;
926 if current != Some(cosigner) {
927 tracing::info!("Margin: cosigner {current:?} -> {cosigner:?}");
928 oracle.methods().set_cosigner(cosigner).call().await?;
929 }
930 }
931
932 for entry in &margin.price_feed.publishers {
940 anyhow::ensure!(
941 !margin.price_feed.remove_publishers.contains(entry),
942 "price feed publisher {entry} is listed in both `publishers` and \
943 `remove_publishers`"
944 );
945 }
946 let price_feed = PriceFeedContract::new(price_feed_id, wallet.clone());
947 for (entry, should_hold) in margin
948 .price_feed
949 .publishers
950 .iter()
951 .map(|entry| (entry, true))
952 .chain(
953 margin
954 .price_feed
955 .remove_publishers
956 .iter()
957 .map(|entry| (entry, false)),
958 )
959 {
960 let publisher = parse_margin_identity(entry)?;
961 let holds = price_feed
962 .methods()
963 .has_role(o2_tools::prop_deploy::PRICE_SUBMITTER_ROLE, publisher)
964 .simulate(Execution::state_read_only())
965 .await
966 .context("read a price feed publisher's submitter role")?
967 .value;
968 if holds == should_hold {
969 continue;
970 }
971 if should_hold {
972 tracing::info!("Margin: adding price feed publisher {publisher:?}");
973 price_feed
974 .methods()
975 .add_publisher(publisher)
976 .call()
977 .await
978 .context("grant a price feed publisher the submitter role")?;
979 } else {
980 tracing::info!("Margin: removing price feed publisher {publisher:?}");
981 price_feed
982 .methods()
983 .remove_publisher(publisher)
984 .call()
985 .await
986 .context("revoke a price feed publisher's submitter role")?;
987 }
988 }
989 if upgrade_bytecode && !tier_only {
998 o2_tools::prop_deploy::upgrade_price_feed_implementation(
999 wallet,
1000 price_feed_id,
1001 prop_config
1004 .owner
1005 .unwrap_or(Identity::Address(wallet.address())),
1006 &prop_config,
1007 )
1008 .await
1009 .context("upgrade the price feed implementation")?;
1010 }
1011
1012 if let Some(seconds) = margin.price_feed.max_offchain_age_seconds {
1017 let current = price_feed
1018 .methods()
1019 .get_max_offchain_age()
1020 .simulate(Execution::state_read_only())
1021 .await
1022 .context("read the price feed's submission-age bound")?
1023 .value;
1024 if current != seconds {
1025 tracing::info!("Margin: submission-age bound {current} -> {seconds}");
1026 price_feed
1027 .methods()
1028 .set_max_offchain_age(seconds)
1029 .call()
1030 .await
1031 .context("set the price feed's submission-age bound")?;
1032 }
1033 }
1034
1035 for supported in &margin.price_feed.supported_assets {
1041 let current = price_feed
1042 .methods()
1043 .get_asset_decimals(supported.asset)
1044 .simulate(Execution::state_read_only())
1045 .await
1046 .context("read the price feed's asset decimals")?
1047 .value;
1048 match current {
1049 Some(decimals) if decimals == supported.decimals => continue,
1050 Some(decimals) => anyhow::bail!(
1054 "price feed already knows {} at {decimals} decimals, config says \
1055 {} - refusing to move it, since the pool pins decimals on first \
1056 use and rejects a change",
1057 supported.asset,
1058 supported.decimals,
1059 ),
1060 None => {
1061 tracing::info!(
1062 "Margin: registering {} at {} decimals on the feed",
1063 supported.asset,
1064 supported.decimals,
1065 );
1066 price_feed
1067 .methods()
1068 .set_asset_decimals(supported.asset, supported.decimals)
1069 .call()
1070 .await
1071 .context("register a supported asset on the price feed")?;
1072 }
1073 }
1074 }
1075
1076 let pool = PropMarginPoolContract::new(margin_pool_id, wallet.clone());
1077 let pool_reachable = wallet
1078 .try_provider()?
1079 .contract_exists(&margin_pool_id)
1080 .await?;
1081
1082 if pool_reachable {
1092 let live_feed = pool
1093 .methods()
1094 .price_feed()
1095 .simulate(Execution::state_read_only())
1096 .await
1097 .context("read the pool's price feed")?
1098 .value;
1099 if live_feed != price_feed_id {
1100 tracing::info!("Margin: pool price feed {live_feed} -> {price_feed_id}");
1101 pool.methods()
1102 .set_price_feed(price_feed_id)
1103 .with_contract_ids(&[price_feed_id])
1106 .call()
1107 .await
1108 .context(
1109 "point the pool at the configured price feed - the \
1110 replacement must already know every admitted asset at the \
1111 decimals the pool pinned",
1112 )?;
1113 }
1114 }
1115
1116 anyhow::ensure!(
1123 pool_reachable || margin.tiers.is_empty(),
1124 "margin pool {margin_pool_id} is not on chain, so its {} configured \
1125 tier(s) cannot be reconciled",
1126 margin.tiers.len()
1127 );
1128 if pool_reachable {
1129 reconcile_margin_tiers(
1130 &pool,
1131 price_feed_id,
1132 pairs,
1133 &margin.tiers,
1134 prop_config.collateral_decimals,
1135 )
1136 .await?;
1137 }
1138
1139 Ok(MarginIds {
1140 price_feed_id,
1141 margin_pool_id,
1142 margin_oracle_id,
1143 })
1144}
1145
1146async fn reconcile_margin_tiers<W>(
1155 pool: &o2_tools::prop::PropMarginPoolContract<W>,
1156 price_feed_id: ContractId,
1157 pairs: &[OrderBookConfig],
1158 tiers: &[MarginTierConfig],
1159 collateral_decimals: u8,
1160) -> anyhow::Result<()>
1161where
1162 W: Account + ViewOnlyAccount + Clone + 'static,
1163{
1164 for tier in tiers {
1165 let books = tier
1166 .markets
1167 .iter()
1168 .map(|market| resolve_tier_market(market, pairs))
1169 .collect::<anyhow::Result<Vec<_>>>()?;
1170 let params = tier_params(tier, collateral_decimals)?;
1171 let current_version = pool
1172 .methods()
1173 .current_tier_version(tier.tier_id)
1174 .simulate(Execution::state_read_only())
1175 .await?
1176 .value;
1177
1178 let mut contract_ids = vec![price_feed_id];
1179 contract_ids.extend(books.iter().copied());
1180
1181 let Some(version) = current_version else {
1182 let version = pool
1183 .methods()
1184 .publish_tier_version(tier.tier_id, params, books)
1185 .with_contract_ids(&contract_ids)
1186 .call()
1187 .await?
1188 .value;
1189 tracing::info!("Margin: tier {} published version {version}", tier.tier_id);
1190 continue;
1191 };
1192
1193 let live = pool
1194 .methods()
1195 .get_tier(tier.tier_id, version)
1196 .simulate(Execution::state_read_only())
1197 .await?
1198 .value
1199 .with_context(|| {
1200 format!("tier {} version {version} vanished mid-read", tier.tier_id)
1201 })?;
1202 if live != params {
1203 let new_version = pool
1204 .methods()
1205 .publish_tier_version(tier.tier_id, params, books)
1206 .with_contract_ids(&contract_ids)
1207 .call()
1208 .await?
1209 .value;
1210 tracing::info!(
1211 "Margin: tier {} republished as version {new_version}",
1212 tier.tier_id
1213 );
1214 continue;
1215 }
1216
1217 let live_books = pool
1218 .methods()
1219 .tier_books(tier.tier_id, version)
1220 .simulate(Execution::state_read_only())
1221 .await?
1222 .value;
1223 let missing: Vec<ContractId> = books
1224 .iter()
1225 .copied()
1226 .filter(|book| !live_books.contains(book))
1227 .collect();
1228 let shrunk = live_books
1229 .iter()
1230 .filter(|book| !books.contains(book))
1231 .count();
1232 if shrunk > 0 {
1233 tracing::warn!(
1234 "Margin: tier {} declares {shrunk} fewer book(s) than live version \
1235 {version}; books are append-only — publish a new version to drop \
1236 markets",
1237 tier.tier_id
1238 );
1239 }
1240 if missing.is_empty() {
1241 tracing::info!(
1242 "Margin: tier {} version {version} matches the declared catalogue — \
1243 unchanged",
1244 tier.tier_id
1245 );
1246 continue;
1247 }
1248 let mut add_contract_ids = vec![price_feed_id];
1249 add_contract_ids.extend(missing.iter().copied());
1250 pool.methods()
1251 .add_books(tier.tier_id, missing.clone())
1252 .with_contract_ids(&add_contract_ids)
1253 .call()
1254 .await?;
1255 tracing::info!(
1256 "Margin: tier {} version {version} gained {} book(s)",
1257 tier.tier_id,
1258 missing.len()
1259 );
1260 }
1261 Ok(())
1262}
1263
1264fn collateral_amount(value: &str, decimals: u8, field: &str) -> anyhow::Result<u64> {
1273 let raw = value.trim();
1274 anyhow::ensure!(!raw.is_empty(), "{field}: empty amount");
1275 anyhow::ensure!(
1276 !raw.starts_with('-'),
1277 "{field}: `{raw}` is negative; fees are unsigned"
1278 );
1279 let (whole, fraction) = match raw.split_once('.') {
1280 Some((whole, fraction)) => (whole, fraction),
1281 None => (raw, ""),
1282 };
1283 anyhow::ensure!(
1284 !fraction.contains('.'),
1285 "{field}: `{raw}` has more than one decimal point"
1286 );
1287 anyhow::ensure!(
1288 !whole.is_empty() && whole.bytes().all(|b| b.is_ascii_digit()),
1289 "{field}: `{raw}` is not a decimal amount"
1290 );
1291 anyhow::ensure!(
1292 fraction.bytes().all(|b| b.is_ascii_digit()),
1293 "{field}: `{raw}` is not a decimal amount"
1294 );
1295 let decimals = usize::from(decimals);
1296 anyhow::ensure!(
1297 fraction.len() <= decimals,
1298 "{field}: `{raw}` has {} decimal places, but the collateral asset has \
1299 only {decimals}; the chain cannot represent it",
1300 fraction.len()
1301 );
1302 let digits = format!("{whole}{fraction}{}", "0".repeat(decimals - fraction.len()));
1303 digits
1304 .parse::<u64>()
1305 .map_err(|e| anyhow::anyhow!("{field}: `{raw}` does not fit a u64: {e}"))
1306}
1307
1308fn tier_params(
1309 tier: &MarginTierConfig,
1310 collateral_decimals: u8,
1311) -> anyhow::Result<o2_tools::prop::TierParams> {
1312 let fee = |value: &str, what: &str| {
1313 collateral_amount(
1314 value,
1315 collateral_decimals,
1316 &format!("tier {} {what}", tier.tier_id),
1317 )
1318 };
1319 Ok(o2_tools::prop::TierParams {
1320 line: tier.line,
1321 leverage: tier.leverage,
1322 duration: tier.duration,
1323 maintenance_bps: tier.maintenance_bps,
1324 open_buffer_bps: tier.open_buffer_bps,
1325 liq_price_factor: tier.liq_price_factor,
1326 prolong_fee: [
1327 fee(&tier.prolong_fee[0], "prolong_fee[0]")?,
1328 fee(&tier.prolong_fee[1], "prolong_fee[1]")?,
1329 fee(&tier.prolong_fee[2], "prolong_fee[2]")?,
1330 fee(&tier.prolong_fee[3], "prolong_fee[3]")?,
1331 ],
1332 max_credit_line_bps: tier.max_credit_line_bps,
1333 max_price_age: tier.max_price_age,
1334 open_fee: fee(&tier.open_fee, "open_fee")?,
1335 profit_share_bps: tier.profit_share_bps,
1336 price_band_bps: tier.price_band_bps,
1337 })
1338}
1339
1340fn resolve_tier_market(
1343 market: &str,
1344 pairs: &[OrderBookConfig],
1345) -> anyhow::Result<ContractId> {
1346 let wanted = market.trim();
1347 let wanted_id = wanted
1348 .strip_prefix("0x")
1349 .unwrap_or(wanted)
1350 .to_ascii_lowercase();
1351 let pair = pairs
1352 .iter()
1353 .find(|pair| {
1354 let symbol = format!("{}/{}", pair.base.symbol, pair.quote.symbol);
1355 symbol.eq_ignore_ascii_case(wanted)
1356 || hex::encode(*pair.market_id) == wanted_id
1357 })
1358 .with_context(|| format!("margin tier references unknown market `{market}`"))?;
1359 pair.contract_id.with_context(|| {
1360 format!("margin tier market `{market}` has no deployed order book")
1361 })
1362}
1363
1364fn parse_margin_identity(s: &str) -> anyhow::Result<Identity> {
1366 if let Some(hex_part) = s.strip_prefix("address:") {
1367 Ok(Identity::Address(fuels::types::Address::new(
1368 parse_margin_bytes32(hex_part)?,
1369 )))
1370 } else if let Some(hex_part) = s.strip_prefix("contract:") {
1371 Ok(Identity::ContractId(ContractId::new(parse_margin_bytes32(
1372 hex_part,
1373 )?)))
1374 } else {
1375 anyhow::bail!("expected `address:0x..` or `contract:0x..`, got `{s}`")
1376 }
1377}
1378
1379fn parse_margin_bytes32(s: &str) -> anyhow::Result<[u8; 32]> {
1380 let raw = s.trim().strip_prefix("0x").unwrap_or(s.trim());
1381 let bytes = hex::decode(raw)
1382 .map_err(|e| anyhow::anyhow!("expected 32 hex bytes, got `{s}`: {e}"))?;
1383 bytes
1384 .try_into()
1385 .map_err(|_| anyhow::anyhow!("expected 32 hex bytes, got `{s}`"))
1386}
1387
1388#[allow(clippy::too_many_arguments)]
1393async fn transfer_ownership<W>(
1394 wallet: &W,
1395 params: &DeployParams,
1396 order_book_registry: &OrderBookRegistryManager<W>,
1397 trade_account_registry: &TradeAccountRegistryManager<W>,
1398 trade_account_oracle_deploy: &TradeAccountDeploy<W>,
1399 trial_trade_account_oracle_id: ContractId,
1400 order_book_blacklist_id: Option<ContractId>,
1401 order_book_whitelist_id: Option<ContractId>,
1402) -> anyhow::Result<()>
1403where
1404 W: Account + ViewOnlyAccount + Clone + 'static,
1405{
1406 if let Some(new_proxy_owner) = params.new_proxy_owner {
1407 let new_identity = Identity::Address(new_proxy_owner);
1408 tracing::info!(
1409 "Transferring OrderBookRegistry proxy ownership to {}",
1410 new_proxy_owner
1411 );
1412 order_book_registry
1413 .registry_proxy
1414 .methods()
1415 .set_owner(new_identity)
1416 .call()
1417 .await?;
1418 tracing::info!(
1419 "Transferring TradeAccountRegistry proxy ownership to {}",
1420 new_proxy_owner
1421 );
1422 trade_account_registry
1423 .registry_proxy
1424 .methods()
1425 .set_owner(new_identity)
1426 .call()
1427 .await?;
1428 }
1429
1430 if let Some(new_contract_owner) = params.new_contract_owner {
1431 let new_identity = Identity::Address(new_contract_owner);
1432 tracing::info!(
1433 "Transferring TradeAccountOracle ownership to {}",
1434 new_contract_owner
1435 );
1436 trade_account_oracle_deploy
1437 .oracle
1438 .methods()
1439 .transfer_ownership(new_identity)
1440 .call()
1441 .await?;
1442 tracing::info!(
1443 "Transferring TrialTradeAccountOracle ownership to {}",
1444 new_contract_owner
1445 );
1446 TrialTradingAccountOracle::new(trial_trade_account_oracle_id, wallet.clone())
1447 .methods()
1448 .transfer_ownership(new_identity)
1449 .call()
1450 .await?;
1451 tracing::info!(
1452 "Transferring TradeAccountRegistry ownership to {}",
1453 new_contract_owner
1454 );
1455 trade_account_registry
1456 .registry
1457 .methods()
1458 .transfer_ownership(new_identity)
1459 .call()
1460 .await?;
1461 tracing::info!(
1462 "Transferring OrderBookRegistry ownership to {}",
1463 new_contract_owner
1464 );
1465 order_book_registry
1466 .registry
1467 .methods()
1468 .transfer_ownership(new_identity)
1469 .call()
1470 .await?;
1471 if let Some(blacklist_id) = order_book_blacklist_id {
1472 tracing::info!(
1473 "Transferring OrderBookBlacklist ownership to {}",
1474 new_contract_owner
1475 );
1476 OrderBookBlacklist::new(blacklist_id, wallet.clone())
1477 .methods()
1478 .transfer_ownership(new_identity)
1479 .call()
1480 .await?;
1481 }
1482 if let Some(whitelist_id) = order_book_whitelist_id {
1483 tracing::info!(
1484 "Transferring OrderBookWhitelist ownership to {}",
1485 new_contract_owner
1486 );
1487 OrderBookWhitelist::new(whitelist_id, wallet.clone())
1488 .methods()
1489 .transfer_ownership(new_identity)
1490 .call()
1491 .await?;
1492 }
1493 }
1494
1495 Ok(())
1496}
1497
1498async fn deploy_order_book_blacklist<W>(
1503 deployer_wallet: W,
1504 deploy_blacklist: bool,
1505 order_book_blacklist_id: Option<ContractId>,
1506 salt: Salt,
1507) -> anyhow::Result<Option<ContractId>>
1508where
1509 W: Account + ViewOnlyAccount + Clone + 'static,
1510{
1511 match order_book_blacklist_id {
1512 Some(order_book_blacklist_id) => {
1513 tracing::info!(
1514 "Using existing OrderBookBlacklist: {}",
1515 order_book_blacklist_id
1516 );
1517 Ok(Some(order_book_blacklist_id))
1518 }
1519 None => {
1520 if !deploy_blacklist {
1521 return Ok(None);
1522 }
1523 tracing::info!("Deploying OrderBookBlacklist");
1524 let order_book_blacklist = OrderBookDeploy::deploy_order_book_blacklist(
1525 &deployer_wallet,
1526 &Identity::Address(ViewOnlyAccount::address(&deployer_wallet)),
1527 &OrderBookDeployConfig {
1528 salt,
1529 ..Default::default()
1530 },
1531 )
1532 .await?;
1533 tracing::info!("OrderBookBlacklist: {}", order_book_blacklist.contract_id());
1534 Ok(Some(order_book_blacklist.contract_id()))
1535 }
1536 }
1537}
1538
1539async fn deploy_order_book_whitelist<W>(
1540 deployer_wallet: W,
1541 deploy_whitelist: bool,
1542 order_book_whitelist_id: Option<ContractId>,
1543 salt: Salt,
1544) -> anyhow::Result<Option<ContractId>>
1545where
1546 W: Account + ViewOnlyAccount + Clone + 'static,
1547{
1548 match (order_book_whitelist_id, deploy_whitelist) {
1549 (Some(order_book_whitelist_id), false)
1550 | (Some(order_book_whitelist_id), true) => {
1551 tracing::info!(
1552 "Using existing OrderBookWhitelist: {}",
1553 order_book_whitelist_id
1554 );
1555 Ok(Some(order_book_whitelist_id))
1556 }
1557 (None, false) => Ok(None),
1558 (None, true) => {
1559 tracing::info!("Deploying OrderBookWhitelist");
1560 let trade_account_whitelist = OrderBookDeploy::deploy_order_book_whitelist(
1561 &deployer_wallet,
1562 &Identity::Address(ViewOnlyAccount::address(&deployer_wallet)),
1563 &OrderBookDeployConfig {
1564 salt,
1565 ..Default::default()
1566 },
1567 )
1568 .await?;
1569 tracing::info!(
1570 "OrderBookWhitelist: {}",
1571 trade_account_whitelist.contract_id()
1572 );
1573 Ok(Some(trade_account_whitelist.contract_id()))
1574 }
1575 }
1576}
1577
1578async fn load_or_recover_trade_account_oracle<W>(
1582 deployer_wallet: &W,
1583 oracle_id: ContractId,
1584) -> anyhow::Result<(TradeAccountDeploy<W>, ContractId)>
1585where
1586 W: Account + ViewOnlyAccount + Clone + 'static,
1587{
1588 let oracle = TradingAccountOracle::new(oracle_id, deployer_wallet.clone());
1589 let impl_id = oracle
1590 .methods()
1591 .get_trade_account_impl()
1592 .simulate(Execution::state_read_only())
1593 .await?
1594 .value;
1595
1596 let blob_id = match impl_id {
1597 Some(id) => id,
1598 None => {
1599 tracing::info!(
1600 "Trade account implementation not set on oracle {}, deploying...",
1601 oracle_id
1602 );
1603 let blob = TradeAccountDeploy::trade_account_blob(
1604 deployer_wallet,
1605 &Default::default(),
1606 )
1607 .await?;
1608 TradeAccountDeploy::deploy_trade_account_blob(
1609 deployer_wallet,
1610 &DeployConfig::Latest(Default::default()),
1611 )
1612 .await?;
1613 oracle
1614 .methods()
1615 .set_trade_account_impl(ContractId::from(blob.id))
1616 .call()
1617 .await?;
1618 ContractId::from(blob.id)
1619 }
1620 };
1621
1622 let deploy = TradeAccountDeploy {
1623 oracle,
1624 oracle_id,
1625 trade_account_blob_id: blob_id.into(),
1626 deployer_wallet: deployer_wallet.clone(),
1627 proxy: None,
1628 proxy_id: None,
1629 };
1630 Ok((deploy, blob_id))
1631}
1632
1633async fn deploy_trade_account_oracle<W>(
1634 deployer_wallet: W,
1635 should_upgrade_bytecode: bool,
1636 trade_account_oracle_id: Option<ContractId>,
1637 salt: Salt,
1638) -> anyhow::Result<(TradeAccountDeploy<W>, ContractId)>
1639where
1640 W: Account + ViewOnlyAccount + Clone + 'static,
1641{
1642 let (trade_account_oracle_deploy, mut trade_account_blob_id) =
1643 match trade_account_oracle_id {
1644 Some(oracle_id) => {
1645 load_or_recover_trade_account_oracle(&deployer_wallet, oracle_id).await?
1646 }
1647 None => {
1648 let deploy = TradeAccountDeploy::deploy(
1649 &deployer_wallet,
1650 &DeployConfig::Latest(TradeAccountDeployConfig {
1651 salt,
1652 ..Default::default()
1653 }),
1654 )
1655 .await?;
1656 let blob_id = deploy
1657 .oracle
1658 .methods()
1659 .get_trade_account_impl()
1660 .simulate(Execution::state_read_only())
1661 .await?
1662 .value
1663 .context("Trade account impl should exist after fresh deploy")?;
1664 (deploy, blob_id)
1665 }
1666 };
1667 tracing::info!(
1668 "TradeAccountOracle: {}",
1669 trade_account_oracle_deploy.oracle_id
1670 );
1671
1672 if should_upgrade_bytecode {
1673 let trade_account_blob =
1674 TradeAccountDeploy::trade_account_blob(&deployer_wallet, &Default::default())
1675 .await?;
1676 if ContractId::from(trade_account_blob.id) != trade_account_blob_id {
1677 tracing::info!(
1678 "Update TradeAccountImpl on Oracle from {:?} to new blob {:?}",
1679 trade_account_blob_id,
1680 ContractId::from(trade_account_blob.id)
1681 );
1682 TradeAccountDeploy::deploy_trade_account_blob(
1683 &deployer_wallet,
1684 &DeployConfig::Latest(Default::default()),
1685 )
1686 .await?;
1687 trade_account_oracle_deploy
1688 .oracle
1689 .methods()
1690 .set_trade_account_impl(ContractId::from(trade_account_blob.id))
1691 .call()
1692 .await?;
1693 trade_account_blob_id = ContractId::from(trade_account_blob.id);
1694 }
1695 }
1696
1697 Ok((trade_account_oracle_deploy, trade_account_blob_id))
1698}
1699
1700async fn deploy_trial_trade_account_oracle<W>(
1708 deployer_wallet: W,
1709 should_upgrade_bytecode: bool,
1710 trial_trade_account_oracle_id: Option<ContractId>,
1711 trial_cosigner: Option<fuels::types::Address>,
1712 salt: Salt,
1713) -> anyhow::Result<ContractId>
1714where
1715 W: Account + ViewOnlyAccount + Clone + 'static,
1716{
1717 let mut trial_deploy_config = TrialTradeAccountDeployConfig {
1718 salt,
1719 ..Default::default()
1720 };
1721 if let Some(cosigner) = trial_cosigner {
1722 trial_deploy_config = trial_deploy_config.with_cosigner(cosigner);
1723 }
1724 let deploy_config = TrialDeployConfig::Latest(trial_deploy_config);
1725
1726 let oracle_id = match trial_trade_account_oracle_id {
1727 None => {
1728 let trial_deploy =
1729 TrialTradeAccountDeploy::deploy(&deployer_wallet, &deploy_config).await?;
1730 tracing::info!(
1731 "TrialTradeAccountOracle: {} (implementation {:?}, cosigner {:?})",
1732 trial_deploy.oracle_id,
1733 trial_deploy.trial_trade_account_blob_id,
1734 trial_cosigner,
1735 );
1736 trial_deploy.oracle_id
1737 }
1738 Some(oracle_id) => {
1739 let current_trial_impl =
1740 TrialTradingAccountOracle::new(oracle_id, deployer_wallet.clone())
1741 .methods()
1742 .get_trial_account_impl()
1743 .simulate(Execution::state_read_only())
1744 .await?
1745 .value;
1746 if current_trial_impl.is_none()
1747 || should_upgrade_bytecode
1748 || trial_cosigner.is_some()
1749 {
1750 let trial_deploy = TrialTradeAccountDeploy::deploy_to_oracle(
1751 &deployer_wallet,
1752 oracle_id,
1753 &deploy_config,
1754 )
1755 .await?;
1756 tracing::info!(
1757 "Trial implementation {:?} deployed to oracle {} (cosigner {:?})",
1758 trial_deploy.trial_trade_account_blob_id,
1759 oracle_id,
1760 trial_cosigner,
1761 );
1762 }
1763 oracle_id
1764 }
1765 };
1766
1767 Ok(oracle_id)
1768}
1769
1770async fn deploy_trade_account_registry<W>(
1771 deployer_wallet: W,
1772 should_upgrade_bytecode: bool,
1773 trade_account_deploy: TradeAccountDeploy<W>,
1774 trial_trade_account_oracle_id: ContractId,
1775 trade_account_registry_id: Option<ContractId>,
1776 salt: Salt,
1777) -> anyhow::Result<(TradeAccountRegistryManager<W>, ContractId)>
1778where
1779 W: Account + ViewOnlyAccount + Clone + 'static,
1780{
1781 let trade_account_oracle_id = trade_account_deploy.oracle_id;
1782 let trade_account_registry = match trade_account_registry_id {
1783 Some(trade_account_registry_contract_id) => TradeAccountRegistryManager::new(
1784 deployer_wallet.clone(),
1785 trade_account_registry_contract_id,
1786 ),
1787 None => {
1788 let trade_account_registry_deploy_config = TradeAccountRegistryDeployConfig {
1789 salt,
1790 ..Default::default()
1791 };
1792 TradeAccountRegistryManager::deploy(
1793 &deployer_wallet,
1794 trade_account_oracle_id,
1795 trial_trade_account_oracle_id,
1796 &trade_account_registry_deploy_config,
1797 )
1798 .await?
1799 }
1800 };
1801 tracing::info!(
1802 "TradeAccountRegistry: {}",
1803 trade_account_registry.contract_id
1804 );
1805 let mut trade_account_registry_blob_id = match trade_account_registry
1806 .registry_proxy
1807 .methods()
1808 .proxy_target()
1809 .simulate(Execution::state_read_only())
1810 .await?
1811 .value
1812 {
1813 Some(blob_id) => blob_id,
1814 None => {
1815 tracing::info!("TradeAccountRegistry proxy target not set, initializing...");
1816 trade_account_registry
1820 .registry_proxy
1821 .methods()
1822 .initialize_proxy()
1823 .call()
1824 .await?;
1825 trade_account_registry
1826 .registry
1827 .methods()
1828 .initialize()
1829 .call()
1830 .await?;
1831 trade_account_registry
1832 .registry_proxy
1833 .methods()
1834 .proxy_target()
1835 .simulate(Execution::state_read_only())
1836 .await?
1837 .value
1838 .context("TradeAccountRegistry proxy target should be set after initialization")?
1839 }
1840 };
1841
1842 if should_upgrade_bytecode {
1843 let trade_account_registry_deploy_config = TradeAccountRegistryDeployConfig {
1847 registry_config: trade_account_registry
1848 .live_prop_config(TradeAccountRegistryConfigurables::default())
1849 .await?,
1850 ..Default::default()
1851 };
1852 let trade_account_proxy_blob = TradeAccountRegistryManager::register_proxy_blob(
1853 &deployer_wallet,
1854 &trade_account_registry_deploy_config,
1855 )
1856 .await?;
1857 let trial_trade_account_proxy_blob =
1858 TradeAccountRegistryManager::register_trial_proxy_blob(
1859 &deployer_wallet,
1860 &trade_account_registry_deploy_config,
1861 )
1862 .await?;
1863
1864 let trade_account_register_blob = TradeAccountRegistryManager::register_blob(
1865 &deployer_wallet,
1866 trade_account_oracle_id,
1867 trial_trade_account_oracle_id,
1868 trade_account_proxy_blob.id,
1869 trial_trade_account_proxy_blob.id,
1870 &trade_account_registry_deploy_config,
1871 )
1872 .await?;
1873
1874 if trade_account_registry_blob_id
1875 != ContractId::from(trade_account_register_blob.id)
1876 {
1877 tracing::info!(
1878 "Upgrade TradeAccountRegistry blob from {:?} to {:?}",
1879 trade_account_registry.contract_id,
1880 ContractId::from(trade_account_register_blob.id)
1881 );
1882 trade_account_registry
1886 .upgrade(
1887 trade_account_oracle_id,
1888 trial_trade_account_oracle_id,
1889 &trade_account_registry_deploy_config,
1890 )
1891 .await?;
1892 trade_account_registry_blob_id = trade_account_register_blob.id.into();
1893 }
1894 }
1895 Ok((trade_account_registry, trade_account_registry_blob_id))
1896}
1897
1898async fn deploy_order_book_registry<W>(
1899 deployer_wallet: W,
1900 should_upgrade_bytecode: bool,
1901 order_book_registry_id: Option<ContractId>,
1902 salt: Salt,
1903) -> anyhow::Result<(OrderBookRegistryManager<W>, ContractId)>
1904where
1905 W: Account + ViewOnlyAccount + Clone + 'static,
1906{
1907 let order_book_registry = match order_book_registry_id {
1908 Some(registry_contract_id) => {
1909 OrderBookRegistryManager::new(deployer_wallet.clone(), registry_contract_id)
1910 }
1911 None => {
1912 OrderBookRegistryManager::deploy(
1913 &deployer_wallet,
1914 &OrderBookRegistryDeployConfig {
1915 salt,
1916 ..Default::default()
1917 },
1918 )
1919 .await?
1920 }
1921 };
1922 tracing::info!("OrderBookRegistry: {}", order_book_registry.contract_id);
1923 let mut order_book_registry_blob_id = match order_book_registry
1924 .registry_proxy
1925 .methods()
1926 .proxy_target()
1927 .simulate(Execution::state_read_only())
1928 .await?
1929 .value
1930 {
1931 Some(blob_id) => blob_id,
1932 None => {
1933 tracing::info!("OrderBookRegistry proxy target not set, initializing...");
1934 order_book_registry
1938 .registry_proxy
1939 .methods()
1940 .initialize_proxy()
1941 .call()
1942 .await?;
1943 order_book_registry
1944 .registry
1945 .methods()
1946 .initialize()
1947 .call()
1948 .await?;
1949 order_book_registry
1950 .registry_proxy
1951 .methods()
1952 .proxy_target()
1953 .simulate(Execution::state_read_only())
1954 .await?
1955 .value
1956 .context(
1957 "OrderBookRegistry proxy target should be set after initialization",
1958 )?
1959 }
1960 };
1961
1962 if should_upgrade_bytecode {
1963 let order_book_register_deploy_config = OrderBookRegistryDeployConfig::default();
1964 let order_book_register_blob = OrderBookRegistryManager::register_blob(
1965 &deployer_wallet,
1966 &order_book_register_deploy_config,
1967 )
1968 .await?;
1969 if order_book_registry_blob_id != order_book_register_blob.id.into() {
1970 tracing::info!(
1971 "Upgrade OrderBookRegistry blob from {:?} to {:?}",
1972 order_book_registry.contract_id,
1973 ContractId::from(order_book_register_blob.id)
1974 );
1975 order_book_registry
1976 .upgrade(&order_book_register_deploy_config)
1977 .await?;
1978 order_book_registry_blob_id = order_book_register_blob.id.into();
1979 }
1980 }
1981
1982 Ok((order_book_registry, order_book_registry_blob_id))
1983}
1984
1985async fn deploy_order_books<W>(
1986 deployer_wallet: W,
1987 should_upgrade_bytecode: bool,
1988 order_book_blacklist_id: Option<ContractId>,
1989 order_book_whitelist_id: Option<ContractId>,
1990 order_book_registry: OrderBookRegistryManager<W>,
1991 order_book_configs: &mut [OrderBookConfig],
1992 ownership_options: OwnershipTransferOptions,
1993) -> anyhow::Result<Vec<OrderBookConfig>>
1994where
1995 W: Account + ViewOnlyAccount + Clone + 'static,
1996{
1997 let mut pairs: Vec<OrderBookConfig> = Vec::with_capacity(order_book_configs.len());
1998
1999 for order_book_config in order_book_configs.iter_mut() {
2000 let pair = deploy_single_order_book(
2001 &deployer_wallet,
2002 should_upgrade_bytecode,
2003 order_book_blacklist_id,
2004 order_book_whitelist_id,
2005 &order_book_registry,
2006 order_book_config,
2007 &ownership_options,
2008 )
2009 .await?;
2010 pairs.push(pair);
2011 }
2012
2013 Ok(pairs)
2014}
2015
2016async fn deploy_single_order_book<W>(
2017 deployer_wallet: &W,
2018 should_upgrade_bytecode: bool,
2019 order_book_blacklist_id: Option<ContractId>,
2020 order_book_whitelist_id: Option<ContractId>,
2021 order_book_registry: &OrderBookRegistryManager<W>,
2022 order_book_config: &mut OrderBookConfig,
2023 ownership_options: &OwnershipTransferOptions,
2024) -> anyhow::Result<OrderBookConfig>
2025where
2026 W: Account + ViewOnlyAccount + Clone + 'static,
2027{
2028 let market_symbol = format!(
2029 "{}/{}",
2030 order_book_config.base.symbol, order_book_config.quote.symbol
2031 );
2032 let market_id = MarketIdAssets {
2033 base_asset: order_book_config.base.asset,
2034 quote_asset: order_book_config.quote.asset,
2035 };
2036 let order_book_configurables = build_order_book_configurables(
2037 order_book_config,
2038 order_book_blacklist_id,
2039 order_book_whitelist_id,
2040 deployer_wallet,
2041 )?;
2042
2043 let order_book = load_or_deploy_order_book(
2044 deployer_wallet,
2045 order_book_registry,
2046 &market_id,
2047 &market_symbol,
2048 &order_book_configurables,
2049 order_book_config,
2050 )
2051 .await?;
2052
2053 tracing::info!(
2054 "[{}] OrderBook: {}",
2055 market_symbol,
2056 order_book.contract.contract_id()
2057 );
2058
2059 let order_book_blob_id = maybe_upgrade_order_book(
2060 deployer_wallet,
2061 should_upgrade_bytecode,
2062 &order_book,
2063 order_book_config,
2064 order_book_configurables,
2065 &market_symbol,
2066 )
2067 .await?;
2068
2069 set_order_book_maintainer(&order_book, ownership_options, &market_symbol).await?;
2073
2074 transfer_order_book_ownership(&order_book, ownership_options, &market_symbol).await?;
2075
2076 order_book_config.contract_id = Some(order_book.contract.contract_id());
2077 order_book_config.blob_id = order_book_blob_id.into();
2078
2079 Ok(order_book_config.clone())
2080}
2081
2082fn build_order_book_configurables<W: ViewOnlyAccount>(
2083 config: &OrderBookConfig,
2084 order_book_blacklist_id: Option<ContractId>,
2085 order_book_whitelist_id: Option<ContractId>,
2086 deployer_wallet: &W,
2087) -> anyhow::Result<OrderBookConfigurables> {
2088 let price_precision = config
2089 .quote
2090 .decimals
2091 .checked_sub(config.quote.max_precision)
2092 .ok_or_else(|| {
2093 anyhow::anyhow!(
2094 "quote max_precision ({}) exceeds decimals ({})",
2095 config.quote.max_precision,
2096 config.quote.decimals
2097 )
2098 })?;
2099 let quantity_precision = config
2100 .base
2101 .decimals
2102 .checked_sub(config.base.max_precision)
2103 .ok_or_else(|| {
2104 anyhow::anyhow!(
2105 "base max_precision ({}) exceeds decimals ({})",
2106 config.base.max_precision,
2107 config.base.decimals
2108 )
2109 })?;
2110
2111 Ok(OrderBookConfigurables::default()
2112 .with_MIN_ORDER(config.min_order)?
2113 .with_ALLOW_FRACTIONAL_PRICE(config.allow_fractional_price)?
2114 .with_TAKER_FEE(config.taker_fee.into())?
2115 .with_MAKER_FEE(config.maker_fee.into())?
2116 .with_DUST(config.dust)?
2117 .with_PRICE_WINDOW(config.price_window as u64)?
2118 .with_BASE_DECIMALS(10u64.pow(config.base.decimals as u32))?
2119 .with_QUOTE_DECIMALS(10u64.pow(config.quote.decimals as u32))?
2120 .with_BASE_SYMBOL(SizedAsciiString::new_with_right_whitespace_padding(
2121 config.base.symbol.clone(),
2122 )?)?
2123 .with_QUOTE_SYMBOL(SizedAsciiString::new_with_right_whitespace_padding(
2124 config.quote.symbol.clone(),
2125 )?)?
2126 .with_PRICE_PRECISION(10u64.pow(price_precision as u32))?
2127 .with_QUANTITY_PRECISION(10u64.pow(quantity_precision as u32))?
2128 .with_INITIAL_OWNER(o2_tools::order_book_deploy::State::Initialized(
2129 Identity::Address(ViewOnlyAccount::address(deployer_wallet)),
2130 ))?
2131 .with_WHITE_LIST_CONTRACT(order_book_whitelist_id)?
2132 .with_BLACK_LIST_CONTRACT(order_book_blacklist_id)?)
2133}
2134
2135async fn load_or_deploy_order_book<W>(
2136 deployer_wallet: &W,
2137 order_book_registry: &OrderBookRegistryManager<W>,
2138 market_id: &MarketIdAssets,
2139 market_symbol: &str,
2140 order_book_configurables: &OrderBookConfigurables,
2141 order_book_config: &OrderBookConfig,
2142) -> anyhow::Result<OrderBookManager<W>>
2143where
2144 W: Account + ViewOnlyAccount + Clone + 'static,
2145{
2146 let register_contract_id = order_book_registry
2147 .registry
2148 .methods()
2149 .get_order_book(to_registry_market_id(market_id))
2150 .simulate(Execution::state_read_only())
2151 .await?
2152 .value;
2153
2154 match register_contract_id {
2155 Some(contract_id) => {
2156 let order_book_deploy = OrderBookDeploy::new(
2157 deployer_wallet.clone(),
2158 contract_id,
2159 market_id.base_asset,
2160 market_id.quote_asset,
2161 );
2162 let proxy_target = order_book_deploy
2165 .order_book_proxy
2166 .methods()
2167 .proxy_target()
2168 .simulate(Execution::state_read_only())
2169 .await?
2170 .value;
2171 if proxy_target.is_none() {
2172 tracing::info!(
2173 "[{}] Proxy target not set, initializing...",
2174 market_symbol
2175 );
2176 order_book_deploy.initialize().await?;
2177 }
2178 Ok(OrderBookManager::new(
2179 deployer_wallet,
2180 10u64.pow(order_book_config.base.decimals as u32),
2181 10u64.pow(order_book_config.quote.decimals as u32),
2182 &order_book_deploy,
2183 ))
2184 }
2185 None => {
2186 let (order_book_deployment, initialization_required) =
2187 OrderBookDeploy::deploy_without_initialization(
2188 deployer_wallet,
2189 market_id.base_asset,
2190 market_id.quote_asset,
2191 &OrderBookDeployConfig {
2192 order_book_configurables: order_book_configurables.clone(),
2193 salt: Salt::from(*order_book_registry.contract_id),
2194 ..Default::default()
2195 },
2196 )
2197 .await?;
2198
2199 order_book_registry
2200 .register_order_book(
2201 to_registry_market_id(market_id),
2202 order_book_deployment.contract_id,
2203 )
2204 .await?;
2205
2206 if initialization_required {
2207 order_book_deployment.initialize().await?;
2208 }
2209 Ok(OrderBookManager::new(
2210 deployer_wallet,
2211 10u64.pow(order_book_config.base.decimals as u32),
2212 10u64.pow(order_book_config.quote.decimals as u32),
2213 &order_book_deployment,
2214 ))
2215 }
2216 }
2217}
2218
2219async fn maybe_upgrade_order_book<W>(
2220 deployer_wallet: &W,
2221 should_upgrade_bytecode: bool,
2222 order_book: &OrderBookManager<W>,
2223 order_book_config: &OrderBookConfig,
2224 order_book_configurables: OrderBookConfigurables,
2225 market_symbol: &str,
2226) -> anyhow::Result<ContractId>
2227where
2228 W: Account + ViewOnlyAccount + Clone + 'static,
2229{
2230 let mut order_book_blob_id = order_book
2231 .proxy
2232 .methods()
2233 .proxy_target()
2234 .simulate(Execution::state_read_only())
2235 .await?
2236 .value
2237 .context("Order book proxy target should be set after initialization")?;
2238
2239 if should_upgrade_bytecode {
2240 let order_book_deploy_config = OrderBookDeployConfig {
2241 order_book_configurables,
2242 ..Default::default()
2243 };
2244 let order_book_deploy = OrderBookDeploy::new(
2245 deployer_wallet.clone(),
2246 order_book.contract.contract_id(),
2247 order_book_config.base.asset,
2248 order_book_config.quote.asset,
2249 );
2250 let order_book_manager = OrderBookManager::new(
2251 deployer_wallet,
2252 10u64.pow(order_book_config.base.decimals as u32),
2253 10u64.pow(order_book_config.quote.decimals as u32),
2254 &order_book_deploy,
2255 );
2256 let order_book_blob = OrderBookDeploy::order_book_blob(
2257 deployer_wallet,
2258 order_book_config.base.asset,
2259 order_book_config.quote.asset,
2260 &order_book_deploy_config,
2261 )
2262 .await?;
2263
2264 if order_book_blob_id != order_book_blob.id.into() {
2265 tracing::info!(
2266 "[{}] Upgrade OrderBook blob from {:?} to {:?}",
2267 market_symbol,
2268 order_book_blob_id,
2269 ContractId::from(order_book_blob.id)
2270 );
2271 order_book_manager
2272 .upgrade(&order_book_deploy_config)
2273 .await?;
2274 tracing::info!(
2275 "[{}] Emit new configuration event for {}",
2276 market_symbol,
2277 order_book.contract.contract_id()
2278 );
2279 order_book_manager.emit_config().await?;
2280 order_book_blob_id = order_book_blob.id.into();
2281 }
2282 }
2283
2284 Ok(order_book_blob_id)
2285}
2286
2287async fn set_order_book_maintainer<W>(
2288 order_book: &OrderBookManager<W>,
2289 ownership_options: &OwnershipTransferOptions,
2290 market_symbol: &str,
2291) -> anyhow::Result<()>
2292where
2293 W: Account + ViewOnlyAccount + Clone + 'static,
2294{
2295 for account in &ownership_options.revoke_orderbook_maintainers {
2299 tracing::info!(
2300 "[{}] Revoking ORDERBOOK_MAINTAINER_ROLE from {}",
2301 market_symbol,
2302 account
2303 );
2304 order_book
2305 .contract
2306 .methods()
2307 .owner_revoke_role(ORDERBOOK_MAINTAINER_ROLE, Identity::Address(*account))
2308 .call()
2309 .await?;
2310 }
2311
2312 for account in &ownership_options.new_orderbook_maintainers {
2313 tracing::info!(
2314 "[{}] Granting ORDERBOOK_MAINTAINER_ROLE to {}",
2315 market_symbol,
2316 account
2317 );
2318 order_book
2319 .contract
2320 .methods()
2321 .owner_grant_role(ORDERBOOK_MAINTAINER_ROLE, Identity::Address(*account))
2322 .call()
2323 .await?;
2324 }
2325
2326 Ok(())
2327}
2328
2329async fn transfer_order_book_ownership<W>(
2330 order_book: &OrderBookManager<W>,
2331 ownership_options: &OwnershipTransferOptions,
2332 market_symbol: &str,
2333) -> anyhow::Result<()>
2334where
2335 W: Account + ViewOnlyAccount + Clone + 'static,
2336{
2337 if let Some(new_owner) = ownership_options.new_proxy_owner {
2338 let new_identity = Identity::Address(new_owner);
2339 tracing::info!(
2340 "[{}] Transferring OrderBook proxy ownership to {}",
2341 market_symbol,
2342 new_owner
2343 );
2344 order_book
2345 .proxy
2346 .methods()
2347 .set_owner(new_identity)
2348 .call()
2349 .await?;
2350 }
2351
2352 if let Some(new_owner) = ownership_options.new_contract_owner {
2353 let new_identity = Identity::Address(new_owner);
2354 tracing::info!(
2355 "[{}] Transferring OrderBook contract ownership to {}",
2356 market_symbol,
2357 new_owner
2358 );
2359 order_book
2360 .contract
2361 .methods()
2362 .transfer_ownership(new_identity)
2363 .call()
2364 .await?;
2365 }
2366
2367 Ok(())
2368}
2369
2370#[cfg(test)]
2371mod tests {
2372 use super::*;
2373
2374 #[test]
2375 fn load_config_empty_path_returns_default() {
2376 let result: MarketsConfigPartial = load_config_from_file("").unwrap();
2377 assert!(result.pairs.is_empty());
2378 }
2379
2380 #[test]
2381 fn load_config_missing_file_errors() {
2382 let result: Result<MarketsConfigPartial, _> =
2383 load_config_from_file("nonexistent_file_12345.json");
2384 assert!(result.is_err());
2385 }
2386
2387 #[test]
2388 fn checked_sub_catches_overflow() {
2389 let decimals: u32 = 6;
2391 let max_precision: u32 = 8; let result = decimals.checked_sub(max_precision);
2394 assert!(
2395 result.is_none(),
2396 "should return None when max_precision > decimals"
2397 );
2398
2399 let result = 9u32.checked_sub(6);
2401 assert_eq!(result, Some(3));
2402 }
2403
2404 #[test]
2405 fn markets_config_partial_default_has_empty_pairs() {
2406 let config = MarketsConfigPartial::default();
2407 assert!(config.pairs.is_empty());
2408 }
2409}
2410
2411#[cfg(test)]
2412mod fee_amount_tests {
2413 use super::collateral_amount;
2414
2415 fn at9(value: &str) -> anyhow::Result<u64> {
2416 collateral_amount(value, 9, "tier 1 open_fee")
2417 }
2418
2419 #[test]
2420 fn whole_and_fractional_units_scale_by_the_asset_decimals() {
2421 assert_eq!(at9("7.5").unwrap(), 7_500_000_000);
2422 assert_eq!(at9("7").unwrap(), 7_000_000_000);
2423 assert_eq!(at9("0").unwrap(), 0);
2424 assert_eq!(at9("0.000000001").unwrap(), 1);
2425 assert_eq!(at9("13.5").unwrap(), 13_500_000_000);
2426 assert_eq!(at9("7.1").unwrap(), 7_100_000_000);
2428 }
2429
2430 #[test]
2431 fn a_figure_the_asset_cannot_represent_is_refused_not_rounded() {
2432 let err = at9("7.0000000001").unwrap_err().to_string();
2433 assert!(err.contains("10 decimal places"), "{err}");
2434 assert!(err.contains("tier 1 open_fee"), "{err}");
2435 }
2436
2437 #[test]
2438 fn junk_is_refused() {
2439 for bad in ["", " ", "-1", "1.2.3", "abc", "1e9", ".5", "1_000"] {
2440 assert!(at9(bad).is_err(), "`{bad}` should not parse");
2441 }
2442 }
2443
2444 #[test]
2445 fn an_amount_past_u64_is_refused_rather_than_wrapping() {
2446 assert!(at9("18446744074").is_err());
2447 assert_eq!(at9("18446744073.709551615").unwrap(), u64::MAX);
2448 }
2449
2450 #[test]
2451 fn other_decimal_scales_are_honoured() {
2452 assert_eq!(collateral_amount("7.5", 6, "f").unwrap(), 7_500_000);
2453 assert_eq!(collateral_amount("7.5", 1, "f").unwrap(), 75);
2454 assert!(collateral_amount("7.5", 0, "f").is_err());
2455 assert_eq!(collateral_amount("7", 0, "f").unwrap(), 7);
2456 }
2457}