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)]
276#[serde(deny_unknown_fields)]
277pub struct MarginTierConfig {
278 pub tier_id: u64,
279 #[serde_as(as = "serde_with::DisplayFromStr")]
281 pub line: u64,
282 pub leverage: u64,
283 pub duration: u64,
284 pub maintenance_bps: u64,
285 pub open_buffer_bps: u64,
286 pub liq_price_factor: u64,
287 pub prolong_fee: [String; 4],
292 pub max_credit_line_bps: u64,
293 pub max_price_age: u64,
294 pub open_fee: String,
297 pub profit_share_bps: u64,
298 pub price_band_bps: u64,
299 pub markets: Vec<String>,
302 #[serde(default, skip_serializing_if = "Option::is_none")]
310 pub turbo: Option<MarginTurboConfig>,
311 #[serde(default = "tier_enabled_default")]
318 pub enabled: bool,
319}
320
321fn tier_enabled_default() -> bool {
323 true
324}
325
326#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
330#[serde(deny_unknown_fields)]
331pub struct MarginTurboConfig {
332 pub forfeit_collateral: bool,
336 pub max_loss_bps: [u64; 4],
340 pub rollover_profit_bps: u64,
343 pub max_rollovers: u64,
345}
346
347impl From<&MarginTurboConfig> for o2_tools::prop::TurboTerms {
348 fn from(turbo: &MarginTurboConfig) -> Self {
349 Self {
350 forfeit_collateral: turbo.forfeit_collateral,
351 max_loss_bps: turbo.max_loss_bps,
352 rollover_profit_bps: turbo.rollover_profit_bps,
353 max_rollovers: turbo.max_rollovers,
354 }
355 }
356}
357
358#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
364pub struct MarginSupportedAsset {
365 pub asset: fuels::types::AssetId,
366 pub decimals: u8,
367}
368
369#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
373pub struct MarginPriceFeedConfig {
374 pub id: Option<ContractId>,
377 pub max_offchain_age_seconds: Option<u64>,
380 #[serde(default)]
386 pub publishers: Vec<String>,
387 #[serde(default)]
400 pub remove_publishers: Vec<String>,
401 #[serde(default)]
408 pub supported_assets: Vec<MarginSupportedAsset>,
409}
410
411#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
414pub struct MarginConfig {
415 #[serde(default)]
417 pub price_feed: MarginPriceFeedConfig,
418 #[serde(default, skip_serializing)]
428 pub price_feed_id: Option<ContractId>,
429 pub margin_pool_id: Option<ContractId>,
433 pub platform_payout: Option<String>,
436 #[serde(default)]
448 pub discount_managers: Vec<String>,
449 #[serde(default)]
461 pub remove_discount_managers: Vec<String>,
462 pub collateral_asset: Option<fuels::types::AssetId>,
465 pub collateral_decimals: Option<u8>,
468 pub max_tier_books: Option<u64>,
470 pub base_repay_fee_ppm: Option<u64>,
478 #[serde(default)]
482 pub tiers: Vec<MarginTierConfig>,
483}
484
485#[derive(Debug, Clone, Default)]
486pub struct OwnershipTransferOptions {
487 pub new_proxy_owner: Option<fuels::types::Address>,
488 pub new_contract_owner: Option<fuels::types::Address>,
489 pub revoke_orderbook_maintainers: Vec<fuels::types::Address>,
493 pub new_orderbook_maintainers: Vec<fuels::types::Address>,
496}
497
498#[derive(Debug, Clone)]
500pub struct DeployParams {
501 pub deploy_config: MarketsConfigPartial,
502 pub output: Option<String>,
503 pub deploy_whitelist: bool,
504 pub deploy_blacklist: bool,
505 pub upgrade_bytecode: bool,
506 pub new_proxy_owner: Option<fuels::types::Address>,
507 pub new_contract_owner: Option<fuels::types::Address>,
508 pub trial_cosigner: Option<fuels::types::Address>,
514 pub trial_creator: Option<Identity>,
521 pub margin_tier_only: bool,
537 pub margin_cosigner: Option<fuels::types::Address>,
543 pub margin_liquidator: Option<Identity>,
548 pub revoke_orderbook_maintainers: Vec<fuels::types::Address>,
549 pub new_orderbook_maintainers: Vec<fuels::types::Address>,
550}
551
552pub fn load_config_from_file<T>(config_path: &str) -> anyhow::Result<T>
558where
559 T: Default + serde::de::DeserializeOwned,
560{
561 if config_path.is_empty() {
562 return Ok(T::default());
563 }
564 let current_dir = std::env::current_dir()?;
565 let path = current_dir.join(config_path);
566 tracing::info!("Loading config from {}", path.display());
567 let file = std::fs::File::open(&path)?;
568 let config: T = serde_json::from_reader(file)?;
569 Ok(config)
570}
571
572pub async fn deploy<W>(
581 wallet: W,
582 params: DeployParams,
583) -> anyhow::Result<MarketsConfigOutput>
584where
585 W: Account + ViewOnlyAccount + Clone + 'static,
586{
587 tracing::info!("Starting Fuel o2 Registries and Markets");
588 let mut markets_config_partial = params.deploy_config.clone();
589 let starting_height: BlockHeight = markets_config_partial.starting_height.into();
590 let trade_account_oracle_id = markets_config_partial.trade_account_oracle_id;
591 let trial_trade_account_oracle_id =
592 markets_config_partial.trial_trade_account_oracle_id;
593 let order_book_registry_id = markets_config_partial.order_book_registry_id;
594 let trade_account_registry_id = markets_config_partial.trade_account_registry_id;
595 let fast_bridge_asset_registry_proxy_id =
596 markets_config_partial.fast_bridge_asset_registry_proxy_id;
597
598 let mut salt = Salt::zeroed();
599 salt.deref_mut()[..4].copy_from_slice(&starting_height.deref().to_be_bytes());
600
601 let (trade_account_oracle_deploy, trade_account_blob_id) =
602 deploy_trade_account_oracle(
603 wallet.clone(),
604 params.upgrade_bytecode,
605 trade_account_oracle_id,
606 salt,
607 )
608 .await?;
609 let trial_trade_account_oracle_id = deploy_trial_trade_account_oracle(
613 wallet.clone(),
614 params.upgrade_bytecode,
615 trial_trade_account_oracle_id,
616 params.trial_cosigner,
617 salt,
618 )
619 .await?;
620 let (trade_account_registry, trade_account_registry_blob_id) =
621 deploy_trade_account_registry(
622 wallet.clone(),
623 params.upgrade_bytecode,
624 trade_account_oracle_deploy.clone(),
625 trial_trade_account_oracle_id,
626 trade_account_registry_id,
627 salt,
628 )
629 .await?;
630 let order_book_blacklist_id = deploy_order_book_blacklist(
631 wallet.clone(),
632 params.deploy_blacklist,
633 markets_config_partial.order_book_blacklist_id,
634 salt,
635 )
636 .await?;
637 let order_book_whitelist_id = deploy_order_book_whitelist(
638 wallet.clone(),
639 params.deploy_whitelist,
640 markets_config_partial.order_book_whitelist_id,
641 salt,
642 )
643 .await?;
644 let (order_book_registry, order_book_registry_blob_id) = deploy_order_book_registry(
645 wallet.clone(),
646 params.upgrade_bytecode,
647 order_book_registry_id,
648 salt,
649 )
650 .await?;
651 let pairs = deploy_order_books(
652 wallet.clone(),
653 params.upgrade_bytecode,
654 order_book_blacklist_id,
655 order_book_whitelist_id,
656 order_book_registry.clone(),
657 &mut markets_config_partial.pairs,
658 OwnershipTransferOptions {
659 new_proxy_owner: params.new_proxy_owner,
660 new_contract_owner: params.new_contract_owner,
661 revoke_orderbook_maintainers: params.revoke_orderbook_maintainers.clone(),
662 new_orderbook_maintainers: params.new_orderbook_maintainers.clone(),
663 },
664 )
665 .await?;
666
667 let order_book_registry_id = order_book_registry.contract_id;
668 let trade_account_registry_id = trade_account_registry.contract_id;
669 let trade_account_oracle_id = trade_account_oracle_deploy.oracle_id;
670
671 let trade_account_proxy = trade_account_registry
672 .registry
673 .methods()
674 .default_bytecode()
675 .simulate(Execution::state_read_only())
676 .await?
677 .value
678 .context(
679 "Trade account registry default bytecode should exist after initialization",
680 )?;
681 let trade_account_root = trade_account_registry
682 .registry
683 .methods()
684 .factory_bytecode_root()
685 .simulate(Execution::state_read_only())
686 .await?
687 .value
688 .context("Trade account registry factory bytecode root should exist after initialization")?;
689
690 let margin_ids = match &markets_config_partial.margin {
694 Some(margin) => Some(
695 deploy_margin(
696 &wallet,
697 margin,
698 salt,
699 params.margin_tier_only,
700 trade_account_registry_id,
701 trade_account_oracle_id,
702 trial_trade_account_oracle_id,
703 &pairs,
704 params.margin_cosigner,
705 params.margin_liquidator,
706 params.upgrade_bytecode,
707 )
708 .await?,
709 ),
710 None => None,
711 };
712
713 if let Some(trial_creator) = params.trial_creator {
717 let current_creator = trade_account_registry
718 .get_trial_trade_account_creator()
719 .await?;
720 if current_creator != trial_creator {
721 tracing::info!("Setting trial trade account creator to {trial_creator:?}");
722 trade_account_registry
723 .set_trial_trade_account_creator(trial_creator)
724 .await?;
725 }
726 }
727
728 transfer_ownership(
729 &wallet,
730 ¶ms,
731 &order_book_registry,
732 &trade_account_registry,
733 &trade_account_oracle_deploy,
734 trial_trade_account_oracle_id,
735 order_book_blacklist_id,
736 order_book_whitelist_id,
737 )
738 .await?;
739
740 let deploy_result = MarketsConfigOutput {
741 starting_height: starting_height.into(),
742 trade_account_registry_id,
743 trade_account_registry_blob_id,
744 trade_account_proxy,
745 trade_account_blob_id,
746 trade_account_root: ContractId::from(trade_account_root.0),
747 trade_account_oracle_id,
748 trial_trade_account_oracle_id,
749 order_book_whitelist_id,
750 order_book_blacklist_id,
751 order_book_registry_id,
752 order_book_registry_blob_id,
753 pairs,
754 fast_bridge_asset_registry_proxy_id,
755 price_feed_id: margin_ids.map(|ids| ids.price_feed_id),
756 margin_pool_id: margin_ids.map(|ids| ids.margin_pool_id),
757 margin_oracle_id: margin_ids.and_then(|ids| ids.margin_oracle_id),
758 margin: markets_config_partial.margin.clone().map(|mut margin| {
765 if let Some(ids) = margin_ids {
766 margin.price_feed.id = Some(ids.price_feed_id);
767 margin.margin_pool_id = Some(ids.margin_pool_id);
768 }
769 margin
770 }),
771 };
772
773 if let Some(output_path) = params.output {
774 let json = serde_json::to_string_pretty(&deploy_result)?;
775 tracing::info!("Deploy result saved to {}", output_path);
776 std::fs::write(output_path, json)?;
777 }
778
779 Ok(deploy_result)
780}
781
782#[derive(Debug, Clone, Copy)]
788pub struct MarginIds {
789 pub price_feed_id: ContractId,
790 pub margin_pool_id: ContractId,
791 pub margin_oracle_id: Option<ContractId>,
792}
793
794#[allow(clippy::too_many_arguments)]
805async fn deploy_margin<W>(
806 wallet: &W,
807 margin: &MarginConfig,
808 salt: fuels::types::Salt,
809 tier_only: bool,
810 trade_account_registry_id: ContractId,
811 trade_account_oracle_id: ContractId,
812 trial_trade_account_oracle_id: ContractId,
813 pairs: &[OrderBookConfig],
814 margin_cosigner: Option<fuels::types::Address>,
815 margin_liquidator: Option<Identity>,
816 upgrade_bytecode: bool,
817) -> anyhow::Result<MarginIds>
818where
819 W: Account + ViewOnlyAccount + Clone + 'static,
820{
821 use o2_tools::prop::{
822 PriceFeedContract,
823 PropAccountOracleContract,
824 PropMarginPoolContract,
825 };
826
827 let collateral_asset = match margin.collateral_asset {
828 Some(collateral_asset) => collateral_asset,
829 None => {
830 pairs
831 .first()
832 .context(
833 "margin.collateral_asset is not set and there are no pairs to \
834 default it from",
835 )?
836 .quote
837 .asset
838 }
839 };
840 let cosigner = margin_cosigner;
845 let platform_payout = margin
846 .platform_payout
847 .as_deref()
848 .map(parse_margin_identity)
849 .transpose()?;
850 let liquidator = margin_liquidator;
851
852 let mut prop_config = PropDeployConfig::new(collateral_asset);
853 prop_config.collateral_decimals = margin.collateral_decimals.context(
856 "margin.collateral_decimals is required - set it to the collateral \
857 asset's decimals in the deploy config",
858 )?;
859 prop_config.max_tier_books = margin.max_tier_books.unwrap_or(80);
860 prop_config.base_repay_fee_ppm = margin.base_repay_fee_ppm.unwrap_or(100);
861 prop_config.cosigner = cosigner;
862 prop_config.platform_payout = platform_payout;
863 prop_config.liquidator = liquidator;
864 prop_config.salt = salt;
865 prop_config.existing_price_feed = margin.price_feed.id;
866 prop_config.max_offchain_age_seconds = margin.price_feed.max_offchain_age_seconds;
867 prop_config.existing_pool = margin.margin_pool_id;
871 prop_config.existing_registry = Some(ExistingRegistry {
872 registry_id: trade_account_registry_id,
873 trade_account_oracle_id,
874 trial_trade_account_oracle_id,
875 });
876
877 anyhow::ensure!(
878 !(tier_only && margin.margin_pool_id.is_none()),
879 "--margin-tier-only was requested but the markets config names no \
880 `margin.margin_pool_id`: there is no pool to reconcile tiers against"
881 );
882
883 let (price_feed_id, margin_pool_id, margin_oracle_id) = if let (
885 true,
886 Some(margin_pool_id),
887 ) =
888 (tier_only, margin.margin_pool_id)
889 {
890 tracing::info!("Margin: tier-only mode against pool {margin_pool_id}");
891 let price_feed_id = match margin.price_feed.id {
892 Some(price_feed_id) => price_feed_id,
893 None => {
894 PropMarginPoolContract::new(margin_pool_id, wallet.clone())
895 .methods()
896 .price_feed()
897 .simulate(Execution::state_read_only())
898 .await
899 .context("read the pool's price feed")?
900 .value
901 }
902 };
903 let margin_oracle_id =
909 TradeAccountRegistryManager::new(wallet.clone(), trade_account_registry_id)
910 .registry
911 .methods()
912 .get_prop_oracle_id()
913 .simulate(Execution::state_read_only())
914 .await
915 .map(|result| result.value)
916 .ok()
917 .filter(|oracle_id| *oracle_id != ContractId::zeroed());
918 (price_feed_id, margin_pool_id, margin_oracle_id)
919 } else {
920 let deployment = deploy_prop_system(wallet, &prop_config).await?;
921 tracing::info!(
922 "Margin: oracle {}, pool {}, feed {} (shared registry {} upgraded in place)",
923 deployment.oracle_id,
924 deployment.pool_id,
925 deployment.price_feed_id,
926 deployment.registry_id,
927 );
928 (
929 deployment.price_feed_id,
930 deployment.pool_id,
931 Some(deployment.oracle_id),
932 )
933 };
934
935 let pool_deployed = wallet
941 .try_provider()?
942 .contract_exists(&margin_pool_id)
943 .await?;
944 if pool_deployed {
945 let pool = PropMarginPoolContract::new(margin_pool_id, wallet.clone());
946 if let Some(platform_payout) = platform_payout {
947 let current = pool
948 .methods()
949 .platform_payout()
950 .simulate(Execution::state_read_only())
951 .await
952 .context("read the pool's platform payout")?
953 .value;
954 if current != platform_payout {
955 tracing::info!(
956 "Margin: platform payout {current:?} -> {platform_payout:?}"
957 );
958 pool.methods()
959 .set_platform_payout(platform_payout)
960 .call()
961 .await?;
962 }
963 }
964 if let Some(liquidator) = liquidator {
965 let current = pool
966 .methods()
967 .liquidator()
968 .simulate(Execution::state_read_only())
969 .await
970 .context("read the pool's liquidator")?
971 .value;
972 if current != liquidator {
973 tracing::info!("Margin: liquidator {current:?} -> {liquidator:?}");
974 pool.methods().set_liquidator(liquidator).call().await?;
975 }
976 }
977
978 for entry in &margin.discount_managers {
987 anyhow::ensure!(
988 !margin.remove_discount_managers.contains(entry),
989 "discount manager {entry} is listed in both \
990 `discount_managers` and `remove_discount_managers`"
991 );
992 }
993 for (entry, should_hold) in margin
994 .discount_managers
995 .iter()
996 .map(|entry| (entry, true))
997 .chain(
998 margin
999 .remove_discount_managers
1000 .iter()
1001 .map(|entry| (entry, false)),
1002 )
1003 {
1004 let manager = parse_margin_identity(entry)?;
1005 let holds = pool
1006 .methods()
1007 .has_role(DISCOUNT_MANAGER_ROLE, manager)
1008 .simulate(Execution::state_read_only())
1009 .await
1010 .context("read a discount manager's role")?
1011 .value;
1012 if holds == should_hold {
1013 continue;
1014 }
1015 if should_hold {
1016 tracing::info!("Margin: adding discount manager {manager:?}");
1017 pool.methods()
1018 .grant_role(DISCOUNT_MANAGER_ROLE, manager)
1019 .call()
1020 .await
1021 .context("grant a discount manager its role")?;
1022 } else {
1023 tracing::info!("Margin: removing discount manager {manager:?}");
1024 pool.methods()
1025 .revoke_role(DISCOUNT_MANAGER_ROLE, manager)
1026 .call()
1027 .await
1028 .context("revoke a discount manager's role")?;
1029 }
1030 }
1031 }
1032
1033 let margin_oracle_id = match margin_oracle_id {
1036 Some(oracle_id) => Some(oracle_id),
1037 None if cosigner.is_some() => {
1038 let registry = o2_tools::trade_account_registry::TradeAccountRegistry::new(
1039 trade_account_registry_id,
1040 wallet.clone(),
1041 );
1042 let oracle_id = registry
1043 .methods()
1044 .get_prop_oracle_id()
1045 .simulate(Execution::state_read_only())
1046 .await
1047 .context("read the registry's prop account oracle")?
1048 .value;
1049 (oracle_id != ContractId::zeroed()).then_some(oracle_id)
1050 }
1051 None => None,
1052 };
1053
1054 if let (Some(cosigner), Some(oracle_id)) = (cosigner, margin_oracle_id)
1059 && wallet.try_provider()?.contract_exists(&oracle_id).await?
1060 {
1061 let oracle = PropAccountOracleContract::new(oracle_id, wallet.clone());
1062 let current = oracle
1063 .methods()
1064 .get_cosigner()
1065 .simulate(Execution::state_read_only())
1066 .await
1067 .context("read the prop account oracle's cosigner")?
1068 .value;
1069 if current != Some(cosigner) {
1070 tracing::info!("Margin: cosigner {current:?} -> {cosigner:?}");
1071 oracle.methods().set_cosigner(cosigner).call().await?;
1072 }
1073 }
1074
1075 for entry in &margin.price_feed.publishers {
1083 anyhow::ensure!(
1084 !margin.price_feed.remove_publishers.contains(entry),
1085 "price feed publisher {entry} is listed in both `publishers` and \
1086 `remove_publishers`"
1087 );
1088 }
1089 let price_feed = PriceFeedContract::new(price_feed_id, wallet.clone());
1090 for (entry, should_hold) in margin
1091 .price_feed
1092 .publishers
1093 .iter()
1094 .map(|entry| (entry, true))
1095 .chain(
1096 margin
1097 .price_feed
1098 .remove_publishers
1099 .iter()
1100 .map(|entry| (entry, false)),
1101 )
1102 {
1103 let publisher = parse_margin_identity(entry)?;
1104 let holds = price_feed
1105 .methods()
1106 .has_role(o2_tools::prop_deploy::PRICE_SUBMITTER_ROLE, publisher)
1107 .simulate(Execution::state_read_only())
1108 .await
1109 .context("read a price feed publisher's submitter role")?
1110 .value;
1111 if holds == should_hold {
1112 continue;
1113 }
1114 if should_hold {
1115 tracing::info!("Margin: adding price feed publisher {publisher:?}");
1116 price_feed
1117 .methods()
1118 .add_publisher(publisher)
1119 .call()
1120 .await
1121 .context("grant a price feed publisher the submitter role")?;
1122 } else {
1123 tracing::info!("Margin: removing price feed publisher {publisher:?}");
1124 price_feed
1125 .methods()
1126 .remove_publisher(publisher)
1127 .call()
1128 .await
1129 .context("revoke a price feed publisher's submitter role")?;
1130 }
1131 }
1132 if upgrade_bytecode && !tier_only {
1141 o2_tools::prop_deploy::upgrade_price_feed_implementation(
1142 wallet,
1143 price_feed_id,
1144 prop_config
1147 .owner
1148 .unwrap_or(Identity::Address(wallet.address())),
1149 &prop_config,
1150 )
1151 .await
1152 .context("upgrade the price feed implementation")?;
1153 }
1154
1155 if let Some(seconds) = margin.price_feed.max_offchain_age_seconds {
1160 let current = price_feed
1161 .methods()
1162 .get_max_offchain_age()
1163 .simulate(Execution::state_read_only())
1164 .await
1165 .context("read the price feed's submission-age bound")?
1166 .value;
1167 if current != seconds {
1168 tracing::info!("Margin: submission-age bound {current} -> {seconds}");
1169 price_feed
1170 .methods()
1171 .set_max_offchain_age(seconds)
1172 .call()
1173 .await
1174 .context("set the price feed's submission-age bound")?;
1175 }
1176 }
1177
1178 for supported in &margin.price_feed.supported_assets {
1184 let current = price_feed
1185 .methods()
1186 .get_asset_decimals(supported.asset)
1187 .simulate(Execution::state_read_only())
1188 .await
1189 .context("read the price feed's asset decimals")?
1190 .value;
1191 match current {
1192 Some(decimals) if decimals == supported.decimals => continue,
1193 Some(decimals) => anyhow::bail!(
1197 "price feed already knows {} at {decimals} decimals, config says \
1198 {} - refusing to move it, since the pool pins decimals on first \
1199 use and rejects a change",
1200 supported.asset,
1201 supported.decimals,
1202 ),
1203 None => {
1204 tracing::info!(
1205 "Margin: registering {} at {} decimals on the feed",
1206 supported.asset,
1207 supported.decimals,
1208 );
1209 price_feed
1210 .methods()
1211 .set_asset_decimals(supported.asset, supported.decimals)
1212 .call()
1213 .await
1214 .context("register a supported asset on the price feed")?;
1215 }
1216 }
1217 }
1218
1219 let pool = PropMarginPoolContract::new(margin_pool_id, wallet.clone());
1220 let pool_reachable = wallet
1221 .try_provider()?
1222 .contract_exists(&margin_pool_id)
1223 .await?;
1224
1225 if pool_reachable {
1235 let live_feed = pool
1236 .methods()
1237 .price_feed()
1238 .simulate(Execution::state_read_only())
1239 .await
1240 .context("read the pool's price feed")?
1241 .value;
1242 if live_feed != price_feed_id {
1243 tracing::info!("Margin: pool price feed {live_feed} -> {price_feed_id}");
1244 pool.methods()
1245 .set_price_feed(price_feed_id)
1246 .with_contract_ids(&[price_feed_id])
1249 .call()
1250 .await
1251 .context(
1252 "point the pool at the configured price feed - the \
1253 replacement must already know every admitted asset at the \
1254 decimals the pool pinned",
1255 )?;
1256 }
1257 }
1258
1259 anyhow::ensure!(
1266 pool_reachable || margin.tiers.is_empty(),
1267 "margin pool {margin_pool_id} is not on chain, so its {} configured \
1268 tier(s) cannot be reconciled",
1269 margin.tiers.len()
1270 );
1271 if pool_reachable {
1272 reconcile_margin_tiers(
1273 &pool,
1274 price_feed_id,
1275 pairs,
1276 &margin.tiers,
1277 prop_config.collateral_decimals,
1278 )
1279 .await?;
1280 }
1281
1282 Ok(MarginIds {
1283 price_feed_id,
1284 margin_pool_id,
1285 margin_oracle_id,
1286 })
1287}
1288
1289async fn reconcile_margin_tiers<W>(
1298 pool: &o2_tools::prop::PropMarginPoolContract<W>,
1299 price_feed_id: ContractId,
1300 pairs: &[OrderBookConfig],
1301 tiers: &[MarginTierConfig],
1302 collateral_decimals: u8,
1303) -> anyhow::Result<()>
1304where
1305 W: Account + ViewOnlyAccount + Clone + 'static,
1306{
1307 for tier in tiers {
1308 let books = tier
1309 .markets
1310 .iter()
1311 .map(|market| resolve_tier_market(market, pairs))
1312 .collect::<anyhow::Result<Vec<_>>>()?;
1313 let params = tier_params(tier, collateral_decimals)?;
1314 let terms = tier.turbo.as_ref().map(o2_tools::prop::TurboTerms::from);
1315 validate_turbo_tier_config(tier, ¶ms, terms.as_ref())?;
1319 let current_version = pool
1320 .methods()
1321 .current_tier_version(tier.tier_id)
1322 .simulate(Execution::state_read_only())
1323 .await?
1324 .value;
1325
1326 let mut contract_ids = vec![price_feed_id];
1327 contract_ids.extend(books.iter().copied());
1328
1329 let Some(version) = current_version else {
1330 let version = publish_margin_tier(
1331 pool,
1332 tier.tier_id,
1333 params,
1334 terms.clone(),
1335 books,
1336 &contract_ids,
1337 )
1338 .await?;
1339 tracing::info!(
1340 "Margin: tier {} published version {version}{}",
1341 tier.tier_id,
1342 if terms.is_some() { " (prepaid)" } else { "" }
1343 );
1344 reconcile_margin_tier_enabled(pool, tier).await?;
1345 continue;
1346 };
1347
1348 let live = pool
1349 .methods()
1350 .get_tier(tier.tier_id, version)
1351 .simulate(Execution::state_read_only())
1352 .await?
1353 .value
1354 .with_context(|| {
1355 format!("tier {} version {version} vanished mid-read", tier.tier_id)
1356 })?;
1357 let live_terms = pool
1364 .methods()
1365 .get_turbo_terms(tier.tier_id, version)
1366 .simulate(Execution::state_read_only())
1367 .await?
1368 .value;
1369 if live != params || live_terms != terms {
1370 let new_version = publish_margin_tier(
1371 pool,
1372 tier.tier_id,
1373 params,
1374 terms.clone(),
1375 books,
1376 &contract_ids,
1377 )
1378 .await?;
1379 tracing::info!(
1380 "Margin: tier {} republished as version {new_version} ({})",
1381 tier.tier_id,
1382 if live_terms != terms {
1383 "terms changed"
1384 } else {
1385 "economics changed"
1386 }
1387 );
1388 reconcile_margin_tier_enabled(pool, tier).await?;
1389 continue;
1390 }
1391 reconcile_margin_tier_enabled(pool, tier).await?;
1392
1393 let live_books = pool
1394 .methods()
1395 .tier_books(tier.tier_id, version)
1396 .simulate(Execution::state_read_only())
1397 .await?
1398 .value;
1399 let missing: Vec<ContractId> = books
1400 .iter()
1401 .copied()
1402 .filter(|book| !live_books.contains(book))
1403 .collect();
1404 let shrunk = live_books
1405 .iter()
1406 .filter(|book| !books.contains(book))
1407 .count();
1408 if shrunk > 0 {
1409 tracing::warn!(
1410 "Margin: tier {} declares {shrunk} fewer book(s) than live version \
1411 {version}; books are append-only — publish a new version to drop \
1412 markets",
1413 tier.tier_id
1414 );
1415 }
1416 if missing.is_empty() {
1417 tracing::info!(
1418 "Margin: tier {} version {version} matches the declared catalogue — \
1419 unchanged",
1420 tier.tier_id
1421 );
1422 continue;
1423 }
1424 let mut add_contract_ids = vec![price_feed_id];
1425 add_contract_ids.extend(missing.iter().copied());
1426 pool.methods()
1427 .add_books(tier.tier_id, missing.clone())
1428 .with_contract_ids(&add_contract_ids)
1429 .call()
1430 .await?;
1431 tracing::info!(
1432 "Margin: tier {} version {version} gained {} book(s)",
1433 tier.tier_id,
1434 missing.len()
1435 );
1436 }
1437 Ok(())
1438}
1439
1440async fn publish_margin_tier<W>(
1446 pool: &o2_tools::prop::PropMarginPoolContract<W>,
1447 tier_id: u64,
1448 params: o2_tools::prop::TierParams,
1449 terms: Option<o2_tools::prop::TurboTerms>,
1450 books: Vec<ContractId>,
1451 contract_ids: &[ContractId],
1452) -> anyhow::Result<u32>
1453where
1454 W: Account + ViewOnlyAccount + Clone + 'static,
1455{
1456 let version = match terms {
1457 Some(terms) => {
1458 pool.methods()
1459 .publish_tier_v2(tier_id, params, terms, books)
1460 .with_contract_ids(contract_ids)
1461 .call()
1462 .await?
1463 .value
1464 }
1465 None => {
1466 pool.methods()
1467 .publish_tier_version(tier_id, params, books)
1468 .with_contract_ids(contract_ids)
1469 .call()
1470 .await?
1471 .value
1472 }
1473 };
1474 Ok(version)
1475}
1476
1477async fn reconcile_margin_tier_enabled<W>(
1485 pool: &o2_tools::prop::PropMarginPoolContract<W>,
1486 tier: &MarginTierConfig,
1487) -> anyhow::Result<()>
1488where
1489 W: Account + ViewOnlyAccount + Clone + 'static,
1490{
1491 let live = pool
1492 .methods()
1493 .is_tier_enabled(tier.tier_id)
1494 .simulate(Execution::state_read_only())
1495 .await?
1496 .value;
1497 if live == tier.enabled {
1498 return Ok(());
1499 }
1500 pool.methods()
1501 .set_tier_enabled(tier.tier_id, tier.enabled)
1502 .call()
1503 .await?;
1504 tracing::info!(
1505 "Margin: tier {} now {} for new sessions",
1506 tier.tier_id,
1507 if tier.enabled { "OPEN" } else { "CLOSED" }
1508 );
1509 Ok(())
1510}
1511
1512fn validate_turbo_tier_config(
1519 tier: &MarginTierConfig,
1520 params: &o2_tools::prop::TierParams,
1521 terms: Option<&o2_tools::prop::TurboTerms>,
1522) -> anyhow::Result<()> {
1523 let Some(terms) = terms else {
1524 return Ok(());
1525 };
1526 let id = tier.tier_id;
1527 anyhow::ensure!(
1528 params.duration == 0,
1529 "tier {id} is prepaid, so `duration` must be 0 - the term it sells is \
1530 the week bought at open, not a tier-wide base period"
1531 );
1532 anyhow::ensure!(
1533 params.prolong_fee.iter().all(|fee| *fee == 0),
1534 "tier {id} is prepaid, so every `prolong_fee` must be \"0\" - a \
1535 prepaid term is extended by the FREE rollover and by nothing else"
1536 );
1537 anyhow::ensure!(
1538 params.profit_share_bps == 0,
1539 "tier {id} is prepaid, so `profit_share_bps` must be 0 - the user \
1540 keeps 100% of profits and the platform's take is the forfeited entry"
1541 );
1542 anyhow::ensure!(
1543 terms.rollover_profit_bps <= 10_000,
1544 "tier {id} `rollover_profit_bps` must be <= 10000"
1545 );
1546 anyhow::ensure!(
1547 terms.max_loss_bps.iter().all(|bps| *bps <= 10_000),
1548 "tier {id} every `max_loss_bps` entry must be <= 10000 - a rate past \
1549 BPS would put the floor below zero for any anchor"
1550 );
1551 if terms.rollover_profit_bps != 0 {
1552 anyhow::ensure!(
1553 terms.max_rollovers != 0,
1554 "tier {id} offers a rollover, so `max_rollovers` must not be 0 - \
1555 an enabled feature must be a real one"
1556 );
1557 }
1558 if terms.max_loss_bps.iter().any(|bps| *bps != 0) {
1559 anyhow::ensure!(
1560 terms.rollover_profit_bps != 0,
1561 "tier {id} sells a drawdown escalator, which is measured from the \
1562 rollover anchor, so `rollover_profit_bps` must not be 0 - the \
1563 schedule would otherwise be silently dead"
1564 );
1565 let last_step = terms.max_rollovers.min(3) as usize;
1570 for step in 0..=last_step {
1571 anyhow::ensure!(
1572 terms.max_loss_bps[step] != 0,
1573 "tier {id} sells a drawdown escalator whose step {step} is 0, \
1574 which floors that term at the session's own anchor - a \
1575 session reaching it is liquidatable immediately"
1576 );
1577 anyhow::ensure!(
1578 step == 0 || terms.max_loss_bps[step] >= terms.max_loss_bps[step - 1],
1579 "tier {id} drawdown step {step} is NARROWER than the step \
1580 before it - the schedule is sold as an escalator and must \
1581 never take back room a previous term already granted"
1582 );
1583 }
1584 let line = u128::from(params.line);
1593 let bps_up = |bps: u64| (line * u128::from(bps)).div_ceil(10_000);
1594 let allowance = line * u128::from(terms.max_loss_bps[0]) / 10_000;
1595 let gap =
1596 bps_up(params.open_buffer_bps).saturating_sub(bps_up(params.maintenance_bps));
1597 anyhow::ensure!(
1598 gap <= allowance,
1599 "tier {id} first drawdown step ({} bps) is narrower than the \
1600 tier's own `open_buffer_bps - maintenance_bps` gap, so every \
1601 session it sells opens already FROZEN and can never trade",
1602 terms.max_loss_bps[0]
1603 );
1604 }
1605 Ok(())
1606}
1607
1608fn collateral_amount(value: &str, decimals: u8, field: &str) -> anyhow::Result<u64> {
1617 let raw = value.trim();
1618 anyhow::ensure!(!raw.is_empty(), "{field}: empty amount");
1619 anyhow::ensure!(
1620 !raw.starts_with('-'),
1621 "{field}: `{raw}` is negative; fees are unsigned"
1622 );
1623 let (whole, fraction) = match raw.split_once('.') {
1624 Some((whole, fraction)) => (whole, fraction),
1625 None => (raw, ""),
1626 };
1627 anyhow::ensure!(
1628 !fraction.contains('.'),
1629 "{field}: `{raw}` has more than one decimal point"
1630 );
1631 anyhow::ensure!(
1632 !whole.is_empty() && whole.bytes().all(|b| b.is_ascii_digit()),
1633 "{field}: `{raw}` is not a decimal amount"
1634 );
1635 anyhow::ensure!(
1636 fraction.bytes().all(|b| b.is_ascii_digit()),
1637 "{field}: `{raw}` is not a decimal amount"
1638 );
1639 let decimals = usize::from(decimals);
1640 anyhow::ensure!(
1641 fraction.len() <= decimals,
1642 "{field}: `{raw}` has {} decimal places, but the collateral asset has \
1643 only {decimals}; the chain cannot represent it",
1644 fraction.len()
1645 );
1646 let digits = format!("{whole}{fraction}{}", "0".repeat(decimals - fraction.len()));
1647 digits
1648 .parse::<u64>()
1649 .map_err(|e| anyhow::anyhow!("{field}: `{raw}` does not fit a u64: {e}"))
1650}
1651
1652fn tier_params(
1653 tier: &MarginTierConfig,
1654 collateral_decimals: u8,
1655) -> anyhow::Result<o2_tools::prop::TierParams> {
1656 let fee = |value: &str, what: &str| {
1657 collateral_amount(
1658 value,
1659 collateral_decimals,
1660 &format!("tier {} {what}", tier.tier_id),
1661 )
1662 };
1663 Ok(o2_tools::prop::TierParams {
1664 line: tier.line,
1665 leverage: tier.leverage,
1666 duration: tier.duration,
1667 maintenance_bps: tier.maintenance_bps,
1668 open_buffer_bps: tier.open_buffer_bps,
1669 liq_price_factor: tier.liq_price_factor,
1670 prolong_fee: [
1671 fee(&tier.prolong_fee[0], "prolong_fee[0]")?,
1672 fee(&tier.prolong_fee[1], "prolong_fee[1]")?,
1673 fee(&tier.prolong_fee[2], "prolong_fee[2]")?,
1674 fee(&tier.prolong_fee[3], "prolong_fee[3]")?,
1675 ],
1676 max_credit_line_bps: tier.max_credit_line_bps,
1677 max_price_age: tier.max_price_age,
1678 open_fee: fee(&tier.open_fee, "open_fee")?,
1679 profit_share_bps: tier.profit_share_bps,
1680 price_band_bps: tier.price_band_bps,
1681 })
1682}
1683
1684fn resolve_tier_market(
1687 market: &str,
1688 pairs: &[OrderBookConfig],
1689) -> anyhow::Result<ContractId> {
1690 let wanted = market.trim();
1691 let wanted_id = wanted
1692 .strip_prefix("0x")
1693 .unwrap_or(wanted)
1694 .to_ascii_lowercase();
1695 let pair = pairs
1696 .iter()
1697 .find(|pair| {
1698 let symbol = format!("{}/{}", pair.base.symbol, pair.quote.symbol);
1699 symbol.eq_ignore_ascii_case(wanted)
1700 || hex::encode(*pair.market_id) == wanted_id
1701 })
1702 .with_context(|| format!("margin tier references unknown market `{market}`"))?;
1703 pair.contract_id.with_context(|| {
1704 format!("margin tier market `{market}` has no deployed order book")
1705 })
1706}
1707
1708fn parse_margin_identity(s: &str) -> anyhow::Result<Identity> {
1710 if let Some(hex_part) = s.strip_prefix("address:") {
1711 Ok(Identity::Address(fuels::types::Address::new(
1712 parse_margin_bytes32(hex_part)?,
1713 )))
1714 } else if let Some(hex_part) = s.strip_prefix("contract:") {
1715 Ok(Identity::ContractId(ContractId::new(parse_margin_bytes32(
1716 hex_part,
1717 )?)))
1718 } else {
1719 anyhow::bail!("expected `address:0x..` or `contract:0x..`, got `{s}`")
1720 }
1721}
1722
1723fn parse_margin_bytes32(s: &str) -> anyhow::Result<[u8; 32]> {
1724 let raw = s.trim().strip_prefix("0x").unwrap_or(s.trim());
1725 let bytes = hex::decode(raw)
1726 .map_err(|e| anyhow::anyhow!("expected 32 hex bytes, got `{s}`: {e}"))?;
1727 bytes
1728 .try_into()
1729 .map_err(|_| anyhow::anyhow!("expected 32 hex bytes, got `{s}`"))
1730}
1731
1732#[allow(clippy::too_many_arguments)]
1737async fn transfer_ownership<W>(
1738 wallet: &W,
1739 params: &DeployParams,
1740 order_book_registry: &OrderBookRegistryManager<W>,
1741 trade_account_registry: &TradeAccountRegistryManager<W>,
1742 trade_account_oracle_deploy: &TradeAccountDeploy<W>,
1743 trial_trade_account_oracle_id: ContractId,
1744 order_book_blacklist_id: Option<ContractId>,
1745 order_book_whitelist_id: Option<ContractId>,
1746) -> anyhow::Result<()>
1747where
1748 W: Account + ViewOnlyAccount + Clone + 'static,
1749{
1750 if let Some(new_proxy_owner) = params.new_proxy_owner {
1751 let new_identity = Identity::Address(new_proxy_owner);
1752 tracing::info!(
1753 "Transferring OrderBookRegistry proxy ownership to {}",
1754 new_proxy_owner
1755 );
1756 order_book_registry
1757 .registry_proxy
1758 .methods()
1759 .set_owner(new_identity)
1760 .call()
1761 .await?;
1762 tracing::info!(
1763 "Transferring TradeAccountRegistry proxy ownership to {}",
1764 new_proxy_owner
1765 );
1766 trade_account_registry
1767 .registry_proxy
1768 .methods()
1769 .set_owner(new_identity)
1770 .call()
1771 .await?;
1772 }
1773
1774 if let Some(new_contract_owner) = params.new_contract_owner {
1775 let new_identity = Identity::Address(new_contract_owner);
1776 tracing::info!(
1777 "Transferring TradeAccountOracle ownership to {}",
1778 new_contract_owner
1779 );
1780 trade_account_oracle_deploy
1781 .oracle
1782 .methods()
1783 .transfer_ownership(new_identity)
1784 .call()
1785 .await?;
1786 tracing::info!(
1787 "Transferring TrialTradeAccountOracle ownership to {}",
1788 new_contract_owner
1789 );
1790 TrialTradingAccountOracle::new(trial_trade_account_oracle_id, wallet.clone())
1791 .methods()
1792 .transfer_ownership(new_identity)
1793 .call()
1794 .await?;
1795 tracing::info!(
1796 "Transferring TradeAccountRegistry ownership to {}",
1797 new_contract_owner
1798 );
1799 trade_account_registry
1800 .registry
1801 .methods()
1802 .transfer_ownership(new_identity)
1803 .call()
1804 .await?;
1805 tracing::info!(
1806 "Transferring OrderBookRegistry ownership to {}",
1807 new_contract_owner
1808 );
1809 order_book_registry
1810 .registry
1811 .methods()
1812 .transfer_ownership(new_identity)
1813 .call()
1814 .await?;
1815 if let Some(blacklist_id) = order_book_blacklist_id {
1816 tracing::info!(
1817 "Transferring OrderBookBlacklist ownership to {}",
1818 new_contract_owner
1819 );
1820 OrderBookBlacklist::new(blacklist_id, wallet.clone())
1821 .methods()
1822 .transfer_ownership(new_identity)
1823 .call()
1824 .await?;
1825 }
1826 if let Some(whitelist_id) = order_book_whitelist_id {
1827 tracing::info!(
1828 "Transferring OrderBookWhitelist ownership to {}",
1829 new_contract_owner
1830 );
1831 OrderBookWhitelist::new(whitelist_id, wallet.clone())
1832 .methods()
1833 .transfer_ownership(new_identity)
1834 .call()
1835 .await?;
1836 }
1837 }
1838
1839 Ok(())
1840}
1841
1842async fn deploy_order_book_blacklist<W>(
1847 deployer_wallet: W,
1848 deploy_blacklist: bool,
1849 order_book_blacklist_id: Option<ContractId>,
1850 salt: Salt,
1851) -> anyhow::Result<Option<ContractId>>
1852where
1853 W: Account + ViewOnlyAccount + Clone + 'static,
1854{
1855 match order_book_blacklist_id {
1856 Some(order_book_blacklist_id) => {
1857 tracing::info!(
1858 "Using existing OrderBookBlacklist: {}",
1859 order_book_blacklist_id
1860 );
1861 Ok(Some(order_book_blacklist_id))
1862 }
1863 None => {
1864 if !deploy_blacklist {
1865 return Ok(None);
1866 }
1867 tracing::info!("Deploying OrderBookBlacklist");
1868 let order_book_blacklist = OrderBookDeploy::deploy_order_book_blacklist(
1869 &deployer_wallet,
1870 &Identity::Address(ViewOnlyAccount::address(&deployer_wallet)),
1871 &OrderBookDeployConfig {
1872 salt,
1873 ..Default::default()
1874 },
1875 )
1876 .await?;
1877 tracing::info!("OrderBookBlacklist: {}", order_book_blacklist.contract_id());
1878 Ok(Some(order_book_blacklist.contract_id()))
1879 }
1880 }
1881}
1882
1883async fn deploy_order_book_whitelist<W>(
1884 deployer_wallet: W,
1885 deploy_whitelist: bool,
1886 order_book_whitelist_id: Option<ContractId>,
1887 salt: Salt,
1888) -> anyhow::Result<Option<ContractId>>
1889where
1890 W: Account + ViewOnlyAccount + Clone + 'static,
1891{
1892 match (order_book_whitelist_id, deploy_whitelist) {
1893 (Some(order_book_whitelist_id), false)
1894 | (Some(order_book_whitelist_id), true) => {
1895 tracing::info!(
1896 "Using existing OrderBookWhitelist: {}",
1897 order_book_whitelist_id
1898 );
1899 Ok(Some(order_book_whitelist_id))
1900 }
1901 (None, false) => Ok(None),
1902 (None, true) => {
1903 tracing::info!("Deploying OrderBookWhitelist");
1904 let trade_account_whitelist = OrderBookDeploy::deploy_order_book_whitelist(
1905 &deployer_wallet,
1906 &Identity::Address(ViewOnlyAccount::address(&deployer_wallet)),
1907 &OrderBookDeployConfig {
1908 salt,
1909 ..Default::default()
1910 },
1911 )
1912 .await?;
1913 tracing::info!(
1914 "OrderBookWhitelist: {}",
1915 trade_account_whitelist.contract_id()
1916 );
1917 Ok(Some(trade_account_whitelist.contract_id()))
1918 }
1919 }
1920}
1921
1922async fn load_or_recover_trade_account_oracle<W>(
1926 deployer_wallet: &W,
1927 oracle_id: ContractId,
1928) -> anyhow::Result<(TradeAccountDeploy<W>, ContractId)>
1929where
1930 W: Account + ViewOnlyAccount + Clone + 'static,
1931{
1932 let oracle = TradingAccountOracle::new(oracle_id, deployer_wallet.clone());
1933 let impl_id = oracle
1934 .methods()
1935 .get_trade_account_impl()
1936 .simulate(Execution::state_read_only())
1937 .await?
1938 .value;
1939
1940 let blob_id = match impl_id {
1941 Some(id) => id,
1942 None => {
1943 tracing::info!(
1944 "Trade account implementation not set on oracle {}, deploying...",
1945 oracle_id
1946 );
1947 let blob = TradeAccountDeploy::trade_account_blob(
1948 deployer_wallet,
1949 &Default::default(),
1950 )
1951 .await?;
1952 TradeAccountDeploy::deploy_trade_account_blob(
1953 deployer_wallet,
1954 &DeployConfig::Latest(Default::default()),
1955 )
1956 .await?;
1957 oracle
1958 .methods()
1959 .set_trade_account_impl(ContractId::from(blob.id))
1960 .call()
1961 .await?;
1962 ContractId::from(blob.id)
1963 }
1964 };
1965
1966 let deploy = TradeAccountDeploy {
1967 oracle,
1968 oracle_id,
1969 trade_account_blob_id: blob_id.into(),
1970 deployer_wallet: deployer_wallet.clone(),
1971 proxy: None,
1972 proxy_id: None,
1973 };
1974 Ok((deploy, blob_id))
1975}
1976
1977async fn deploy_trade_account_oracle<W>(
1978 deployer_wallet: W,
1979 should_upgrade_bytecode: bool,
1980 trade_account_oracle_id: Option<ContractId>,
1981 salt: Salt,
1982) -> anyhow::Result<(TradeAccountDeploy<W>, ContractId)>
1983where
1984 W: Account + ViewOnlyAccount + Clone + 'static,
1985{
1986 let (trade_account_oracle_deploy, mut trade_account_blob_id) =
1987 match trade_account_oracle_id {
1988 Some(oracle_id) => {
1989 load_or_recover_trade_account_oracle(&deployer_wallet, oracle_id).await?
1990 }
1991 None => {
1992 let deploy = TradeAccountDeploy::deploy(
1993 &deployer_wallet,
1994 &DeployConfig::Latest(TradeAccountDeployConfig {
1995 salt,
1996 ..Default::default()
1997 }),
1998 )
1999 .await?;
2000 let blob_id = deploy
2001 .oracle
2002 .methods()
2003 .get_trade_account_impl()
2004 .simulate(Execution::state_read_only())
2005 .await?
2006 .value
2007 .context("Trade account impl should exist after fresh deploy")?;
2008 (deploy, blob_id)
2009 }
2010 };
2011 tracing::info!(
2012 "TradeAccountOracle: {}",
2013 trade_account_oracle_deploy.oracle_id
2014 );
2015
2016 if should_upgrade_bytecode {
2017 let trade_account_blob =
2018 TradeAccountDeploy::trade_account_blob(&deployer_wallet, &Default::default())
2019 .await?;
2020 if ContractId::from(trade_account_blob.id) != trade_account_blob_id {
2021 tracing::info!(
2022 "Update TradeAccountImpl on Oracle from {:?} to new blob {:?}",
2023 trade_account_blob_id,
2024 ContractId::from(trade_account_blob.id)
2025 );
2026 TradeAccountDeploy::deploy_trade_account_blob(
2027 &deployer_wallet,
2028 &DeployConfig::Latest(Default::default()),
2029 )
2030 .await?;
2031 trade_account_oracle_deploy
2032 .oracle
2033 .methods()
2034 .set_trade_account_impl(ContractId::from(trade_account_blob.id))
2035 .call()
2036 .await?;
2037 trade_account_blob_id = ContractId::from(trade_account_blob.id);
2038 }
2039 }
2040
2041 Ok((trade_account_oracle_deploy, trade_account_blob_id))
2042}
2043
2044async fn deploy_trial_trade_account_oracle<W>(
2052 deployer_wallet: W,
2053 should_upgrade_bytecode: bool,
2054 trial_trade_account_oracle_id: Option<ContractId>,
2055 trial_cosigner: Option<fuels::types::Address>,
2056 salt: Salt,
2057) -> anyhow::Result<ContractId>
2058where
2059 W: Account + ViewOnlyAccount + Clone + 'static,
2060{
2061 let mut trial_deploy_config = TrialTradeAccountDeployConfig {
2062 salt,
2063 ..Default::default()
2064 };
2065 if let Some(cosigner) = trial_cosigner {
2066 trial_deploy_config = trial_deploy_config.with_cosigner(cosigner);
2067 }
2068 let deploy_config = TrialDeployConfig::Latest(trial_deploy_config);
2069
2070 let oracle_id = match trial_trade_account_oracle_id {
2071 None => {
2072 let trial_deploy =
2073 TrialTradeAccountDeploy::deploy(&deployer_wallet, &deploy_config).await?;
2074 tracing::info!(
2075 "TrialTradeAccountOracle: {} (implementation {:?}, cosigner {:?})",
2076 trial_deploy.oracle_id,
2077 trial_deploy.trial_trade_account_blob_id,
2078 trial_cosigner,
2079 );
2080 trial_deploy.oracle_id
2081 }
2082 Some(oracle_id) => {
2083 let current_trial_impl =
2084 TrialTradingAccountOracle::new(oracle_id, deployer_wallet.clone())
2085 .methods()
2086 .get_trial_account_impl()
2087 .simulate(Execution::state_read_only())
2088 .await?
2089 .value;
2090 if current_trial_impl.is_none()
2091 || should_upgrade_bytecode
2092 || trial_cosigner.is_some()
2093 {
2094 let trial_deploy = TrialTradeAccountDeploy::deploy_to_oracle(
2095 &deployer_wallet,
2096 oracle_id,
2097 &deploy_config,
2098 )
2099 .await?;
2100 tracing::info!(
2101 "Trial implementation {:?} deployed to oracle {} (cosigner {:?})",
2102 trial_deploy.trial_trade_account_blob_id,
2103 oracle_id,
2104 trial_cosigner,
2105 );
2106 }
2107 oracle_id
2108 }
2109 };
2110
2111 Ok(oracle_id)
2112}
2113
2114async fn deploy_trade_account_registry<W>(
2115 deployer_wallet: W,
2116 should_upgrade_bytecode: bool,
2117 trade_account_deploy: TradeAccountDeploy<W>,
2118 trial_trade_account_oracle_id: ContractId,
2119 trade_account_registry_id: Option<ContractId>,
2120 salt: Salt,
2121) -> anyhow::Result<(TradeAccountRegistryManager<W>, ContractId)>
2122where
2123 W: Account + ViewOnlyAccount + Clone + 'static,
2124{
2125 let trade_account_oracle_id = trade_account_deploy.oracle_id;
2126 let trade_account_registry = match trade_account_registry_id {
2127 Some(trade_account_registry_contract_id) => TradeAccountRegistryManager::new(
2128 deployer_wallet.clone(),
2129 trade_account_registry_contract_id,
2130 ),
2131 None => {
2132 let trade_account_registry_deploy_config = TradeAccountRegistryDeployConfig {
2133 salt,
2134 ..Default::default()
2135 };
2136 TradeAccountRegistryManager::deploy(
2137 &deployer_wallet,
2138 trade_account_oracle_id,
2139 trial_trade_account_oracle_id,
2140 &trade_account_registry_deploy_config,
2141 )
2142 .await?
2143 }
2144 };
2145 tracing::info!(
2146 "TradeAccountRegistry: {}",
2147 trade_account_registry.contract_id
2148 );
2149 let mut trade_account_registry_blob_id = match trade_account_registry
2150 .registry_proxy
2151 .methods()
2152 .proxy_target()
2153 .simulate(Execution::state_read_only())
2154 .await?
2155 .value
2156 {
2157 Some(blob_id) => blob_id,
2158 None => {
2159 tracing::info!("TradeAccountRegistry proxy target not set, initializing...");
2160 trade_account_registry
2164 .registry_proxy
2165 .methods()
2166 .initialize_proxy()
2167 .call()
2168 .await?;
2169 trade_account_registry
2170 .registry
2171 .methods()
2172 .initialize()
2173 .call()
2174 .await?;
2175 trade_account_registry
2176 .registry_proxy
2177 .methods()
2178 .proxy_target()
2179 .simulate(Execution::state_read_only())
2180 .await?
2181 .value
2182 .context("TradeAccountRegistry proxy target should be set after initialization")?
2183 }
2184 };
2185
2186 if should_upgrade_bytecode {
2187 let trade_account_registry_deploy_config = TradeAccountRegistryDeployConfig {
2191 registry_config: trade_account_registry
2192 .live_prop_config(TradeAccountRegistryConfigurables::default())
2193 .await?,
2194 ..Default::default()
2195 };
2196 let trade_account_proxy_blob = TradeAccountRegistryManager::register_proxy_blob(
2197 &deployer_wallet,
2198 &trade_account_registry_deploy_config,
2199 )
2200 .await?;
2201 let trial_trade_account_proxy_blob =
2202 TradeAccountRegistryManager::register_trial_proxy_blob(
2203 &deployer_wallet,
2204 &trade_account_registry_deploy_config,
2205 )
2206 .await?;
2207
2208 let trade_account_register_blob = TradeAccountRegistryManager::register_blob(
2209 &deployer_wallet,
2210 trade_account_oracle_id,
2211 trial_trade_account_oracle_id,
2212 trade_account_proxy_blob.id,
2213 trial_trade_account_proxy_blob.id,
2214 &trade_account_registry_deploy_config,
2215 )
2216 .await?;
2217
2218 if trade_account_registry_blob_id
2219 != ContractId::from(trade_account_register_blob.id)
2220 {
2221 tracing::info!(
2222 "Upgrade TradeAccountRegistry blob from {:?} to {:?}",
2223 trade_account_registry.contract_id,
2224 ContractId::from(trade_account_register_blob.id)
2225 );
2226 trade_account_registry
2230 .upgrade(
2231 trade_account_oracle_id,
2232 trial_trade_account_oracle_id,
2233 &trade_account_registry_deploy_config,
2234 )
2235 .await?;
2236 trade_account_registry_blob_id = trade_account_register_blob.id.into();
2237 }
2238 }
2239 Ok((trade_account_registry, trade_account_registry_blob_id))
2240}
2241
2242async fn deploy_order_book_registry<W>(
2243 deployer_wallet: W,
2244 should_upgrade_bytecode: bool,
2245 order_book_registry_id: Option<ContractId>,
2246 salt: Salt,
2247) -> anyhow::Result<(OrderBookRegistryManager<W>, ContractId)>
2248where
2249 W: Account + ViewOnlyAccount + Clone + 'static,
2250{
2251 let order_book_registry = match order_book_registry_id {
2252 Some(registry_contract_id) => {
2253 OrderBookRegistryManager::new(deployer_wallet.clone(), registry_contract_id)
2254 }
2255 None => {
2256 OrderBookRegistryManager::deploy(
2257 &deployer_wallet,
2258 &OrderBookRegistryDeployConfig {
2259 salt,
2260 ..Default::default()
2261 },
2262 )
2263 .await?
2264 }
2265 };
2266 tracing::info!("OrderBookRegistry: {}", order_book_registry.contract_id);
2267 let mut order_book_registry_blob_id = match order_book_registry
2268 .registry_proxy
2269 .methods()
2270 .proxy_target()
2271 .simulate(Execution::state_read_only())
2272 .await?
2273 .value
2274 {
2275 Some(blob_id) => blob_id,
2276 None => {
2277 tracing::info!("OrderBookRegistry proxy target not set, initializing...");
2278 order_book_registry
2282 .registry_proxy
2283 .methods()
2284 .initialize_proxy()
2285 .call()
2286 .await?;
2287 order_book_registry
2288 .registry
2289 .methods()
2290 .initialize()
2291 .call()
2292 .await?;
2293 order_book_registry
2294 .registry_proxy
2295 .methods()
2296 .proxy_target()
2297 .simulate(Execution::state_read_only())
2298 .await?
2299 .value
2300 .context(
2301 "OrderBookRegistry proxy target should be set after initialization",
2302 )?
2303 }
2304 };
2305
2306 if should_upgrade_bytecode {
2307 let order_book_register_deploy_config = OrderBookRegistryDeployConfig::default();
2308 let order_book_register_blob = OrderBookRegistryManager::register_blob(
2309 &deployer_wallet,
2310 &order_book_register_deploy_config,
2311 )
2312 .await?;
2313 if order_book_registry_blob_id != order_book_register_blob.id.into() {
2314 tracing::info!(
2315 "Upgrade OrderBookRegistry blob from {:?} to {:?}",
2316 order_book_registry.contract_id,
2317 ContractId::from(order_book_register_blob.id)
2318 );
2319 order_book_registry
2320 .upgrade(&order_book_register_deploy_config)
2321 .await?;
2322 order_book_registry_blob_id = order_book_register_blob.id.into();
2323 }
2324 }
2325
2326 Ok((order_book_registry, order_book_registry_blob_id))
2327}
2328
2329async fn deploy_order_books<W>(
2330 deployer_wallet: W,
2331 should_upgrade_bytecode: bool,
2332 order_book_blacklist_id: Option<ContractId>,
2333 order_book_whitelist_id: Option<ContractId>,
2334 order_book_registry: OrderBookRegistryManager<W>,
2335 order_book_configs: &mut [OrderBookConfig],
2336 ownership_options: OwnershipTransferOptions,
2337) -> anyhow::Result<Vec<OrderBookConfig>>
2338where
2339 W: Account + ViewOnlyAccount + Clone + 'static,
2340{
2341 audit_order_book_precision(order_book_configs)?;
2346
2347 let mut pairs: Vec<OrderBookConfig> = Vec::with_capacity(order_book_configs.len());
2348
2349 for order_book_config in order_book_configs.iter_mut() {
2350 let pair = deploy_single_order_book(
2351 &deployer_wallet,
2352 should_upgrade_bytecode,
2353 order_book_blacklist_id,
2354 order_book_whitelist_id,
2355 &order_book_registry,
2356 order_book_config,
2357 &ownership_options,
2358 )
2359 .await?;
2360 pairs.push(pair);
2361 }
2362
2363 Ok(pairs)
2364}
2365
2366async fn deploy_single_order_book<W>(
2367 deployer_wallet: &W,
2368 should_upgrade_bytecode: bool,
2369 order_book_blacklist_id: Option<ContractId>,
2370 order_book_whitelist_id: Option<ContractId>,
2371 order_book_registry: &OrderBookRegistryManager<W>,
2372 order_book_config: &mut OrderBookConfig,
2373 ownership_options: &OwnershipTransferOptions,
2374) -> anyhow::Result<OrderBookConfig>
2375where
2376 W: Account + ViewOnlyAccount + Clone + 'static,
2377{
2378 let market_symbol = format!(
2379 "{}/{}",
2380 order_book_config.base.symbol, order_book_config.quote.symbol
2381 );
2382 let market_id = MarketIdAssets {
2383 base_asset: order_book_config.base.asset,
2384 quote_asset: order_book_config.quote.asset,
2385 };
2386 let order_book_configurables = build_order_book_configurables(
2387 order_book_config,
2388 order_book_blacklist_id,
2389 order_book_whitelist_id,
2390 deployer_wallet,
2391 )?;
2392
2393 let order_book = load_or_deploy_order_book(
2394 deployer_wallet,
2395 order_book_registry,
2396 &market_id,
2397 &market_symbol,
2398 &order_book_configurables,
2399 order_book_config,
2400 )
2401 .await?;
2402
2403 tracing::info!(
2404 "[{}] OrderBook: {}",
2405 market_symbol,
2406 order_book.contract.contract_id()
2407 );
2408
2409 let order_book_blob_id = maybe_upgrade_order_book(
2410 deployer_wallet,
2411 should_upgrade_bytecode,
2412 &order_book,
2413 order_book_config,
2414 order_book_configurables,
2415 &market_symbol,
2416 )
2417 .await?;
2418
2419 set_order_book_maintainer(&order_book, ownership_options, &market_symbol).await?;
2423
2424 transfer_order_book_ownership(&order_book, ownership_options, &market_symbol).await?;
2425
2426 order_book_config.contract_id = Some(order_book.contract.contract_id());
2427 order_book_config.blob_id = order_book_blob_id.into();
2428
2429 Ok(order_book_config.clone())
2430}
2431
2432pub fn audit_order_book_precision(configs: &[OrderBookConfig]) -> anyhow::Result<()> {
2440 for config in configs {
2441 order_book_precision_exponents(config)?;
2442 }
2443 Ok(())
2444}
2445
2446fn order_book_precision_exponents(config: &OrderBookConfig) -> anyhow::Result<(u8, u8)> {
2466 let price_precision = config
2467 .quote
2468 .decimals
2469 .checked_sub(config.quote.max_precision)
2470 .ok_or_else(|| {
2471 anyhow::anyhow!(
2472 "quote max_precision ({}) exceeds decimals ({})",
2473 config.quote.max_precision,
2474 config.quote.decimals
2475 )
2476 })?;
2477 let quantity_precision = config
2478 .base
2479 .decimals
2480 .checked_sub(config.base.max_precision)
2481 .ok_or_else(|| {
2482 anyhow::anyhow!(
2483 "base max_precision ({}) exceeds decimals ({})",
2484 config.base.max_precision,
2485 config.base.decimals
2486 )
2487 })?;
2488
2489 let spent = config.base.max_precision as u16 + config.quote.max_precision as u16;
2492 let budget = config.quote.decimals as u16;
2493
2494 if config.allow_fractional_price {
2495 if spent > budget {
2496 tracing::warn!(
2497 "{}/{}: base max_precision ({}) + quote max_precision ({}) = {spent} \
2498 exceeds the quote's {budget} decimals; ALLOW_FRACTIONAL_PRICE is set, \
2499 so notionals will silently truncate rather than revert",
2500 config.base.symbol,
2501 config.quote.symbol,
2502 config.base.max_precision,
2503 config.quote.max_precision,
2504 );
2505 }
2506 } else {
2507 anyhow::ensure!(
2508 spent <= budget,
2509 "{}/{}: base max_precision ({}) + quote max_precision ({}) = {spent} \
2510 exceeds the quote's {budget} decimals, so every order would revert \
2511 with FractionalPrice. Lower one of the two until they sum to {budget} \
2512 or less.",
2513 config.base.symbol,
2514 config.quote.symbol,
2515 config.base.max_precision,
2516 config.quote.max_precision,
2517 );
2518 }
2519
2520 Ok((price_precision, quantity_precision))
2521}
2522
2523fn build_order_book_configurables<W: ViewOnlyAccount>(
2524 config: &OrderBookConfig,
2525 order_book_blacklist_id: Option<ContractId>,
2526 order_book_whitelist_id: Option<ContractId>,
2527 deployer_wallet: &W,
2528) -> anyhow::Result<OrderBookConfigurables> {
2529 let (price_precision, quantity_precision) = order_book_precision_exponents(config)?;
2530
2531 Ok(OrderBookConfigurables::default()
2532 .with_MIN_ORDER(config.min_order)?
2533 .with_ALLOW_FRACTIONAL_PRICE(config.allow_fractional_price)?
2534 .with_TAKER_FEE(config.taker_fee.into())?
2535 .with_MAKER_FEE(config.maker_fee.into())?
2536 .with_DUST(config.dust)?
2537 .with_PRICE_WINDOW(config.price_window as u64)?
2538 .with_BASE_DECIMALS(10u64.pow(config.base.decimals as u32))?
2539 .with_QUOTE_DECIMALS(10u64.pow(config.quote.decimals as u32))?
2540 .with_BASE_SYMBOL(SizedAsciiString::new_with_right_whitespace_padding(
2541 config.base.symbol.clone(),
2542 )?)?
2543 .with_QUOTE_SYMBOL(SizedAsciiString::new_with_right_whitespace_padding(
2544 config.quote.symbol.clone(),
2545 )?)?
2546 .with_PRICE_PRECISION(10u64.pow(price_precision as u32))?
2547 .with_QUANTITY_PRECISION(10u64.pow(quantity_precision as u32))?
2548 .with_INITIAL_OWNER(o2_tools::order_book_deploy::State::Initialized(
2549 Identity::Address(ViewOnlyAccount::address(deployer_wallet)),
2550 ))?
2551 .with_WHITE_LIST_CONTRACT(order_book_whitelist_id)?
2552 .with_BLACK_LIST_CONTRACT(order_book_blacklist_id)?)
2553}
2554
2555async fn load_or_deploy_order_book<W>(
2556 deployer_wallet: &W,
2557 order_book_registry: &OrderBookRegistryManager<W>,
2558 market_id: &MarketIdAssets,
2559 market_symbol: &str,
2560 order_book_configurables: &OrderBookConfigurables,
2561 order_book_config: &OrderBookConfig,
2562) -> anyhow::Result<OrderBookManager<W>>
2563where
2564 W: Account + ViewOnlyAccount + Clone + 'static,
2565{
2566 let register_contract_id = order_book_registry
2567 .registry
2568 .methods()
2569 .get_order_book(to_registry_market_id(market_id))
2570 .simulate(Execution::state_read_only())
2571 .await?
2572 .value;
2573
2574 match register_contract_id {
2575 Some(contract_id) => {
2576 let order_book_deploy = OrderBookDeploy::new(
2577 deployer_wallet.clone(),
2578 contract_id,
2579 market_id.base_asset,
2580 market_id.quote_asset,
2581 );
2582 let proxy_target = order_book_deploy
2585 .order_book_proxy
2586 .methods()
2587 .proxy_target()
2588 .simulate(Execution::state_read_only())
2589 .await?
2590 .value;
2591 if proxy_target.is_none() {
2592 tracing::info!(
2593 "[{}] Proxy target not set, initializing...",
2594 market_symbol
2595 );
2596 order_book_deploy.initialize().await?;
2597 }
2598 Ok(OrderBookManager::new(
2599 deployer_wallet,
2600 10u64.pow(order_book_config.base.decimals as u32),
2601 10u64.pow(order_book_config.quote.decimals as u32),
2602 &order_book_deploy,
2603 ))
2604 }
2605 None => {
2606 let (order_book_deployment, initialization_required) =
2607 OrderBookDeploy::deploy_without_initialization(
2608 deployer_wallet,
2609 market_id.base_asset,
2610 market_id.quote_asset,
2611 &OrderBookDeployConfig {
2612 order_book_configurables: order_book_configurables.clone(),
2613 salt: Salt::from(*order_book_registry.contract_id),
2614 ..Default::default()
2615 },
2616 )
2617 .await?;
2618
2619 order_book_registry
2620 .register_order_book(
2621 to_registry_market_id(market_id),
2622 order_book_deployment.contract_id,
2623 )
2624 .await?;
2625
2626 if initialization_required {
2627 order_book_deployment.initialize().await?;
2628 }
2629 Ok(OrderBookManager::new(
2630 deployer_wallet,
2631 10u64.pow(order_book_config.base.decimals as u32),
2632 10u64.pow(order_book_config.quote.decimals as u32),
2633 &order_book_deployment,
2634 ))
2635 }
2636 }
2637}
2638
2639async fn maybe_upgrade_order_book<W>(
2640 deployer_wallet: &W,
2641 should_upgrade_bytecode: bool,
2642 order_book: &OrderBookManager<W>,
2643 order_book_config: &OrderBookConfig,
2644 order_book_configurables: OrderBookConfigurables,
2645 market_symbol: &str,
2646) -> anyhow::Result<ContractId>
2647where
2648 W: Account + ViewOnlyAccount + Clone + 'static,
2649{
2650 let mut order_book_blob_id = order_book
2651 .proxy
2652 .methods()
2653 .proxy_target()
2654 .simulate(Execution::state_read_only())
2655 .await?
2656 .value
2657 .context("Order book proxy target should be set after initialization")?;
2658
2659 if should_upgrade_bytecode {
2660 let order_book_deploy_config = OrderBookDeployConfig {
2661 order_book_configurables,
2662 ..Default::default()
2663 };
2664 let order_book_deploy = OrderBookDeploy::new(
2665 deployer_wallet.clone(),
2666 order_book.contract.contract_id(),
2667 order_book_config.base.asset,
2668 order_book_config.quote.asset,
2669 );
2670 let order_book_manager = OrderBookManager::new(
2671 deployer_wallet,
2672 10u64.pow(order_book_config.base.decimals as u32),
2673 10u64.pow(order_book_config.quote.decimals as u32),
2674 &order_book_deploy,
2675 );
2676 let order_book_blob = OrderBookDeploy::order_book_blob(
2677 deployer_wallet,
2678 order_book_config.base.asset,
2679 order_book_config.quote.asset,
2680 &order_book_deploy_config,
2681 )
2682 .await?;
2683
2684 if order_book_blob_id != order_book_blob.id.into() {
2685 tracing::info!(
2686 "[{}] Upgrade OrderBook blob from {:?} to {:?}",
2687 market_symbol,
2688 order_book_blob_id,
2689 ContractId::from(order_book_blob.id)
2690 );
2691 order_book_manager
2692 .upgrade(&order_book_deploy_config)
2693 .await?;
2694 tracing::info!(
2695 "[{}] Emit new configuration event for {}",
2696 market_symbol,
2697 order_book.contract.contract_id()
2698 );
2699 order_book_manager.emit_config().await?;
2700 order_book_blob_id = order_book_blob.id.into();
2701 }
2702 }
2703
2704 Ok(order_book_blob_id)
2705}
2706
2707async fn set_order_book_maintainer<W>(
2708 order_book: &OrderBookManager<W>,
2709 ownership_options: &OwnershipTransferOptions,
2710 market_symbol: &str,
2711) -> anyhow::Result<()>
2712where
2713 W: Account + ViewOnlyAccount + Clone + 'static,
2714{
2715 for account in &ownership_options.revoke_orderbook_maintainers {
2719 tracing::info!(
2720 "[{}] Revoking ORDERBOOK_MAINTAINER_ROLE from {}",
2721 market_symbol,
2722 account
2723 );
2724 order_book
2725 .contract
2726 .methods()
2727 .owner_revoke_role(ORDERBOOK_MAINTAINER_ROLE, Identity::Address(*account))
2728 .call()
2729 .await?;
2730 }
2731
2732 for account in &ownership_options.new_orderbook_maintainers {
2733 tracing::info!(
2734 "[{}] Granting ORDERBOOK_MAINTAINER_ROLE to {}",
2735 market_symbol,
2736 account
2737 );
2738 order_book
2739 .contract
2740 .methods()
2741 .owner_grant_role(ORDERBOOK_MAINTAINER_ROLE, Identity::Address(*account))
2742 .call()
2743 .await?;
2744 }
2745
2746 Ok(())
2747}
2748
2749async fn transfer_order_book_ownership<W>(
2750 order_book: &OrderBookManager<W>,
2751 ownership_options: &OwnershipTransferOptions,
2752 market_symbol: &str,
2753) -> anyhow::Result<()>
2754where
2755 W: Account + ViewOnlyAccount + Clone + 'static,
2756{
2757 if let Some(new_owner) = ownership_options.new_proxy_owner {
2758 let new_identity = Identity::Address(new_owner);
2759 tracing::info!(
2760 "[{}] Transferring OrderBook proxy ownership to {}",
2761 market_symbol,
2762 new_owner
2763 );
2764 order_book
2765 .proxy
2766 .methods()
2767 .set_owner(new_identity)
2768 .call()
2769 .await?;
2770 }
2771
2772 if let Some(new_owner) = ownership_options.new_contract_owner {
2773 let new_identity = Identity::Address(new_owner);
2774 tracing::info!(
2775 "[{}] Transferring OrderBook contract ownership to {}",
2776 market_symbol,
2777 new_owner
2778 );
2779 order_book
2780 .contract
2781 .methods()
2782 .transfer_ownership(new_identity)
2783 .call()
2784 .await?;
2785 }
2786
2787 Ok(())
2788}
2789
2790#[cfg(test)]
2791mod tests {
2792 use super::*;
2793
2794 #[test]
2795 fn load_config_empty_path_returns_default() {
2796 let result: MarketsConfigPartial = load_config_from_file("").unwrap();
2797 assert!(result.pairs.is_empty());
2798 }
2799
2800 #[test]
2801 fn load_config_missing_file_errors() {
2802 let result: Result<MarketsConfigPartial, _> =
2803 load_config_from_file("nonexistent_file_12345.json");
2804 assert!(result.is_err());
2805 }
2806
2807 #[test]
2808 fn checked_sub_catches_overflow() {
2809 let decimals: u32 = 6;
2811 let max_precision: u32 = 8; let result = decimals.checked_sub(max_precision);
2814 assert!(
2815 result.is_none(),
2816 "should return None when max_precision > decimals"
2817 );
2818
2819 let result = 9u32.checked_sub(6);
2821 assert_eq!(result, Some(3));
2822 }
2823
2824 #[test]
2825 fn markets_config_partial_default_has_empty_pairs() {
2826 let config = MarketsConfigPartial::default();
2827 assert!(config.pairs.is_empty());
2828 }
2829
2830 const LIVE_LEGACY_TIER: &str = r#"{
2833 "tier_id": 11,
2834 "line": "2500000000000",
2835 "leverage": 40,
2836 "duration": 0,
2837 "maintenance_bps": 150,
2838 "open_buffer_bps": 151,
2839 "liq_price_factor": 9900,
2840 "prolong_fee": ["10", "18", "60", "200"],
2841 "max_credit_line_bps": 20000,
2842 "max_price_age": 300,
2843 "open_fee": "0",
2844 "profit_share_bps": 0,
2845 "price_band_bps": 85,
2846 "markets": ["fwBTC/fUSDC", "fETH/fUSDC", "fXAUT/fUSDC"]
2847 }"#;
2848
2849 fn turbo_tier(turbo: &str) -> String {
2850 format!(
2851 r#"{{
2852 "tier_id": 33,
2853 "line": "10000000000000",
2854 "leverage": 40,
2855 "duration": 0,
2856 "maintenance_bps": 100,
2857 "open_buffer_bps": 101,
2858 "liq_price_factor": 9900,
2859 "prolong_fee": ["0", "0", "0", "0"],
2860 "max_credit_line_bps": 20000,
2861 "max_price_age": 300,
2862 "open_fee": "0",
2863 "profit_share_bps": 0,
2864 "price_band_bps": 100,
2865 "markets": ["fwBTC/fUSDC"],
2866 "turbo": {turbo}
2867 }}"#
2868 )
2869 }
2870
2871 const SHIPPED_TERMS: &str = r#"{
2872 "forfeit_collateral": true,
2873 "max_loss_bps": [150, 200, 250, 300],
2874 "rollover_profit_bps": 900,
2875 "max_rollovers": 3
2876 }"#;
2877
2878 #[test]
2882 fn a_live_legacy_tier_still_parses_and_defaults_to_selling() {
2883 let tier: MarginTierConfig = serde_json::from_str(LIVE_LEGACY_TIER).unwrap();
2884 assert_eq!(tier.tier_id, 11);
2885 assert!(tier.turbo.is_none(), "no terms means the legacy cohort");
2886 assert!(tier.enabled, "a tier with no opinion sells");
2887 }
2888
2889 #[test]
2893 fn a_misspelled_tier_key_is_refused_rather_than_ignored() {
2894 let typo = LIVE_LEGACY_TIER.replace("\"leverage\"", "\"leverag\"");
2895 let err = serde_json::from_str::<MarginTierConfig>(&typo).unwrap_err();
2896 assert!(err.to_string().contains("leverag"), "{err}");
2897
2898 let typo = turbo_tier(&SHIPPED_TERMS.replace("max_loss_bps", "max_los_bps"));
2899 let err = serde_json::from_str::<MarginTierConfig>(&typo).unwrap_err();
2900 assert!(err.to_string().contains("max_los_bps"), "{err}");
2901 }
2902
2903 #[test]
2904 fn a_prepaid_tier_carries_its_terms_through_to_the_pools_shape() {
2905 let tier: MarginTierConfig =
2906 serde_json::from_str(&turbo_tier(SHIPPED_TERMS)).unwrap();
2907 let turbo = tier.turbo.as_ref().expect("the tier declares terms");
2908 let terms = o2_tools::prop::TurboTerms::from(turbo);
2909 assert!(terms.forfeit_collateral);
2910 assert_eq!(terms.max_loss_bps, [150, 200, 250, 300]);
2911 assert_eq!(terms.rollover_profit_bps, 900);
2912 assert_eq!(terms.max_rollovers, 3);
2913
2914 let params = tier_params(&tier, 9).unwrap();
2915 validate_turbo_tier_config(&tier, ¶ms, Some(&terms))
2916 .expect("the shipped shape is publishable");
2917 }
2918
2919 #[test]
2923 fn a_config_the_pool_would_refuse_is_refused_before_it_costs_a_transaction() {
2924 let check = |mutate: &dyn Fn(&mut MarginTierConfig), expect: &str| {
2925 let mut tier: MarginTierConfig =
2926 serde_json::from_str(&turbo_tier(SHIPPED_TERMS)).unwrap();
2927 mutate(&mut tier);
2928 let params = tier_params(&tier, 9).unwrap();
2929 let terms = tier.turbo.as_ref().map(o2_tools::prop::TurboTerms::from);
2930 let err = validate_turbo_tier_config(&tier, ¶ms, terms.as_ref())
2931 .expect_err("must be refused");
2932 assert!(
2933 err.to_string().contains(expect),
2934 "expected {expect:?} in: {err}"
2935 );
2936 };
2937
2938 check(&|t| t.duration = 604_800, "duration");
2939 check(&|t| t.prolong_fee[2] = "60".to_owned(), "prolong_fee");
2940 check(&|t| t.profit_share_bps = 500, "profit_share_bps");
2941 check(
2944 &|t| t.turbo.as_mut().unwrap().max_loss_bps = [0, 200, 250, 300],
2945 "step 0 is 0",
2946 );
2947 check(
2951 &|t| t.turbo.as_mut().unwrap().max_loss_bps = [150, 0, 250, 300],
2952 "step 1 is 0",
2953 );
2954 check(
2955 &|t| t.turbo.as_mut().unwrap().max_loss_bps = [150, 200, 250, 0],
2956 "step 3 is 0",
2957 );
2958 check(
2960 &|t| t.turbo.as_mut().unwrap().max_loss_bps = [150, 100, 250, 300],
2961 "NARROWER",
2962 );
2963 {
2965 let mut tier: MarginTierConfig =
2966 serde_json::from_str(&turbo_tier(SHIPPED_TERMS)).unwrap();
2967 let turbo = tier.turbo.as_mut().unwrap();
2968 turbo.max_rollovers = 1;
2969 turbo.max_loss_bps = [150, 200, 0, 0];
2970 let params = tier_params(&tier, 9).unwrap();
2971 let terms = tier.turbo.as_ref().map(o2_tools::prop::TurboTerms::from);
2972 validate_turbo_tier_config(&tier, ¶ms, terms.as_ref())
2973 .expect("only reachable steps are policed");
2974 }
2975 check(
2977 &|t| t.turbo.as_mut().unwrap().rollover_profit_bps = 0,
2978 "rollover_profit_bps` must not be 0",
2979 );
2980 check(
2982 &|t| {
2983 let turbo = t.turbo.as_mut().unwrap();
2984 turbo.max_rollovers = 0;
2985 },
2986 "max_rollovers",
2987 );
2988 check(
2989 &|t| t.turbo.as_mut().unwrap().max_loss_bps = [150, 200, 250, 10_001],
2990 "<= 10000",
2991 );
2992 }
2993
2994 #[test]
2997 fn a_legacy_tier_is_not_held_to_the_prepaid_invariants() {
2998 let tier: MarginTierConfig = serde_json::from_str(LIVE_LEGACY_TIER).unwrap();
2999 let params = tier_params(&tier, 9).unwrap();
3000 validate_turbo_tier_config(&tier, ¶ms, None).unwrap();
3001 }
3002}
3003
3004#[cfg(test)]
3005mod precision_budget_tests {
3006 use super::*;
3007
3008 fn pair(base_max: u8, quote_max: u8) -> OrderBookConfig {
3011 let asset = |symbol: &str, max_precision: u8| AssetConfig {
3012 symbol: symbol.to_string(),
3013 asset: Default::default(),
3014 decimals: 9,
3015 min_precision: 0,
3016 max_precision,
3017 };
3018 OrderBookConfig {
3019 contract_id: None,
3020 blob_id: None,
3021 market_id: Default::default(),
3022 taker_fee: 100,
3023 maker_fee: 0,
3024 min_order: 1_000_000_000,
3025 dust: 1_000,
3026 price_window: 0,
3027 allow_fractional_price: false,
3028 base: asset("TKN", base_max),
3029 quote: asset("USDC", quote_max),
3030 }
3031 }
3032
3033 #[test]
3034 fn a_pair_that_spends_its_whole_budget_yields_the_contract_exponents() {
3035 let (price, quantity) = order_book_precision_exponents(&pair(2, 7)).unwrap();
3037 assert_eq!(price, 2, "PRICE_PRECISION = 10^(9-7)");
3038 assert_eq!(quantity, 7, "QUANTITY_PRECISION = 10^(9-2)");
3039 }
3040
3041 #[test]
3042 fn one_digit_over_budget_is_refused() {
3043 let err = order_book_precision_exponents(&pair(4, 6))
3045 .unwrap_err()
3046 .to_string();
3047 assert!(err.contains("FractionalPrice"), "{err}");
3048 assert!(err.contains("TKN/USDC"), "{err}");
3049 assert!(err.contains("= 10"), "{err}");
3050 }
3051
3052 #[test]
3053 fn fractional_prices_downgrade_the_breach_to_a_warning() {
3054 let mut config = pair(4, 6);
3057 config.allow_fractional_price = true;
3058 assert!(order_book_precision_exponents(&config).is_ok());
3059 }
3060
3061 #[test]
3062 fn max_precision_beyond_the_asset_decimals_is_still_refused() {
3063 let err = order_book_precision_exponents(&pair(0, 10))
3064 .unwrap_err()
3065 .to_string();
3066 assert!(err.contains("exceeds decimals"), "{err}");
3067 }
3068
3069 #[test]
3070 fn the_budget_tracks_the_quote_decimals_not_a_hardcoded_nine() {
3071 let mut config = pair(4, 2);
3072 config.quote.decimals = 6;
3073 assert!(
3074 order_book_precision_exponents(&config).is_ok(),
3075 "4 + 2 fits a 6-decimal quote"
3076 );
3077
3078 config.quote.max_precision = 3;
3079 assert!(
3080 order_book_precision_exponents(&config).is_err(),
3081 "4 + 3 does not"
3082 );
3083 }
3084}
3085
3086#[cfg(test)]
3087mod fee_amount_tests {
3088 use super::collateral_amount;
3089
3090 fn at9(value: &str) -> anyhow::Result<u64> {
3091 collateral_amount(value, 9, "tier 1 open_fee")
3092 }
3093
3094 #[test]
3095 fn whole_and_fractional_units_scale_by_the_asset_decimals() {
3096 assert_eq!(at9("7.5").unwrap(), 7_500_000_000);
3097 assert_eq!(at9("7").unwrap(), 7_000_000_000);
3098 assert_eq!(at9("0").unwrap(), 0);
3099 assert_eq!(at9("0.000000001").unwrap(), 1);
3100 assert_eq!(at9("13.5").unwrap(), 13_500_000_000);
3101 assert_eq!(at9("7.1").unwrap(), 7_100_000_000);
3103 }
3104
3105 #[test]
3106 fn a_figure_the_asset_cannot_represent_is_refused_not_rounded() {
3107 let err = at9("7.0000000001").unwrap_err().to_string();
3108 assert!(err.contains("10 decimal places"), "{err}");
3109 assert!(err.contains("tier 1 open_fee"), "{err}");
3110 }
3111
3112 #[test]
3113 fn junk_is_refused() {
3114 for bad in ["", " ", "-1", "1.2.3", "abc", "1e9", ".5", "1_000"] {
3115 assert!(at9(bad).is_err(), "`{bad}` should not parse");
3116 }
3117 }
3118
3119 #[test]
3120 fn an_amount_past_u64_is_refused_rather_than_wrapping() {
3121 assert!(at9("18446744074").is_err());
3122 assert_eq!(at9("18446744073.709551615").unwrap(), u64::MAX);
3123 }
3124
3125 #[test]
3126 fn other_decimal_scales_are_honoured() {
3127 assert_eq!(collateral_amount("7.5", 6, "f").unwrap(), 7_500_000);
3128 assert_eq!(collateral_amount("7.5", 1, "f").unwrap(), 75);
3129 assert!(collateral_amount("7.5", 0, "f").is_err());
3130 assert_eq!(collateral_amount("7", 0, "f").unwrap(), 7);
3131 }
3132}