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
80const DISCOUNT_MANAGER_ROLE: u64 = 1;
87
88pub 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#[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 #[serde(skip_serializing_if = "Option::is_none")]
155 pub margin: Option<MarginConfig>,
156 pub pairs: Vec<OrderBookConfig>,
157}
158
159#[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 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#[serde_as]
275#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
276pub struct MarginTierConfig {
277 pub tier_id: u64,
278 #[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 pub prolong_fee: [String; 4],
291 pub max_credit_line_bps: u64,
292 pub max_price_age: u64,
293 pub open_fee: String,
296 pub profit_share_bps: u64,
297 pub price_band_bps: u64,
298 pub markets: Vec<String>,
301}
302
303#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
309pub struct MarginSupportedAsset {
310 pub asset: fuels::types::AssetId,
311 pub decimals: u8,
312}
313
314#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
318pub struct MarginPriceFeedConfig {
319 pub id: Option<ContractId>,
322 pub max_offchain_age_seconds: Option<u64>,
325 #[serde(default)]
331 pub publishers: Vec<String>,
332 #[serde(default)]
345 pub remove_publishers: Vec<String>,
346 #[serde(default)]
353 pub supported_assets: Vec<MarginSupportedAsset>,
354}
355
356#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
359pub struct MarginConfig {
360 #[serde(default)]
362 pub price_feed: MarginPriceFeedConfig,
363 #[serde(default, skip_serializing)]
373 pub price_feed_id: Option<ContractId>,
374 pub margin_pool_id: Option<ContractId>,
378 pub platform_payout: Option<String>,
381 #[serde(default)]
393 pub discount_managers: Vec<String>,
394 #[serde(default)]
406 pub remove_discount_managers: Vec<String>,
407 pub collateral_asset: Option<fuels::types::AssetId>,
410 pub collateral_decimals: Option<u8>,
413 pub max_tier_books: Option<u64>,
415 pub base_repay_fee_ppm: Option<u64>,
423 #[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 pub revoke_orderbook_maintainers: Vec<fuels::types::Address>,
438 pub new_orderbook_maintainers: Vec<fuels::types::Address>,
441}
442
443#[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 pub trial_cosigner: Option<fuels::types::Address>,
459 pub trial_creator: Option<Identity>,
466 pub margin_tier_only: bool,
482 pub margin_cosigner: Option<fuels::types::Address>,
488 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
497pub 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
517pub 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 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 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 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 ¶ms,
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 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#[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#[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 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 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 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 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 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 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 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 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 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 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 if upgrade_bytecode && !tier_only {
1086 o2_tools::prop_deploy::upgrade_price_feed_implementation(
1087 wallet,
1088 price_feed_id,
1089 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 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 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 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 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 .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 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
1234async 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
1352fn 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
1428fn 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
1452fn 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#[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
1586async 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
1666async 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
1788async 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 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 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 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 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 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_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
2176pub 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
2190fn 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 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 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 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 let decimals: u32 = 6;
2555 let max_precision: u32 = 8; let result = decimals.checked_sub(max_precision);
2558 assert!(
2559 result.is_none(),
2560 "should return None when max_precision > decimals"
2561 );
2562
2563 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 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 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 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 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 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}