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 pub expiry_cosigner: Option<fuels::types::Address>,
454 #[serde(default)]
466 pub discount_managers: Vec<String>,
467 #[serde(default)]
479 pub remove_discount_managers: Vec<String>,
480 pub collateral_asset: Option<fuels::types::AssetId>,
483 pub collateral_decimals: Option<u8>,
486 pub max_tier_books: Option<u64>,
488 pub base_repay_fee_ppm: Option<u64>,
496 #[serde(default)]
500 pub tiers: Vec<MarginTierConfig>,
501}
502
503#[derive(Debug, Clone, Default)]
504pub struct OwnershipTransferOptions {
505 pub new_proxy_owner: Option<fuels::types::Address>,
506 pub new_contract_owner: Option<fuels::types::Address>,
507 pub revoke_orderbook_maintainers: Vec<fuels::types::Address>,
511 pub new_orderbook_maintainers: Vec<fuels::types::Address>,
514}
515
516#[derive(Debug, Clone)]
518pub struct DeployParams {
519 pub deploy_config: MarketsConfigPartial,
520 pub output: Option<String>,
521 pub deploy_whitelist: bool,
522 pub deploy_blacklist: bool,
523 pub upgrade_bytecode: bool,
524 pub new_proxy_owner: Option<fuels::types::Address>,
525 pub new_contract_owner: Option<fuels::types::Address>,
526 pub trial_cosigner: Option<fuels::types::Address>,
532 pub trial_creator: Option<Identity>,
539 pub margin_tier_only: bool,
555 pub margin_cosigner: Option<fuels::types::Address>,
561 pub margin_liquidator: Option<Identity>,
566 pub margin_expiry_cosigner: Option<fuels::types::Address>,
579 pub revoke_orderbook_maintainers: Vec<fuels::types::Address>,
580 pub new_orderbook_maintainers: Vec<fuels::types::Address>,
581}
582
583pub fn load_config_from_file<T>(config_path: &str) -> anyhow::Result<T>
589where
590 T: Default + serde::de::DeserializeOwned,
591{
592 if config_path.is_empty() {
593 return Ok(T::default());
594 }
595 let current_dir = std::env::current_dir()?;
596 let path = current_dir.join(config_path);
597 tracing::info!("Loading config from {}", path.display());
598 let file = std::fs::File::open(&path)?;
599 let config: T = serde_json::from_reader(file)?;
600 Ok(config)
601}
602
603pub async fn deploy<W>(
612 wallet: W,
613 params: DeployParams,
614) -> anyhow::Result<MarketsConfigOutput>
615where
616 W: Account + ViewOnlyAccount + Clone + 'static,
617{
618 tracing::info!("Starting Fuel o2 Registries and Markets");
619 let mut markets_config_partial = params.deploy_config.clone();
620 let starting_height: BlockHeight = markets_config_partial.starting_height.into();
621 let trade_account_oracle_id = markets_config_partial.trade_account_oracle_id;
622 let trial_trade_account_oracle_id =
623 markets_config_partial.trial_trade_account_oracle_id;
624 let order_book_registry_id = markets_config_partial.order_book_registry_id;
625 let trade_account_registry_id = markets_config_partial.trade_account_registry_id;
626 let fast_bridge_asset_registry_proxy_id =
627 markets_config_partial.fast_bridge_asset_registry_proxy_id;
628
629 let mut salt = Salt::zeroed();
630 salt.deref_mut()[..4].copy_from_slice(&starting_height.deref().to_be_bytes());
631
632 let (trade_account_oracle_deploy, trade_account_blob_id) =
633 deploy_trade_account_oracle(
634 wallet.clone(),
635 params.upgrade_bytecode,
636 trade_account_oracle_id,
637 salt,
638 )
639 .await?;
640 let trial_trade_account_oracle_id = deploy_trial_trade_account_oracle(
644 wallet.clone(),
645 params.upgrade_bytecode,
646 trial_trade_account_oracle_id,
647 params.trial_cosigner,
648 salt,
649 )
650 .await?;
651 let (trade_account_registry, trade_account_registry_blob_id) =
652 deploy_trade_account_registry(
653 wallet.clone(),
654 params.upgrade_bytecode,
655 trade_account_oracle_deploy.clone(),
656 trial_trade_account_oracle_id,
657 trade_account_registry_id,
658 salt,
659 )
660 .await?;
661 let order_book_blacklist_id = deploy_order_book_blacklist(
662 wallet.clone(),
663 params.deploy_blacklist,
664 markets_config_partial.order_book_blacklist_id,
665 salt,
666 )
667 .await?;
668 let order_book_whitelist_id = deploy_order_book_whitelist(
669 wallet.clone(),
670 params.deploy_whitelist,
671 markets_config_partial.order_book_whitelist_id,
672 salt,
673 )
674 .await?;
675 let (order_book_registry, order_book_registry_blob_id) = deploy_order_book_registry(
676 wallet.clone(),
677 params.upgrade_bytecode,
678 order_book_registry_id,
679 salt,
680 )
681 .await?;
682 let pairs = deploy_order_books(
683 wallet.clone(),
684 params.upgrade_bytecode,
685 order_book_blacklist_id,
686 order_book_whitelist_id,
687 order_book_registry.clone(),
688 &mut markets_config_partial.pairs,
689 OwnershipTransferOptions {
690 new_proxy_owner: params.new_proxy_owner,
691 new_contract_owner: params.new_contract_owner,
692 revoke_orderbook_maintainers: params.revoke_orderbook_maintainers.clone(),
693 new_orderbook_maintainers: params.new_orderbook_maintainers.clone(),
694 },
695 )
696 .await?;
697
698 let order_book_registry_id = order_book_registry.contract_id;
699 let trade_account_registry_id = trade_account_registry.contract_id;
700 let trade_account_oracle_id = trade_account_oracle_deploy.oracle_id;
701
702 let trade_account_proxy = trade_account_registry
703 .registry
704 .methods()
705 .default_bytecode()
706 .simulate(Execution::state_read_only())
707 .await?
708 .value
709 .context(
710 "Trade account registry default bytecode should exist after initialization",
711 )?;
712 let trade_account_root = trade_account_registry
713 .registry
714 .methods()
715 .factory_bytecode_root()
716 .simulate(Execution::state_read_only())
717 .await?
718 .value
719 .context("Trade account registry factory bytecode root should exist after initialization")?;
720
721 let margin_ids = match &markets_config_partial.margin {
725 Some(margin) => Some(
726 deploy_margin(
727 &wallet,
728 margin,
729 salt,
730 params.margin_tier_only,
731 trade_account_registry_id,
732 trade_account_oracle_id,
733 trial_trade_account_oracle_id,
734 &pairs,
735 params.margin_cosigner,
736 params.margin_liquidator,
737 params.margin_expiry_cosigner,
738 params.upgrade_bytecode,
739 )
740 .await?,
741 ),
742 None => None,
743 };
744
745 if let Some(trial_creator) = params.trial_creator {
749 let current_creator = trade_account_registry
750 .get_trial_trade_account_creator()
751 .await?;
752 if current_creator != trial_creator {
753 tracing::info!("Setting trial trade account creator to {trial_creator:?}");
754 trade_account_registry
755 .set_trial_trade_account_creator(trial_creator)
756 .await?;
757 }
758 }
759
760 transfer_ownership(
761 &wallet,
762 ¶ms,
763 &order_book_registry,
764 &trade_account_registry,
765 &trade_account_oracle_deploy,
766 trial_trade_account_oracle_id,
767 order_book_blacklist_id,
768 order_book_whitelist_id,
769 )
770 .await?;
771
772 let deploy_result = MarketsConfigOutput {
773 starting_height: starting_height.into(),
774 trade_account_registry_id,
775 trade_account_registry_blob_id,
776 trade_account_proxy,
777 trade_account_blob_id,
778 trade_account_root: ContractId::from(trade_account_root.0),
779 trade_account_oracle_id,
780 trial_trade_account_oracle_id,
781 order_book_whitelist_id,
782 order_book_blacklist_id,
783 order_book_registry_id,
784 order_book_registry_blob_id,
785 pairs,
786 fast_bridge_asset_registry_proxy_id,
787 price_feed_id: margin_ids.map(|ids| ids.price_feed_id),
788 margin_pool_id: margin_ids.map(|ids| ids.margin_pool_id),
789 margin_oracle_id: margin_ids.and_then(|ids| ids.margin_oracle_id),
790 margin: markets_config_partial.margin.clone().map(|mut margin| {
797 if let Some(ids) = margin_ids {
798 margin.price_feed.id = Some(ids.price_feed_id);
799 margin.margin_pool_id = Some(ids.margin_pool_id);
800 }
801 margin
802 }),
803 };
804
805 if let Some(output_path) = params.output {
806 let json = serde_json::to_string_pretty(&deploy_result)?;
807 tracing::info!("Deploy result saved to {}", output_path);
808 std::fs::write(output_path, json)?;
809 }
810
811 Ok(deploy_result)
812}
813
814#[derive(Debug, Clone, Copy)]
820pub struct MarginIds {
821 pub price_feed_id: ContractId,
822 pub margin_pool_id: ContractId,
823 pub margin_oracle_id: Option<ContractId>,
824}
825
826#[allow(clippy::too_many_arguments)]
837async fn deploy_margin<W>(
838 wallet: &W,
839 margin: &MarginConfig,
840 salt: fuels::types::Salt,
841 tier_only: bool,
842 trade_account_registry_id: ContractId,
843 trade_account_oracle_id: ContractId,
844 trial_trade_account_oracle_id: ContractId,
845 pairs: &[OrderBookConfig],
846 margin_cosigner: Option<fuels::types::Address>,
847 margin_liquidator: Option<Identity>,
848 margin_expiry_cosigner: Option<fuels::types::Address>,
849 upgrade_bytecode: bool,
850) -> anyhow::Result<MarginIds>
851where
852 W: Account + ViewOnlyAccount + Clone + 'static,
853{
854 use o2_tools::prop::{
855 PriceFeedContract,
856 PropAccountOracleContract,
857 PropMarginPoolContract,
858 };
859
860 let collateral_asset = match margin.collateral_asset {
861 Some(collateral_asset) => collateral_asset,
862 None => {
863 pairs
864 .first()
865 .context(
866 "margin.collateral_asset is not set and there are no pairs to \
867 default it from",
868 )?
869 .quote
870 .asset
871 }
872 };
873 let cosigner = margin_cosigner;
878 let platform_payout = margin
879 .platform_payout
880 .as_deref()
881 .map(parse_margin_identity)
882 .transpose()?;
883 let liquidator = margin_liquidator;
884 let expiry_cosigner = margin_expiry_cosigner.or(margin.expiry_cosigner);
893
894 let mut prop_config = PropDeployConfig::new(collateral_asset);
895 prop_config.collateral_decimals = margin.collateral_decimals.context(
898 "margin.collateral_decimals is required - set it to the collateral \
899 asset's decimals in the deploy config",
900 )?;
901 prop_config.max_tier_books = margin.max_tier_books.unwrap_or(80);
902 prop_config.base_repay_fee_ppm = margin.base_repay_fee_ppm.unwrap_or(100);
903 prop_config.cosigner = cosigner;
904 prop_config.platform_payout = platform_payout;
905 prop_config.liquidator = liquidator;
906 prop_config.salt = salt;
907 prop_config.existing_price_feed = margin.price_feed.id;
908 prop_config.max_offchain_age_seconds = margin.price_feed.max_offchain_age_seconds;
909 prop_config.existing_pool = margin.margin_pool_id;
913 prop_config.existing_registry = Some(ExistingRegistry {
914 registry_id: trade_account_registry_id,
915 trade_account_oracle_id,
916 trial_trade_account_oracle_id,
917 });
918
919 anyhow::ensure!(
920 !(tier_only && margin.margin_pool_id.is_none()),
921 "--margin-tier-only was requested but the markets config names no \
922 `margin.margin_pool_id`: there is no pool to reconcile tiers against"
923 );
924
925 let (price_feed_id, margin_pool_id, margin_oracle_id) = if let (
927 true,
928 Some(margin_pool_id),
929 ) =
930 (tier_only, margin.margin_pool_id)
931 {
932 tracing::info!("Margin: tier-only mode against pool {margin_pool_id}");
933 let price_feed_id = match margin.price_feed.id {
934 Some(price_feed_id) => price_feed_id,
935 None => {
936 PropMarginPoolContract::new(margin_pool_id, wallet.clone())
937 .methods()
938 .price_feed()
939 .simulate(Execution::state_read_only())
940 .await
941 .context("read the pool's price feed")?
942 .value
943 }
944 };
945 let margin_oracle_id =
951 TradeAccountRegistryManager::new(wallet.clone(), trade_account_registry_id)
952 .registry
953 .methods()
954 .get_prop_oracle_id()
955 .simulate(Execution::state_read_only())
956 .await
957 .map(|result| result.value)
958 .ok()
959 .filter(|oracle_id| *oracle_id != ContractId::zeroed());
960 (price_feed_id, margin_pool_id, margin_oracle_id)
961 } else {
962 let deployment = deploy_prop_system(wallet, &prop_config).await?;
963 tracing::info!(
964 "Margin: oracle {}, pool {}, feed {} (shared registry {} upgraded in place)",
965 deployment.oracle_id,
966 deployment.pool_id,
967 deployment.price_feed_id,
968 deployment.registry_id,
969 );
970 (
971 deployment.price_feed_id,
972 deployment.pool_id,
973 Some(deployment.oracle_id),
974 )
975 };
976
977 let pool_deployed = wallet
983 .try_provider()?
984 .contract_exists(&margin_pool_id)
985 .await?;
986 if pool_deployed {
987 let pool = PropMarginPoolContract::new(margin_pool_id, wallet.clone());
988 if let Some(platform_payout) = platform_payout {
989 let current = pool
990 .methods()
991 .platform_payout()
992 .simulate(Execution::state_read_only())
993 .await
994 .context("read the pool's platform payout")?
995 .value;
996 if current != platform_payout {
997 tracing::info!(
998 "Margin: platform payout {current:?} -> {platform_payout:?}"
999 );
1000 pool.methods()
1001 .set_platform_payout(platform_payout)
1002 .call()
1003 .await?;
1004 }
1005 }
1006 if let Some(liquidator) = liquidator {
1007 let current = pool
1008 .methods()
1009 .liquidator()
1010 .simulate(Execution::state_read_only())
1011 .await
1012 .context("read the pool's liquidator")?
1013 .value;
1014 if current != liquidator {
1015 tracing::info!("Margin: liquidator {current:?} -> {liquidator:?}");
1016 pool.methods().set_liquidator(liquidator).call().await?;
1017 }
1018 }
1019
1020 if let Some(expiry_cosigner) = expiry_cosigner {
1031 let current = pool
1032 .methods()
1033 .expiry_cosigner()
1034 .simulate(Execution::state_read_only())
1035 .await
1036 .context("read the pool's expiry cosigner")?
1037 .value;
1038 if current != Some(expiry_cosigner) {
1039 tracing::info!(
1040 "Margin: expiry cosigner {current:?} -> {expiry_cosigner:?}"
1041 );
1042 pool.methods()
1043 .set_expiry_cosigner(expiry_cosigner)
1044 .call()
1045 .await?;
1046 }
1047 } else {
1048 let current = pool
1052 .methods()
1053 .expiry_cosigner()
1054 .simulate(Execution::state_read_only())
1055 .await
1056 .context("read the pool's expiry cosigner")?
1057 .value;
1058 if current.is_none() {
1059 tracing::warn!(
1060 "Margin: no expiry cosigner configured and this pool has \
1061 none - PREPAID (Turbo) deadlines cannot be settled until \
1062 `margin.expiry_cosigner` names the address of the \
1063 backend's margin cosigner key."
1064 );
1065 }
1066 }
1067
1068 for entry in &margin.discount_managers {
1077 anyhow::ensure!(
1078 !margin.remove_discount_managers.contains(entry),
1079 "discount manager {entry} is listed in both \
1080 `discount_managers` and `remove_discount_managers`"
1081 );
1082 }
1083 for (entry, should_hold) in margin
1084 .discount_managers
1085 .iter()
1086 .map(|entry| (entry, true))
1087 .chain(
1088 margin
1089 .remove_discount_managers
1090 .iter()
1091 .map(|entry| (entry, false)),
1092 )
1093 {
1094 let manager = parse_margin_identity(entry)?;
1095 let holds = pool
1096 .methods()
1097 .has_role(DISCOUNT_MANAGER_ROLE, manager)
1098 .simulate(Execution::state_read_only())
1099 .await
1100 .context("read a discount manager's role")?
1101 .value;
1102 if holds == should_hold {
1103 continue;
1104 }
1105 if should_hold {
1106 tracing::info!("Margin: adding discount manager {manager:?}");
1107 pool.methods()
1108 .grant_role(DISCOUNT_MANAGER_ROLE, manager)
1109 .call()
1110 .await
1111 .context("grant a discount manager its role")?;
1112 } else {
1113 tracing::info!("Margin: removing discount manager {manager:?}");
1114 pool.methods()
1115 .revoke_role(DISCOUNT_MANAGER_ROLE, manager)
1116 .call()
1117 .await
1118 .context("revoke a discount manager's role")?;
1119 }
1120 }
1121 }
1122
1123 let margin_oracle_id = match margin_oracle_id {
1126 Some(oracle_id) => Some(oracle_id),
1127 None if cosigner.is_some() => {
1128 let registry = o2_tools::trade_account_registry::TradeAccountRegistry::new(
1129 trade_account_registry_id,
1130 wallet.clone(),
1131 );
1132 let oracle_id = registry
1133 .methods()
1134 .get_prop_oracle_id()
1135 .simulate(Execution::state_read_only())
1136 .await
1137 .context("read the registry's prop account oracle")?
1138 .value;
1139 (oracle_id != ContractId::zeroed()).then_some(oracle_id)
1140 }
1141 None => None,
1142 };
1143
1144 if let (Some(cosigner), Some(oracle_id)) = (cosigner, margin_oracle_id)
1149 && wallet.try_provider()?.contract_exists(&oracle_id).await?
1150 {
1151 let oracle = PropAccountOracleContract::new(oracle_id, wallet.clone());
1152 let current = oracle
1153 .methods()
1154 .get_cosigner()
1155 .simulate(Execution::state_read_only())
1156 .await
1157 .context("read the prop account oracle's cosigner")?
1158 .value;
1159 if current != Some(cosigner) {
1160 tracing::info!("Margin: cosigner {current:?} -> {cosigner:?}");
1161 oracle.methods().set_cosigner(cosigner).call().await?;
1162 }
1163 }
1164
1165 for entry in &margin.price_feed.publishers {
1173 anyhow::ensure!(
1174 !margin.price_feed.remove_publishers.contains(entry),
1175 "price feed publisher {entry} is listed in both `publishers` and \
1176 `remove_publishers`"
1177 );
1178 }
1179 let price_feed = PriceFeedContract::new(price_feed_id, wallet.clone());
1180 for (entry, should_hold) in margin
1181 .price_feed
1182 .publishers
1183 .iter()
1184 .map(|entry| (entry, true))
1185 .chain(
1186 margin
1187 .price_feed
1188 .remove_publishers
1189 .iter()
1190 .map(|entry| (entry, false)),
1191 )
1192 {
1193 let publisher = parse_margin_identity(entry)?;
1194 let holds = price_feed
1195 .methods()
1196 .has_role(o2_tools::prop_deploy::PRICE_SUBMITTER_ROLE, publisher)
1197 .simulate(Execution::state_read_only())
1198 .await
1199 .context("read a price feed publisher's submitter role")?
1200 .value;
1201 if holds == should_hold {
1202 continue;
1203 }
1204 if should_hold {
1205 tracing::info!("Margin: adding price feed publisher {publisher:?}");
1206 price_feed
1207 .methods()
1208 .add_publisher(publisher)
1209 .call()
1210 .await
1211 .context("grant a price feed publisher the submitter role")?;
1212 } else {
1213 tracing::info!("Margin: removing price feed publisher {publisher:?}");
1214 price_feed
1215 .methods()
1216 .remove_publisher(publisher)
1217 .call()
1218 .await
1219 .context("revoke a price feed publisher's submitter role")?;
1220 }
1221 }
1222 if upgrade_bytecode && !tier_only {
1231 o2_tools::prop_deploy::upgrade_price_feed_implementation(
1232 wallet,
1233 price_feed_id,
1234 prop_config
1237 .owner
1238 .unwrap_or(Identity::Address(wallet.address())),
1239 &prop_config,
1240 )
1241 .await
1242 .context("upgrade the price feed implementation")?;
1243 }
1244
1245 if let Some(seconds) = margin.price_feed.max_offchain_age_seconds {
1250 let current = price_feed
1251 .methods()
1252 .get_max_offchain_age()
1253 .simulate(Execution::state_read_only())
1254 .await
1255 .context("read the price feed's submission-age bound")?
1256 .value;
1257 if current != seconds {
1258 tracing::info!("Margin: submission-age bound {current} -> {seconds}");
1259 price_feed
1260 .methods()
1261 .set_max_offchain_age(seconds)
1262 .call()
1263 .await
1264 .context("set the price feed's submission-age bound")?;
1265 }
1266 }
1267
1268 for supported in &margin.price_feed.supported_assets {
1274 let current = price_feed
1275 .methods()
1276 .get_asset_decimals(supported.asset)
1277 .simulate(Execution::state_read_only())
1278 .await
1279 .context("read the price feed's asset decimals")?
1280 .value;
1281 match current {
1282 Some(decimals) if decimals == supported.decimals => continue,
1283 Some(decimals) => anyhow::bail!(
1287 "price feed already knows {} at {decimals} decimals, config says \
1288 {} - refusing to move it, since the pool pins decimals on first \
1289 use and rejects a change",
1290 supported.asset,
1291 supported.decimals,
1292 ),
1293 None => {
1294 tracing::info!(
1295 "Margin: registering {} at {} decimals on the feed",
1296 supported.asset,
1297 supported.decimals,
1298 );
1299 price_feed
1300 .methods()
1301 .set_asset_decimals(supported.asset, supported.decimals)
1302 .call()
1303 .await
1304 .context("register a supported asset on the price feed")?;
1305 }
1306 }
1307 }
1308
1309 let pool = PropMarginPoolContract::new(margin_pool_id, wallet.clone());
1310 let pool_reachable = wallet
1311 .try_provider()?
1312 .contract_exists(&margin_pool_id)
1313 .await?;
1314
1315 if pool_reachable {
1325 let live_feed = pool
1326 .methods()
1327 .price_feed()
1328 .simulate(Execution::state_read_only())
1329 .await
1330 .context("read the pool's price feed")?
1331 .value;
1332 if live_feed != price_feed_id {
1333 tracing::info!("Margin: pool price feed {live_feed} -> {price_feed_id}");
1334 pool.methods()
1335 .set_price_feed(price_feed_id)
1336 .with_contract_ids(&[price_feed_id])
1339 .call()
1340 .await
1341 .context(
1342 "point the pool at the configured price feed - the \
1343 replacement must already know every admitted asset at the \
1344 decimals the pool pinned",
1345 )?;
1346 }
1347 }
1348
1349 anyhow::ensure!(
1356 pool_reachable || margin.tiers.is_empty(),
1357 "margin pool {margin_pool_id} is not on chain, so its {} configured \
1358 tier(s) cannot be reconciled",
1359 margin.tiers.len()
1360 );
1361 if pool_reachable {
1362 reconcile_margin_tiers(
1363 &pool,
1364 price_feed_id,
1365 pairs,
1366 &margin.tiers,
1367 prop_config.collateral_decimals,
1368 )
1369 .await?;
1370 }
1371
1372 Ok(MarginIds {
1373 price_feed_id,
1374 margin_pool_id,
1375 margin_oracle_id,
1376 })
1377}
1378
1379async fn reconcile_margin_tiers<W>(
1388 pool: &o2_tools::prop::PropMarginPoolContract<W>,
1389 price_feed_id: ContractId,
1390 pairs: &[OrderBookConfig],
1391 tiers: &[MarginTierConfig],
1392 collateral_decimals: u8,
1393) -> anyhow::Result<()>
1394where
1395 W: Account + ViewOnlyAccount + Clone + 'static,
1396{
1397 for tier in tiers {
1398 let books = tier
1399 .markets
1400 .iter()
1401 .map(|market| resolve_tier_market(market, pairs))
1402 .collect::<anyhow::Result<Vec<_>>>()?;
1403 let params = tier_params(tier, collateral_decimals)?;
1404 let terms = tier.turbo.as_ref().map(o2_tools::prop::TurboTerms::from);
1405 validate_turbo_tier_config(tier, ¶ms, terms.as_ref())?;
1409 let current_version = pool
1410 .methods()
1411 .current_tier_version(tier.tier_id)
1412 .simulate(Execution::state_read_only())
1413 .await?
1414 .value;
1415
1416 let mut contract_ids = vec![price_feed_id];
1417 contract_ids.extend(books.iter().copied());
1418
1419 let Some(version) = current_version else {
1420 let version = publish_margin_tier(
1421 pool,
1422 tier.tier_id,
1423 params,
1424 terms.clone(),
1425 books,
1426 &contract_ids,
1427 )
1428 .await?;
1429 tracing::info!(
1430 "Margin: tier {} published version {version}{}",
1431 tier.tier_id,
1432 if terms.is_some() { " (prepaid)" } else { "" }
1433 );
1434 reconcile_margin_tier_enabled(pool, tier).await?;
1435 continue;
1436 };
1437
1438 let live = pool
1439 .methods()
1440 .get_tier(tier.tier_id, version)
1441 .simulate(Execution::state_read_only())
1442 .await?
1443 .value
1444 .with_context(|| {
1445 format!("tier {} version {version} vanished mid-read", tier.tier_id)
1446 })?;
1447 let live_terms = pool
1454 .methods()
1455 .get_turbo_terms(tier.tier_id, version)
1456 .simulate(Execution::state_read_only())
1457 .await?
1458 .value;
1459 if live != params || live_terms != terms {
1460 let new_version = publish_margin_tier(
1461 pool,
1462 tier.tier_id,
1463 params,
1464 terms.clone(),
1465 books,
1466 &contract_ids,
1467 )
1468 .await?;
1469 tracing::info!(
1470 "Margin: tier {} republished as version {new_version} ({})",
1471 tier.tier_id,
1472 if live_terms != terms {
1473 "terms changed"
1474 } else {
1475 "economics changed"
1476 }
1477 );
1478 reconcile_margin_tier_enabled(pool, tier).await?;
1479 continue;
1480 }
1481 reconcile_margin_tier_enabled(pool, tier).await?;
1482
1483 let live_books = pool
1484 .methods()
1485 .tier_books(tier.tier_id, version)
1486 .simulate(Execution::state_read_only())
1487 .await?
1488 .value;
1489 let missing: Vec<ContractId> = books
1490 .iter()
1491 .copied()
1492 .filter(|book| !live_books.contains(book))
1493 .collect();
1494 let shrunk = live_books
1495 .iter()
1496 .filter(|book| !books.contains(book))
1497 .count();
1498 if shrunk > 0 {
1499 tracing::warn!(
1500 "Margin: tier {} declares {shrunk} fewer book(s) than live version \
1501 {version}; books are append-only — publish a new version to drop \
1502 markets",
1503 tier.tier_id
1504 );
1505 }
1506 if missing.is_empty() {
1507 tracing::info!(
1508 "Margin: tier {} version {version} matches the declared catalogue — \
1509 unchanged",
1510 tier.tier_id
1511 );
1512 continue;
1513 }
1514 let mut add_contract_ids = vec![price_feed_id];
1515 add_contract_ids.extend(missing.iter().copied());
1516 pool.methods()
1517 .add_books(tier.tier_id, missing.clone())
1518 .with_contract_ids(&add_contract_ids)
1519 .call()
1520 .await?;
1521 tracing::info!(
1522 "Margin: tier {} version {version} gained {} book(s)",
1523 tier.tier_id,
1524 missing.len()
1525 );
1526 }
1527 Ok(())
1528}
1529
1530async fn publish_margin_tier<W>(
1536 pool: &o2_tools::prop::PropMarginPoolContract<W>,
1537 tier_id: u64,
1538 params: o2_tools::prop::TierParams,
1539 terms: Option<o2_tools::prop::TurboTerms>,
1540 books: Vec<ContractId>,
1541 contract_ids: &[ContractId],
1542) -> anyhow::Result<u32>
1543where
1544 W: Account + ViewOnlyAccount + Clone + 'static,
1545{
1546 let version = match terms {
1547 Some(terms) => {
1548 pool.methods()
1549 .publish_tier_v2(tier_id, params, terms, books)
1550 .with_contract_ids(contract_ids)
1551 .call()
1552 .await?
1553 .value
1554 }
1555 None => {
1556 pool.methods()
1557 .publish_tier_version(tier_id, params, books)
1558 .with_contract_ids(contract_ids)
1559 .call()
1560 .await?
1561 .value
1562 }
1563 };
1564 Ok(version)
1565}
1566
1567async fn reconcile_margin_tier_enabled<W>(
1575 pool: &o2_tools::prop::PropMarginPoolContract<W>,
1576 tier: &MarginTierConfig,
1577) -> anyhow::Result<()>
1578where
1579 W: Account + ViewOnlyAccount + Clone + 'static,
1580{
1581 let live = pool
1582 .methods()
1583 .is_tier_enabled(tier.tier_id)
1584 .simulate(Execution::state_read_only())
1585 .await?
1586 .value;
1587 if live == tier.enabled {
1588 return Ok(());
1589 }
1590 pool.methods()
1591 .set_tier_enabled(tier.tier_id, tier.enabled)
1592 .call()
1593 .await?;
1594 tracing::info!(
1595 "Margin: tier {} now {} for new sessions",
1596 tier.tier_id,
1597 if tier.enabled { "OPEN" } else { "CLOSED" }
1598 );
1599 Ok(())
1600}
1601
1602fn validate_turbo_tier_config(
1609 tier: &MarginTierConfig,
1610 params: &o2_tools::prop::TierParams,
1611 terms: Option<&o2_tools::prop::TurboTerms>,
1612) -> anyhow::Result<()> {
1613 let Some(terms) = terms else {
1614 return Ok(());
1615 };
1616 let id = tier.tier_id;
1617 anyhow::ensure!(
1618 params.duration == 0,
1619 "tier {id} is prepaid, so `duration` must be 0 - the term it sells is \
1620 the week bought at open, not a tier-wide base period"
1621 );
1622 anyhow::ensure!(
1623 params.prolong_fee.iter().all(|fee| *fee == 0),
1624 "tier {id} is prepaid, so every `prolong_fee` must be \"0\" - a \
1625 prepaid term is extended by the FREE rollover and by nothing else"
1626 );
1627 anyhow::ensure!(
1628 params.profit_share_bps == 0,
1629 "tier {id} is prepaid, so `profit_share_bps` must be 0 - the user \
1630 keeps 100% of profits and the platform's take is the forfeited entry"
1631 );
1632 anyhow::ensure!(
1633 terms.rollover_profit_bps <= 10_000,
1634 "tier {id} `rollover_profit_bps` must be <= 10000"
1635 );
1636 anyhow::ensure!(
1637 terms.max_loss_bps.iter().all(|bps| *bps <= 10_000),
1638 "tier {id} every `max_loss_bps` entry must be <= 10000 - a rate past \
1639 BPS would put the floor below zero for any anchor"
1640 );
1641 if terms.rollover_profit_bps != 0 {
1642 anyhow::ensure!(
1643 terms.max_rollovers != 0,
1644 "tier {id} offers a rollover, so `max_rollovers` must not be 0 - \
1645 an enabled feature must be a real one"
1646 );
1647 }
1648 if terms.max_loss_bps.iter().any(|bps| *bps != 0) {
1649 anyhow::ensure!(
1650 terms.rollover_profit_bps != 0,
1651 "tier {id} sells a drawdown escalator, which is measured from the \
1652 rollover anchor, so `rollover_profit_bps` must not be 0 - the \
1653 schedule would otherwise be silently dead"
1654 );
1655 let last_step = terms.max_rollovers.min(3) as usize;
1660 for step in 0..=last_step {
1661 anyhow::ensure!(
1662 terms.max_loss_bps[step] != 0,
1663 "tier {id} sells a drawdown escalator whose step {step} is 0, \
1664 which floors that term at the session's own anchor - a \
1665 session reaching it is liquidatable immediately"
1666 );
1667 anyhow::ensure!(
1668 step == 0 || terms.max_loss_bps[step] >= terms.max_loss_bps[step - 1],
1669 "tier {id} drawdown step {step} is NARROWER than the step \
1670 before it - the schedule is sold as an escalator and must \
1671 never take back room a previous term already granted"
1672 );
1673 }
1674 let line = u128::from(params.line);
1683 let bps_up = |bps: u64| (line * u128::from(bps)).div_ceil(10_000);
1684 let allowance = line * u128::from(terms.max_loss_bps[0]) / 10_000;
1685 let gap =
1686 bps_up(params.open_buffer_bps).saturating_sub(bps_up(params.maintenance_bps));
1687 anyhow::ensure!(
1688 gap <= allowance,
1689 "tier {id} first drawdown step ({} bps) is narrower than the \
1690 tier's own `open_buffer_bps - maintenance_bps` gap, so every \
1691 session it sells opens already FROZEN and can never trade",
1692 terms.max_loss_bps[0]
1693 );
1694 }
1695 Ok(())
1696}
1697
1698fn collateral_amount(value: &str, decimals: u8, field: &str) -> anyhow::Result<u64> {
1707 let raw = value.trim();
1708 anyhow::ensure!(!raw.is_empty(), "{field}: empty amount");
1709 anyhow::ensure!(
1710 !raw.starts_with('-'),
1711 "{field}: `{raw}` is negative; fees are unsigned"
1712 );
1713 let (whole, fraction) = match raw.split_once('.') {
1714 Some((whole, fraction)) => (whole, fraction),
1715 None => (raw, ""),
1716 };
1717 anyhow::ensure!(
1718 !fraction.contains('.'),
1719 "{field}: `{raw}` has more than one decimal point"
1720 );
1721 anyhow::ensure!(
1722 !whole.is_empty() && whole.bytes().all(|b| b.is_ascii_digit()),
1723 "{field}: `{raw}` is not a decimal amount"
1724 );
1725 anyhow::ensure!(
1726 fraction.bytes().all(|b| b.is_ascii_digit()),
1727 "{field}: `{raw}` is not a decimal amount"
1728 );
1729 let decimals = usize::from(decimals);
1730 anyhow::ensure!(
1731 fraction.len() <= decimals,
1732 "{field}: `{raw}` has {} decimal places, but the collateral asset has \
1733 only {decimals}; the chain cannot represent it",
1734 fraction.len()
1735 );
1736 let digits = format!("{whole}{fraction}{}", "0".repeat(decimals - fraction.len()));
1737 digits
1738 .parse::<u64>()
1739 .map_err(|e| anyhow::anyhow!("{field}: `{raw}` does not fit a u64: {e}"))
1740}
1741
1742fn tier_params(
1743 tier: &MarginTierConfig,
1744 collateral_decimals: u8,
1745) -> anyhow::Result<o2_tools::prop::TierParams> {
1746 let fee = |value: &str, what: &str| {
1747 collateral_amount(
1748 value,
1749 collateral_decimals,
1750 &format!("tier {} {what}", tier.tier_id),
1751 )
1752 };
1753 Ok(o2_tools::prop::TierParams {
1754 line: tier.line,
1755 leverage: tier.leverage,
1756 duration: tier.duration,
1757 maintenance_bps: tier.maintenance_bps,
1758 open_buffer_bps: tier.open_buffer_bps,
1759 liq_price_factor: tier.liq_price_factor,
1760 prolong_fee: [
1761 fee(&tier.prolong_fee[0], "prolong_fee[0]")?,
1762 fee(&tier.prolong_fee[1], "prolong_fee[1]")?,
1763 fee(&tier.prolong_fee[2], "prolong_fee[2]")?,
1764 fee(&tier.prolong_fee[3], "prolong_fee[3]")?,
1765 ],
1766 max_credit_line_bps: tier.max_credit_line_bps,
1767 max_price_age: tier.max_price_age,
1768 open_fee: fee(&tier.open_fee, "open_fee")?,
1769 profit_share_bps: tier.profit_share_bps,
1770 price_band_bps: tier.price_band_bps,
1771 })
1772}
1773
1774fn resolve_tier_market(
1777 market: &str,
1778 pairs: &[OrderBookConfig],
1779) -> anyhow::Result<ContractId> {
1780 let wanted = market.trim();
1781 let wanted_id = wanted
1782 .strip_prefix("0x")
1783 .unwrap_or(wanted)
1784 .to_ascii_lowercase();
1785 let pair = pairs
1786 .iter()
1787 .find(|pair| {
1788 let symbol = format!("{}/{}", pair.base.symbol, pair.quote.symbol);
1789 symbol.eq_ignore_ascii_case(wanted)
1790 || hex::encode(*pair.market_id) == wanted_id
1791 })
1792 .with_context(|| format!("margin tier references unknown market `{market}`"))?;
1793 pair.contract_id.with_context(|| {
1794 format!("margin tier market `{market}` has no deployed order book")
1795 })
1796}
1797
1798fn parse_margin_identity(s: &str) -> anyhow::Result<Identity> {
1800 if let Some(hex_part) = s.strip_prefix("address:") {
1801 Ok(Identity::Address(fuels::types::Address::new(
1802 parse_margin_bytes32(hex_part)?,
1803 )))
1804 } else if let Some(hex_part) = s.strip_prefix("contract:") {
1805 Ok(Identity::ContractId(ContractId::new(parse_margin_bytes32(
1806 hex_part,
1807 )?)))
1808 } else {
1809 anyhow::bail!("expected `address:0x..` or `contract:0x..`, got `{s}`")
1810 }
1811}
1812
1813fn parse_margin_bytes32(s: &str) -> anyhow::Result<[u8; 32]> {
1814 let raw = s.trim().strip_prefix("0x").unwrap_or(s.trim());
1815 let bytes = hex::decode(raw)
1816 .map_err(|e| anyhow::anyhow!("expected 32 hex bytes, got `{s}`: {e}"))?;
1817 bytes
1818 .try_into()
1819 .map_err(|_| anyhow::anyhow!("expected 32 hex bytes, got `{s}`"))
1820}
1821
1822#[allow(clippy::too_many_arguments)]
1827async fn transfer_ownership<W>(
1828 wallet: &W,
1829 params: &DeployParams,
1830 order_book_registry: &OrderBookRegistryManager<W>,
1831 trade_account_registry: &TradeAccountRegistryManager<W>,
1832 trade_account_oracle_deploy: &TradeAccountDeploy<W>,
1833 trial_trade_account_oracle_id: ContractId,
1834 order_book_blacklist_id: Option<ContractId>,
1835 order_book_whitelist_id: Option<ContractId>,
1836) -> anyhow::Result<()>
1837where
1838 W: Account + ViewOnlyAccount + Clone + 'static,
1839{
1840 if let Some(new_proxy_owner) = params.new_proxy_owner {
1841 let new_identity = Identity::Address(new_proxy_owner);
1842 tracing::info!(
1843 "Transferring OrderBookRegistry proxy ownership to {}",
1844 new_proxy_owner
1845 );
1846 order_book_registry
1847 .registry_proxy
1848 .methods()
1849 .set_owner(new_identity)
1850 .call()
1851 .await?;
1852 tracing::info!(
1853 "Transferring TradeAccountRegistry proxy ownership to {}",
1854 new_proxy_owner
1855 );
1856 trade_account_registry
1857 .registry_proxy
1858 .methods()
1859 .set_owner(new_identity)
1860 .call()
1861 .await?;
1862 }
1863
1864 if let Some(new_contract_owner) = params.new_contract_owner {
1865 let new_identity = Identity::Address(new_contract_owner);
1866 tracing::info!(
1867 "Transferring TradeAccountOracle ownership to {}",
1868 new_contract_owner
1869 );
1870 trade_account_oracle_deploy
1871 .oracle
1872 .methods()
1873 .transfer_ownership(new_identity)
1874 .call()
1875 .await?;
1876 tracing::info!(
1877 "Transferring TrialTradeAccountOracle ownership to {}",
1878 new_contract_owner
1879 );
1880 TrialTradingAccountOracle::new(trial_trade_account_oracle_id, wallet.clone())
1881 .methods()
1882 .transfer_ownership(new_identity)
1883 .call()
1884 .await?;
1885 tracing::info!(
1886 "Transferring TradeAccountRegistry ownership to {}",
1887 new_contract_owner
1888 );
1889 trade_account_registry
1890 .registry
1891 .methods()
1892 .transfer_ownership(new_identity)
1893 .call()
1894 .await?;
1895 tracing::info!(
1896 "Transferring OrderBookRegistry ownership to {}",
1897 new_contract_owner
1898 );
1899 order_book_registry
1900 .registry
1901 .methods()
1902 .transfer_ownership(new_identity)
1903 .call()
1904 .await?;
1905 if let Some(blacklist_id) = order_book_blacklist_id {
1906 tracing::info!(
1907 "Transferring OrderBookBlacklist ownership to {}",
1908 new_contract_owner
1909 );
1910 OrderBookBlacklist::new(blacklist_id, wallet.clone())
1911 .methods()
1912 .transfer_ownership(new_identity)
1913 .call()
1914 .await?;
1915 }
1916 if let Some(whitelist_id) = order_book_whitelist_id {
1917 tracing::info!(
1918 "Transferring OrderBookWhitelist ownership to {}",
1919 new_contract_owner
1920 );
1921 OrderBookWhitelist::new(whitelist_id, wallet.clone())
1922 .methods()
1923 .transfer_ownership(new_identity)
1924 .call()
1925 .await?;
1926 }
1927 }
1928
1929 Ok(())
1930}
1931
1932async fn deploy_order_book_blacklist<W>(
1937 deployer_wallet: W,
1938 deploy_blacklist: bool,
1939 order_book_blacklist_id: Option<ContractId>,
1940 salt: Salt,
1941) -> anyhow::Result<Option<ContractId>>
1942where
1943 W: Account + ViewOnlyAccount + Clone + 'static,
1944{
1945 match order_book_blacklist_id {
1946 Some(order_book_blacklist_id) => {
1947 tracing::info!(
1948 "Using existing OrderBookBlacklist: {}",
1949 order_book_blacklist_id
1950 );
1951 Ok(Some(order_book_blacklist_id))
1952 }
1953 None => {
1954 if !deploy_blacklist {
1955 return Ok(None);
1956 }
1957 tracing::info!("Deploying OrderBookBlacklist");
1958 let order_book_blacklist = OrderBookDeploy::deploy_order_book_blacklist(
1959 &deployer_wallet,
1960 &Identity::Address(ViewOnlyAccount::address(&deployer_wallet)),
1961 &OrderBookDeployConfig {
1962 salt,
1963 ..Default::default()
1964 },
1965 )
1966 .await?;
1967 tracing::info!("OrderBookBlacklist: {}", order_book_blacklist.contract_id());
1968 Ok(Some(order_book_blacklist.contract_id()))
1969 }
1970 }
1971}
1972
1973async fn deploy_order_book_whitelist<W>(
1974 deployer_wallet: W,
1975 deploy_whitelist: bool,
1976 order_book_whitelist_id: Option<ContractId>,
1977 salt: Salt,
1978) -> anyhow::Result<Option<ContractId>>
1979where
1980 W: Account + ViewOnlyAccount + Clone + 'static,
1981{
1982 match (order_book_whitelist_id, deploy_whitelist) {
1983 (Some(order_book_whitelist_id), false)
1984 | (Some(order_book_whitelist_id), true) => {
1985 tracing::info!(
1986 "Using existing OrderBookWhitelist: {}",
1987 order_book_whitelist_id
1988 );
1989 Ok(Some(order_book_whitelist_id))
1990 }
1991 (None, false) => Ok(None),
1992 (None, true) => {
1993 tracing::info!("Deploying OrderBookWhitelist");
1994 let trade_account_whitelist = OrderBookDeploy::deploy_order_book_whitelist(
1995 &deployer_wallet,
1996 &Identity::Address(ViewOnlyAccount::address(&deployer_wallet)),
1997 &OrderBookDeployConfig {
1998 salt,
1999 ..Default::default()
2000 },
2001 )
2002 .await?;
2003 tracing::info!(
2004 "OrderBookWhitelist: {}",
2005 trade_account_whitelist.contract_id()
2006 );
2007 Ok(Some(trade_account_whitelist.contract_id()))
2008 }
2009 }
2010}
2011
2012async fn load_or_recover_trade_account_oracle<W>(
2016 deployer_wallet: &W,
2017 oracle_id: ContractId,
2018) -> anyhow::Result<(TradeAccountDeploy<W>, ContractId)>
2019where
2020 W: Account + ViewOnlyAccount + Clone + 'static,
2021{
2022 let oracle = TradingAccountOracle::new(oracle_id, deployer_wallet.clone());
2023 let impl_id = oracle
2024 .methods()
2025 .get_trade_account_impl()
2026 .simulate(Execution::state_read_only())
2027 .await?
2028 .value;
2029
2030 let blob_id = match impl_id {
2031 Some(id) => id,
2032 None => {
2033 tracing::info!(
2034 "Trade account implementation not set on oracle {}, deploying...",
2035 oracle_id
2036 );
2037 let blob = TradeAccountDeploy::trade_account_blob(
2038 deployer_wallet,
2039 &Default::default(),
2040 )
2041 .await?;
2042 TradeAccountDeploy::deploy_trade_account_blob(
2043 deployer_wallet,
2044 &DeployConfig::Latest(Default::default()),
2045 )
2046 .await?;
2047 oracle
2048 .methods()
2049 .set_trade_account_impl(ContractId::from(blob.id))
2050 .call()
2051 .await?;
2052 ContractId::from(blob.id)
2053 }
2054 };
2055
2056 let deploy = TradeAccountDeploy {
2057 oracle,
2058 oracle_id,
2059 trade_account_blob_id: blob_id.into(),
2060 deployer_wallet: deployer_wallet.clone(),
2061 proxy: None,
2062 proxy_id: None,
2063 };
2064 Ok((deploy, blob_id))
2065}
2066
2067async fn deploy_trade_account_oracle<W>(
2068 deployer_wallet: W,
2069 should_upgrade_bytecode: bool,
2070 trade_account_oracle_id: Option<ContractId>,
2071 salt: Salt,
2072) -> anyhow::Result<(TradeAccountDeploy<W>, ContractId)>
2073where
2074 W: Account + ViewOnlyAccount + Clone + 'static,
2075{
2076 let (trade_account_oracle_deploy, mut trade_account_blob_id) =
2077 match trade_account_oracle_id {
2078 Some(oracle_id) => {
2079 load_or_recover_trade_account_oracle(&deployer_wallet, oracle_id).await?
2080 }
2081 None => {
2082 let deploy = TradeAccountDeploy::deploy(
2083 &deployer_wallet,
2084 &DeployConfig::Latest(TradeAccountDeployConfig {
2085 salt,
2086 ..Default::default()
2087 }),
2088 )
2089 .await?;
2090 let blob_id = deploy
2091 .oracle
2092 .methods()
2093 .get_trade_account_impl()
2094 .simulate(Execution::state_read_only())
2095 .await?
2096 .value
2097 .context("Trade account impl should exist after fresh deploy")?;
2098 (deploy, blob_id)
2099 }
2100 };
2101 tracing::info!(
2102 "TradeAccountOracle: {}",
2103 trade_account_oracle_deploy.oracle_id
2104 );
2105
2106 if should_upgrade_bytecode {
2107 let trade_account_blob =
2108 TradeAccountDeploy::trade_account_blob(&deployer_wallet, &Default::default())
2109 .await?;
2110 if ContractId::from(trade_account_blob.id) != trade_account_blob_id {
2111 tracing::info!(
2112 "Update TradeAccountImpl on Oracle from {:?} to new blob {:?}",
2113 trade_account_blob_id,
2114 ContractId::from(trade_account_blob.id)
2115 );
2116 TradeAccountDeploy::deploy_trade_account_blob(
2117 &deployer_wallet,
2118 &DeployConfig::Latest(Default::default()),
2119 )
2120 .await?;
2121 trade_account_oracle_deploy
2122 .oracle
2123 .methods()
2124 .set_trade_account_impl(ContractId::from(trade_account_blob.id))
2125 .call()
2126 .await?;
2127 trade_account_blob_id = ContractId::from(trade_account_blob.id);
2128 }
2129 }
2130
2131 Ok((trade_account_oracle_deploy, trade_account_blob_id))
2132}
2133
2134async fn deploy_trial_trade_account_oracle<W>(
2142 deployer_wallet: W,
2143 should_upgrade_bytecode: bool,
2144 trial_trade_account_oracle_id: Option<ContractId>,
2145 trial_cosigner: Option<fuels::types::Address>,
2146 salt: Salt,
2147) -> anyhow::Result<ContractId>
2148where
2149 W: Account + ViewOnlyAccount + Clone + 'static,
2150{
2151 let mut trial_deploy_config = TrialTradeAccountDeployConfig {
2152 salt,
2153 ..Default::default()
2154 };
2155 if let Some(cosigner) = trial_cosigner {
2156 trial_deploy_config = trial_deploy_config.with_cosigner(cosigner);
2157 }
2158 let deploy_config = TrialDeployConfig::Latest(trial_deploy_config);
2159
2160 let oracle_id = match trial_trade_account_oracle_id {
2161 None => {
2162 let trial_deploy =
2163 TrialTradeAccountDeploy::deploy(&deployer_wallet, &deploy_config).await?;
2164 tracing::info!(
2165 "TrialTradeAccountOracle: {} (implementation {:?}, cosigner {:?})",
2166 trial_deploy.oracle_id,
2167 trial_deploy.trial_trade_account_blob_id,
2168 trial_cosigner,
2169 );
2170 trial_deploy.oracle_id
2171 }
2172 Some(oracle_id) => {
2173 let current_trial_impl =
2174 TrialTradingAccountOracle::new(oracle_id, deployer_wallet.clone())
2175 .methods()
2176 .get_trial_account_impl()
2177 .simulate(Execution::state_read_only())
2178 .await?
2179 .value;
2180 if current_trial_impl.is_none()
2181 || should_upgrade_bytecode
2182 || trial_cosigner.is_some()
2183 {
2184 let trial_deploy = TrialTradeAccountDeploy::deploy_to_oracle(
2185 &deployer_wallet,
2186 oracle_id,
2187 &deploy_config,
2188 )
2189 .await?;
2190 tracing::info!(
2191 "Trial implementation {:?} deployed to oracle {} (cosigner {:?})",
2192 trial_deploy.trial_trade_account_blob_id,
2193 oracle_id,
2194 trial_cosigner,
2195 );
2196 }
2197 oracle_id
2198 }
2199 };
2200
2201 Ok(oracle_id)
2202}
2203
2204async fn deploy_trade_account_registry<W>(
2205 deployer_wallet: W,
2206 should_upgrade_bytecode: bool,
2207 trade_account_deploy: TradeAccountDeploy<W>,
2208 trial_trade_account_oracle_id: ContractId,
2209 trade_account_registry_id: Option<ContractId>,
2210 salt: Salt,
2211) -> anyhow::Result<(TradeAccountRegistryManager<W>, ContractId)>
2212where
2213 W: Account + ViewOnlyAccount + Clone + 'static,
2214{
2215 let trade_account_oracle_id = trade_account_deploy.oracle_id;
2216 let trade_account_registry = match trade_account_registry_id {
2217 Some(trade_account_registry_contract_id) => TradeAccountRegistryManager::new(
2218 deployer_wallet.clone(),
2219 trade_account_registry_contract_id,
2220 ),
2221 None => {
2222 let trade_account_registry_deploy_config = TradeAccountRegistryDeployConfig {
2223 salt,
2224 ..Default::default()
2225 };
2226 TradeAccountRegistryManager::deploy(
2227 &deployer_wallet,
2228 trade_account_oracle_id,
2229 trial_trade_account_oracle_id,
2230 &trade_account_registry_deploy_config,
2231 )
2232 .await?
2233 }
2234 };
2235 tracing::info!(
2236 "TradeAccountRegistry: {}",
2237 trade_account_registry.contract_id
2238 );
2239 let mut trade_account_registry_blob_id = match trade_account_registry
2240 .registry_proxy
2241 .methods()
2242 .proxy_target()
2243 .simulate(Execution::state_read_only())
2244 .await?
2245 .value
2246 {
2247 Some(blob_id) => blob_id,
2248 None => {
2249 tracing::info!("TradeAccountRegistry proxy target not set, initializing...");
2250 trade_account_registry
2254 .registry_proxy
2255 .methods()
2256 .initialize_proxy()
2257 .call()
2258 .await?;
2259 trade_account_registry
2260 .registry
2261 .methods()
2262 .initialize()
2263 .call()
2264 .await?;
2265 trade_account_registry
2266 .registry_proxy
2267 .methods()
2268 .proxy_target()
2269 .simulate(Execution::state_read_only())
2270 .await?
2271 .value
2272 .context("TradeAccountRegistry proxy target should be set after initialization")?
2273 }
2274 };
2275
2276 if should_upgrade_bytecode {
2277 let trade_account_registry_deploy_config = TradeAccountRegistryDeployConfig {
2281 registry_config: trade_account_registry
2282 .live_prop_config(TradeAccountRegistryConfigurables::default())
2283 .await?,
2284 ..Default::default()
2285 };
2286 let trade_account_proxy_blob = TradeAccountRegistryManager::register_proxy_blob(
2287 &deployer_wallet,
2288 &trade_account_registry_deploy_config,
2289 )
2290 .await?;
2291 let trial_trade_account_proxy_blob =
2292 TradeAccountRegistryManager::register_trial_proxy_blob(
2293 &deployer_wallet,
2294 &trade_account_registry_deploy_config,
2295 )
2296 .await?;
2297
2298 let trade_account_register_blob = TradeAccountRegistryManager::register_blob(
2299 &deployer_wallet,
2300 trade_account_oracle_id,
2301 trial_trade_account_oracle_id,
2302 trade_account_proxy_blob.id,
2303 trial_trade_account_proxy_blob.id,
2304 &trade_account_registry_deploy_config,
2305 )
2306 .await?;
2307
2308 if trade_account_registry_blob_id
2309 != ContractId::from(trade_account_register_blob.id)
2310 {
2311 tracing::info!(
2312 "Upgrade TradeAccountRegistry blob from {:?} to {:?}",
2313 trade_account_registry.contract_id,
2314 ContractId::from(trade_account_register_blob.id)
2315 );
2316 trade_account_registry
2320 .upgrade(
2321 trade_account_oracle_id,
2322 trial_trade_account_oracle_id,
2323 &trade_account_registry_deploy_config,
2324 )
2325 .await?;
2326 trade_account_registry_blob_id = trade_account_register_blob.id.into();
2327 }
2328 }
2329 Ok((trade_account_registry, trade_account_registry_blob_id))
2330}
2331
2332async fn deploy_order_book_registry<W>(
2333 deployer_wallet: W,
2334 should_upgrade_bytecode: bool,
2335 order_book_registry_id: Option<ContractId>,
2336 salt: Salt,
2337) -> anyhow::Result<(OrderBookRegistryManager<W>, ContractId)>
2338where
2339 W: Account + ViewOnlyAccount + Clone + 'static,
2340{
2341 let order_book_registry = match order_book_registry_id {
2342 Some(registry_contract_id) => {
2343 OrderBookRegistryManager::new(deployer_wallet.clone(), registry_contract_id)
2344 }
2345 None => {
2346 OrderBookRegistryManager::deploy(
2347 &deployer_wallet,
2348 &OrderBookRegistryDeployConfig {
2349 salt,
2350 ..Default::default()
2351 },
2352 )
2353 .await?
2354 }
2355 };
2356 tracing::info!("OrderBookRegistry: {}", order_book_registry.contract_id);
2357 let mut order_book_registry_blob_id = match order_book_registry
2358 .registry_proxy
2359 .methods()
2360 .proxy_target()
2361 .simulate(Execution::state_read_only())
2362 .await?
2363 .value
2364 {
2365 Some(blob_id) => blob_id,
2366 None => {
2367 tracing::info!("OrderBookRegistry proxy target not set, initializing...");
2368 order_book_registry
2372 .registry_proxy
2373 .methods()
2374 .initialize_proxy()
2375 .call()
2376 .await?;
2377 order_book_registry
2378 .registry
2379 .methods()
2380 .initialize()
2381 .call()
2382 .await?;
2383 order_book_registry
2384 .registry_proxy
2385 .methods()
2386 .proxy_target()
2387 .simulate(Execution::state_read_only())
2388 .await?
2389 .value
2390 .context(
2391 "OrderBookRegistry proxy target should be set after initialization",
2392 )?
2393 }
2394 };
2395
2396 if should_upgrade_bytecode {
2397 let order_book_register_deploy_config = OrderBookRegistryDeployConfig::default();
2398 let order_book_register_blob = OrderBookRegistryManager::register_blob(
2399 &deployer_wallet,
2400 &order_book_register_deploy_config,
2401 )
2402 .await?;
2403 if order_book_registry_blob_id != order_book_register_blob.id.into() {
2404 tracing::info!(
2405 "Upgrade OrderBookRegistry blob from {:?} to {:?}",
2406 order_book_registry.contract_id,
2407 ContractId::from(order_book_register_blob.id)
2408 );
2409 order_book_registry
2410 .upgrade(&order_book_register_deploy_config)
2411 .await?;
2412 order_book_registry_blob_id = order_book_register_blob.id.into();
2413 }
2414 }
2415
2416 Ok((order_book_registry, order_book_registry_blob_id))
2417}
2418
2419async fn deploy_order_books<W>(
2420 deployer_wallet: W,
2421 should_upgrade_bytecode: bool,
2422 order_book_blacklist_id: Option<ContractId>,
2423 order_book_whitelist_id: Option<ContractId>,
2424 order_book_registry: OrderBookRegistryManager<W>,
2425 order_book_configs: &mut [OrderBookConfig],
2426 ownership_options: OwnershipTransferOptions,
2427) -> anyhow::Result<Vec<OrderBookConfig>>
2428where
2429 W: Account + ViewOnlyAccount + Clone + 'static,
2430{
2431 audit_order_book_precision(order_book_configs)?;
2436
2437 let mut pairs: Vec<OrderBookConfig> = Vec::with_capacity(order_book_configs.len());
2438
2439 for order_book_config in order_book_configs.iter_mut() {
2440 let pair = deploy_single_order_book(
2441 &deployer_wallet,
2442 should_upgrade_bytecode,
2443 order_book_blacklist_id,
2444 order_book_whitelist_id,
2445 &order_book_registry,
2446 order_book_config,
2447 &ownership_options,
2448 )
2449 .await?;
2450 pairs.push(pair);
2451 }
2452
2453 Ok(pairs)
2454}
2455
2456async fn deploy_single_order_book<W>(
2457 deployer_wallet: &W,
2458 should_upgrade_bytecode: bool,
2459 order_book_blacklist_id: Option<ContractId>,
2460 order_book_whitelist_id: Option<ContractId>,
2461 order_book_registry: &OrderBookRegistryManager<W>,
2462 order_book_config: &mut OrderBookConfig,
2463 ownership_options: &OwnershipTransferOptions,
2464) -> anyhow::Result<OrderBookConfig>
2465where
2466 W: Account + ViewOnlyAccount + Clone + 'static,
2467{
2468 let market_symbol = format!(
2469 "{}/{}",
2470 order_book_config.base.symbol, order_book_config.quote.symbol
2471 );
2472 let market_id = MarketIdAssets {
2473 base_asset: order_book_config.base.asset,
2474 quote_asset: order_book_config.quote.asset,
2475 };
2476 let order_book_configurables = build_order_book_configurables(
2477 order_book_config,
2478 order_book_blacklist_id,
2479 order_book_whitelist_id,
2480 deployer_wallet,
2481 )?;
2482
2483 let order_book = load_or_deploy_order_book(
2484 deployer_wallet,
2485 order_book_registry,
2486 &market_id,
2487 &market_symbol,
2488 &order_book_configurables,
2489 order_book_config,
2490 )
2491 .await?;
2492
2493 tracing::info!(
2494 "[{}] OrderBook: {}",
2495 market_symbol,
2496 order_book.contract.contract_id()
2497 );
2498
2499 let order_book_blob_id = maybe_upgrade_order_book(
2500 deployer_wallet,
2501 should_upgrade_bytecode,
2502 &order_book,
2503 order_book_config,
2504 order_book_configurables,
2505 &market_symbol,
2506 )
2507 .await?;
2508
2509 set_order_book_maintainer(&order_book, ownership_options, &market_symbol).await?;
2513
2514 transfer_order_book_ownership(&order_book, ownership_options, &market_symbol).await?;
2515
2516 order_book_config.contract_id = Some(order_book.contract.contract_id());
2517 order_book_config.blob_id = order_book_blob_id.into();
2518
2519 Ok(order_book_config.clone())
2520}
2521
2522pub fn audit_order_book_precision(configs: &[OrderBookConfig]) -> anyhow::Result<()> {
2530 for config in configs {
2531 order_book_precision_exponents(config)?;
2532 }
2533 Ok(())
2534}
2535
2536fn order_book_precision_exponents(config: &OrderBookConfig) -> anyhow::Result<(u8, u8)> {
2556 let price_precision = config
2557 .quote
2558 .decimals
2559 .checked_sub(config.quote.max_precision)
2560 .ok_or_else(|| {
2561 anyhow::anyhow!(
2562 "quote max_precision ({}) exceeds decimals ({})",
2563 config.quote.max_precision,
2564 config.quote.decimals
2565 )
2566 })?;
2567 let quantity_precision = config
2568 .base
2569 .decimals
2570 .checked_sub(config.base.max_precision)
2571 .ok_or_else(|| {
2572 anyhow::anyhow!(
2573 "base max_precision ({}) exceeds decimals ({})",
2574 config.base.max_precision,
2575 config.base.decimals
2576 )
2577 })?;
2578
2579 let spent = config.base.max_precision as u16 + config.quote.max_precision as u16;
2582 let budget = config.quote.decimals as u16;
2583
2584 if config.allow_fractional_price {
2585 if spent > budget {
2586 tracing::warn!(
2587 "{}/{}: base max_precision ({}) + quote max_precision ({}) = {spent} \
2588 exceeds the quote's {budget} decimals; ALLOW_FRACTIONAL_PRICE is set, \
2589 so notionals will silently truncate rather than revert",
2590 config.base.symbol,
2591 config.quote.symbol,
2592 config.base.max_precision,
2593 config.quote.max_precision,
2594 );
2595 }
2596 } else {
2597 anyhow::ensure!(
2598 spent <= budget,
2599 "{}/{}: base max_precision ({}) + quote max_precision ({}) = {spent} \
2600 exceeds the quote's {budget} decimals, so every order would revert \
2601 with FractionalPrice. Lower one of the two until they sum to {budget} \
2602 or less.",
2603 config.base.symbol,
2604 config.quote.symbol,
2605 config.base.max_precision,
2606 config.quote.max_precision,
2607 );
2608 }
2609
2610 Ok((price_precision, quantity_precision))
2611}
2612
2613fn build_order_book_configurables<W: ViewOnlyAccount>(
2614 config: &OrderBookConfig,
2615 order_book_blacklist_id: Option<ContractId>,
2616 order_book_whitelist_id: Option<ContractId>,
2617 deployer_wallet: &W,
2618) -> anyhow::Result<OrderBookConfigurables> {
2619 let (price_precision, quantity_precision) = order_book_precision_exponents(config)?;
2620
2621 Ok(OrderBookConfigurables::default()
2622 .with_MIN_ORDER(config.min_order)?
2623 .with_ALLOW_FRACTIONAL_PRICE(config.allow_fractional_price)?
2624 .with_TAKER_FEE(config.taker_fee.into())?
2625 .with_MAKER_FEE(config.maker_fee.into())?
2626 .with_DUST(config.dust)?
2627 .with_PRICE_WINDOW(config.price_window as u64)?
2628 .with_BASE_DECIMALS(10u64.pow(config.base.decimals as u32))?
2629 .with_QUOTE_DECIMALS(10u64.pow(config.quote.decimals as u32))?
2630 .with_BASE_SYMBOL(SizedAsciiString::new_with_right_whitespace_padding(
2631 config.base.symbol.clone(),
2632 )?)?
2633 .with_QUOTE_SYMBOL(SizedAsciiString::new_with_right_whitespace_padding(
2634 config.quote.symbol.clone(),
2635 )?)?
2636 .with_PRICE_PRECISION(10u64.pow(price_precision as u32))?
2637 .with_QUANTITY_PRECISION(10u64.pow(quantity_precision as u32))?
2638 .with_INITIAL_OWNER(o2_tools::order_book_deploy::State::Initialized(
2639 Identity::Address(ViewOnlyAccount::address(deployer_wallet)),
2640 ))?
2641 .with_WHITE_LIST_CONTRACT(order_book_whitelist_id)?
2642 .with_BLACK_LIST_CONTRACT(order_book_blacklist_id)?)
2643}
2644
2645async fn load_or_deploy_order_book<W>(
2646 deployer_wallet: &W,
2647 order_book_registry: &OrderBookRegistryManager<W>,
2648 market_id: &MarketIdAssets,
2649 market_symbol: &str,
2650 order_book_configurables: &OrderBookConfigurables,
2651 order_book_config: &OrderBookConfig,
2652) -> anyhow::Result<OrderBookManager<W>>
2653where
2654 W: Account + ViewOnlyAccount + Clone + 'static,
2655{
2656 let register_contract_id = order_book_registry
2657 .registry
2658 .methods()
2659 .get_order_book(to_registry_market_id(market_id))
2660 .simulate(Execution::state_read_only())
2661 .await?
2662 .value;
2663
2664 match register_contract_id {
2665 Some(contract_id) => {
2666 let order_book_deploy = OrderBookDeploy::new(
2667 deployer_wallet.clone(),
2668 contract_id,
2669 market_id.base_asset,
2670 market_id.quote_asset,
2671 );
2672 let proxy_target = order_book_deploy
2675 .order_book_proxy
2676 .methods()
2677 .proxy_target()
2678 .simulate(Execution::state_read_only())
2679 .await?
2680 .value;
2681 if proxy_target.is_none() {
2682 tracing::info!(
2683 "[{}] Proxy target not set, initializing...",
2684 market_symbol
2685 );
2686 order_book_deploy.initialize().await?;
2687 }
2688 Ok(OrderBookManager::new(
2689 deployer_wallet,
2690 10u64.pow(order_book_config.base.decimals as u32),
2691 10u64.pow(order_book_config.quote.decimals as u32),
2692 &order_book_deploy,
2693 ))
2694 }
2695 None => {
2696 let (order_book_deployment, initialization_required) =
2697 OrderBookDeploy::deploy_without_initialization(
2698 deployer_wallet,
2699 market_id.base_asset,
2700 market_id.quote_asset,
2701 &OrderBookDeployConfig {
2702 order_book_configurables: order_book_configurables.clone(),
2703 salt: Salt::from(*order_book_registry.contract_id),
2704 ..Default::default()
2705 },
2706 )
2707 .await?;
2708
2709 order_book_registry
2710 .register_order_book(
2711 to_registry_market_id(market_id),
2712 order_book_deployment.contract_id,
2713 )
2714 .await?;
2715
2716 if initialization_required {
2717 order_book_deployment.initialize().await?;
2718 }
2719 Ok(OrderBookManager::new(
2720 deployer_wallet,
2721 10u64.pow(order_book_config.base.decimals as u32),
2722 10u64.pow(order_book_config.quote.decimals as u32),
2723 &order_book_deployment,
2724 ))
2725 }
2726 }
2727}
2728
2729async fn maybe_upgrade_order_book<W>(
2730 deployer_wallet: &W,
2731 should_upgrade_bytecode: bool,
2732 order_book: &OrderBookManager<W>,
2733 order_book_config: &OrderBookConfig,
2734 order_book_configurables: OrderBookConfigurables,
2735 market_symbol: &str,
2736) -> anyhow::Result<ContractId>
2737where
2738 W: Account + ViewOnlyAccount + Clone + 'static,
2739{
2740 let mut order_book_blob_id = order_book
2741 .proxy
2742 .methods()
2743 .proxy_target()
2744 .simulate(Execution::state_read_only())
2745 .await?
2746 .value
2747 .context("Order book proxy target should be set after initialization")?;
2748
2749 if should_upgrade_bytecode {
2750 let order_book_deploy_config = OrderBookDeployConfig {
2751 order_book_configurables,
2752 ..Default::default()
2753 };
2754 let order_book_deploy = OrderBookDeploy::new(
2755 deployer_wallet.clone(),
2756 order_book.contract.contract_id(),
2757 order_book_config.base.asset,
2758 order_book_config.quote.asset,
2759 );
2760 let order_book_manager = OrderBookManager::new(
2761 deployer_wallet,
2762 10u64.pow(order_book_config.base.decimals as u32),
2763 10u64.pow(order_book_config.quote.decimals as u32),
2764 &order_book_deploy,
2765 );
2766 let order_book_blob = OrderBookDeploy::order_book_blob(
2767 deployer_wallet,
2768 order_book_config.base.asset,
2769 order_book_config.quote.asset,
2770 &order_book_deploy_config,
2771 )
2772 .await?;
2773
2774 if order_book_blob_id != order_book_blob.id.into() {
2775 tracing::info!(
2776 "[{}] Upgrade OrderBook blob from {:?} to {:?}",
2777 market_symbol,
2778 order_book_blob_id,
2779 ContractId::from(order_book_blob.id)
2780 );
2781 order_book_manager
2782 .upgrade(&order_book_deploy_config)
2783 .await?;
2784 tracing::info!(
2785 "[{}] Emit new configuration event for {}",
2786 market_symbol,
2787 order_book.contract.contract_id()
2788 );
2789 order_book_manager.emit_config().await?;
2790 order_book_blob_id = order_book_blob.id.into();
2791 }
2792 }
2793
2794 Ok(order_book_blob_id)
2795}
2796
2797async fn set_order_book_maintainer<W>(
2798 order_book: &OrderBookManager<W>,
2799 ownership_options: &OwnershipTransferOptions,
2800 market_symbol: &str,
2801) -> anyhow::Result<()>
2802where
2803 W: Account + ViewOnlyAccount + Clone + 'static,
2804{
2805 for account in &ownership_options.revoke_orderbook_maintainers {
2809 tracing::info!(
2810 "[{}] Revoking ORDERBOOK_MAINTAINER_ROLE from {}",
2811 market_symbol,
2812 account
2813 );
2814 order_book
2815 .contract
2816 .methods()
2817 .owner_revoke_role(ORDERBOOK_MAINTAINER_ROLE, Identity::Address(*account))
2818 .call()
2819 .await?;
2820 }
2821
2822 for account in &ownership_options.new_orderbook_maintainers {
2823 tracing::info!(
2824 "[{}] Granting ORDERBOOK_MAINTAINER_ROLE to {}",
2825 market_symbol,
2826 account
2827 );
2828 order_book
2829 .contract
2830 .methods()
2831 .owner_grant_role(ORDERBOOK_MAINTAINER_ROLE, Identity::Address(*account))
2832 .call()
2833 .await?;
2834 }
2835
2836 Ok(())
2837}
2838
2839async fn transfer_order_book_ownership<W>(
2840 order_book: &OrderBookManager<W>,
2841 ownership_options: &OwnershipTransferOptions,
2842 market_symbol: &str,
2843) -> anyhow::Result<()>
2844where
2845 W: Account + ViewOnlyAccount + Clone + 'static,
2846{
2847 if let Some(new_owner) = ownership_options.new_proxy_owner {
2848 let new_identity = Identity::Address(new_owner);
2849 tracing::info!(
2850 "[{}] Transferring OrderBook proxy ownership to {}",
2851 market_symbol,
2852 new_owner
2853 );
2854 order_book
2855 .proxy
2856 .methods()
2857 .set_owner(new_identity)
2858 .call()
2859 .await?;
2860 }
2861
2862 if let Some(new_owner) = ownership_options.new_contract_owner {
2863 let new_identity = Identity::Address(new_owner);
2864 tracing::info!(
2865 "[{}] Transferring OrderBook contract ownership to {}",
2866 market_symbol,
2867 new_owner
2868 );
2869 order_book
2870 .contract
2871 .methods()
2872 .transfer_ownership(new_identity)
2873 .call()
2874 .await?;
2875 }
2876
2877 Ok(())
2878}
2879
2880#[cfg(test)]
2881mod tests {
2882 use super::*;
2883
2884 #[test]
2885 fn load_config_empty_path_returns_default() {
2886 let result: MarketsConfigPartial = load_config_from_file("").unwrap();
2887 assert!(result.pairs.is_empty());
2888 }
2889
2890 #[test]
2891 fn load_config_missing_file_errors() {
2892 let result: Result<MarketsConfigPartial, _> =
2893 load_config_from_file("nonexistent_file_12345.json");
2894 assert!(result.is_err());
2895 }
2896
2897 #[test]
2898 fn checked_sub_catches_overflow() {
2899 let decimals: u32 = 6;
2901 let max_precision: u32 = 8; let result = decimals.checked_sub(max_precision);
2904 assert!(
2905 result.is_none(),
2906 "should return None when max_precision > decimals"
2907 );
2908
2909 let result = 9u32.checked_sub(6);
2911 assert_eq!(result, Some(3));
2912 }
2913
2914 #[test]
2915 fn markets_config_partial_default_has_empty_pairs() {
2916 let config = MarketsConfigPartial::default();
2917 assert!(config.pairs.is_empty());
2918 }
2919
2920 const LIVE_LEGACY_TIER: &str = r#"{
2923 "tier_id": 11,
2924 "line": "2500000000000",
2925 "leverage": 40,
2926 "duration": 0,
2927 "maintenance_bps": 150,
2928 "open_buffer_bps": 151,
2929 "liq_price_factor": 9900,
2930 "prolong_fee": ["10", "18", "60", "200"],
2931 "max_credit_line_bps": 20000,
2932 "max_price_age": 300,
2933 "open_fee": "0",
2934 "profit_share_bps": 0,
2935 "price_band_bps": 85,
2936 "markets": ["fwBTC/fUSDC", "fETH/fUSDC", "fXAUT/fUSDC"]
2937 }"#;
2938
2939 fn turbo_tier(turbo: &str) -> String {
2940 format!(
2941 r#"{{
2942 "tier_id": 33,
2943 "line": "10000000000000",
2944 "leverage": 40,
2945 "duration": 0,
2946 "maintenance_bps": 100,
2947 "open_buffer_bps": 101,
2948 "liq_price_factor": 9900,
2949 "prolong_fee": ["0", "0", "0", "0"],
2950 "max_credit_line_bps": 20000,
2951 "max_price_age": 300,
2952 "open_fee": "0",
2953 "profit_share_bps": 0,
2954 "price_band_bps": 100,
2955 "markets": ["fwBTC/fUSDC"],
2956 "turbo": {turbo}
2957 }}"#
2958 )
2959 }
2960
2961 const SHIPPED_TERMS: &str = r#"{
2962 "forfeit_collateral": true,
2963 "max_loss_bps": [150, 200, 250, 300],
2964 "rollover_profit_bps": 900,
2965 "max_rollovers": 3
2966 }"#;
2967
2968 #[test]
2972 fn a_live_legacy_tier_still_parses_and_defaults_to_selling() {
2973 let tier: MarginTierConfig = serde_json::from_str(LIVE_LEGACY_TIER).unwrap();
2974 assert_eq!(tier.tier_id, 11);
2975 assert!(tier.turbo.is_none(), "no terms means the legacy cohort");
2976 assert!(tier.enabled, "a tier with no opinion sells");
2977 }
2978
2979 #[test]
2983 fn a_misspelled_tier_key_is_refused_rather_than_ignored() {
2984 let typo = LIVE_LEGACY_TIER.replace("\"leverage\"", "\"leverag\"");
2985 let err = serde_json::from_str::<MarginTierConfig>(&typo).unwrap_err();
2986 assert!(err.to_string().contains("leverag"), "{err}");
2987
2988 let typo = turbo_tier(&SHIPPED_TERMS.replace("max_loss_bps", "max_los_bps"));
2989 let err = serde_json::from_str::<MarginTierConfig>(&typo).unwrap_err();
2990 assert!(err.to_string().contains("max_los_bps"), "{err}");
2991 }
2992
2993 #[test]
2994 fn a_prepaid_tier_carries_its_terms_through_to_the_pools_shape() {
2995 let tier: MarginTierConfig =
2996 serde_json::from_str(&turbo_tier(SHIPPED_TERMS)).unwrap();
2997 let turbo = tier.turbo.as_ref().expect("the tier declares terms");
2998 let terms = o2_tools::prop::TurboTerms::from(turbo);
2999 assert!(terms.forfeit_collateral);
3000 assert_eq!(terms.max_loss_bps, [150, 200, 250, 300]);
3001 assert_eq!(terms.rollover_profit_bps, 900);
3002 assert_eq!(terms.max_rollovers, 3);
3003
3004 let params = tier_params(&tier, 9).unwrap();
3005 validate_turbo_tier_config(&tier, ¶ms, Some(&terms))
3006 .expect("the shipped shape is publishable");
3007 }
3008
3009 #[test]
3013 fn a_config_the_pool_would_refuse_is_refused_before_it_costs_a_transaction() {
3014 let check = |mutate: &dyn Fn(&mut MarginTierConfig), expect: &str| {
3015 let mut tier: MarginTierConfig =
3016 serde_json::from_str(&turbo_tier(SHIPPED_TERMS)).unwrap();
3017 mutate(&mut tier);
3018 let params = tier_params(&tier, 9).unwrap();
3019 let terms = tier.turbo.as_ref().map(o2_tools::prop::TurboTerms::from);
3020 let err = validate_turbo_tier_config(&tier, ¶ms, terms.as_ref())
3021 .expect_err("must be refused");
3022 assert!(
3023 err.to_string().contains(expect),
3024 "expected {expect:?} in: {err}"
3025 );
3026 };
3027
3028 check(&|t| t.duration = 604_800, "duration");
3029 check(&|t| t.prolong_fee[2] = "60".to_owned(), "prolong_fee");
3030 check(&|t| t.profit_share_bps = 500, "profit_share_bps");
3031 check(
3034 &|t| t.turbo.as_mut().unwrap().max_loss_bps = [0, 200, 250, 300],
3035 "step 0 is 0",
3036 );
3037 check(
3041 &|t| t.turbo.as_mut().unwrap().max_loss_bps = [150, 0, 250, 300],
3042 "step 1 is 0",
3043 );
3044 check(
3045 &|t| t.turbo.as_mut().unwrap().max_loss_bps = [150, 200, 250, 0],
3046 "step 3 is 0",
3047 );
3048 check(
3050 &|t| t.turbo.as_mut().unwrap().max_loss_bps = [150, 100, 250, 300],
3051 "NARROWER",
3052 );
3053 {
3055 let mut tier: MarginTierConfig =
3056 serde_json::from_str(&turbo_tier(SHIPPED_TERMS)).unwrap();
3057 let turbo = tier.turbo.as_mut().unwrap();
3058 turbo.max_rollovers = 1;
3059 turbo.max_loss_bps = [150, 200, 0, 0];
3060 let params = tier_params(&tier, 9).unwrap();
3061 let terms = tier.turbo.as_ref().map(o2_tools::prop::TurboTerms::from);
3062 validate_turbo_tier_config(&tier, ¶ms, terms.as_ref())
3063 .expect("only reachable steps are policed");
3064 }
3065 check(
3067 &|t| t.turbo.as_mut().unwrap().rollover_profit_bps = 0,
3068 "rollover_profit_bps` must not be 0",
3069 );
3070 check(
3072 &|t| {
3073 let turbo = t.turbo.as_mut().unwrap();
3074 turbo.max_rollovers = 0;
3075 },
3076 "max_rollovers",
3077 );
3078 check(
3079 &|t| t.turbo.as_mut().unwrap().max_loss_bps = [150, 200, 250, 10_001],
3080 "<= 10000",
3081 );
3082 }
3083
3084 #[test]
3087 fn a_legacy_tier_is_not_held_to_the_prepaid_invariants() {
3088 let tier: MarginTierConfig = serde_json::from_str(LIVE_LEGACY_TIER).unwrap();
3089 let params = tier_params(&tier, 9).unwrap();
3090 validate_turbo_tier_config(&tier, ¶ms, None).unwrap();
3091 }
3092}
3093
3094#[cfg(test)]
3095mod precision_budget_tests {
3096 use super::*;
3097
3098 fn pair(base_max: u8, quote_max: u8) -> OrderBookConfig {
3101 let asset = |symbol: &str, max_precision: u8| AssetConfig {
3102 symbol: symbol.to_string(),
3103 asset: Default::default(),
3104 decimals: 9,
3105 min_precision: 0,
3106 max_precision,
3107 };
3108 OrderBookConfig {
3109 contract_id: None,
3110 blob_id: None,
3111 market_id: Default::default(),
3112 taker_fee: 100,
3113 maker_fee: 0,
3114 min_order: 1_000_000_000,
3115 dust: 1_000,
3116 price_window: 0,
3117 allow_fractional_price: false,
3118 base: asset("TKN", base_max),
3119 quote: asset("USDC", quote_max),
3120 }
3121 }
3122
3123 #[test]
3124 fn a_pair_that_spends_its_whole_budget_yields_the_contract_exponents() {
3125 let (price, quantity) = order_book_precision_exponents(&pair(2, 7)).unwrap();
3127 assert_eq!(price, 2, "PRICE_PRECISION = 10^(9-7)");
3128 assert_eq!(quantity, 7, "QUANTITY_PRECISION = 10^(9-2)");
3129 }
3130
3131 #[test]
3132 fn one_digit_over_budget_is_refused() {
3133 let err = order_book_precision_exponents(&pair(4, 6))
3135 .unwrap_err()
3136 .to_string();
3137 assert!(err.contains("FractionalPrice"), "{err}");
3138 assert!(err.contains("TKN/USDC"), "{err}");
3139 assert!(err.contains("= 10"), "{err}");
3140 }
3141
3142 #[test]
3143 fn fractional_prices_downgrade_the_breach_to_a_warning() {
3144 let mut config = pair(4, 6);
3147 config.allow_fractional_price = true;
3148 assert!(order_book_precision_exponents(&config).is_ok());
3149 }
3150
3151 #[test]
3152 fn max_precision_beyond_the_asset_decimals_is_still_refused() {
3153 let err = order_book_precision_exponents(&pair(0, 10))
3154 .unwrap_err()
3155 .to_string();
3156 assert!(err.contains("exceeds decimals"), "{err}");
3157 }
3158
3159 #[test]
3160 fn the_budget_tracks_the_quote_decimals_not_a_hardcoded_nine() {
3161 let mut config = pair(4, 2);
3162 config.quote.decimals = 6;
3163 assert!(
3164 order_book_precision_exponents(&config).is_ok(),
3165 "4 + 2 fits a 6-decimal quote"
3166 );
3167
3168 config.quote.max_precision = 3;
3169 assert!(
3170 order_book_precision_exponents(&config).is_err(),
3171 "4 + 3 does not"
3172 );
3173 }
3174}
3175
3176#[cfg(test)]
3177mod fee_amount_tests {
3178 use super::collateral_amount;
3179
3180 fn at9(value: &str) -> anyhow::Result<u64> {
3181 collateral_amount(value, 9, "tier 1 open_fee")
3182 }
3183
3184 #[test]
3185 fn whole_and_fractional_units_scale_by_the_asset_decimals() {
3186 assert_eq!(at9("7.5").unwrap(), 7_500_000_000);
3187 assert_eq!(at9("7").unwrap(), 7_000_000_000);
3188 assert_eq!(at9("0").unwrap(), 0);
3189 assert_eq!(at9("0.000000001").unwrap(), 1);
3190 assert_eq!(at9("13.5").unwrap(), 13_500_000_000);
3191 assert_eq!(at9("7.1").unwrap(), 7_100_000_000);
3193 }
3194
3195 #[test]
3196 fn a_figure_the_asset_cannot_represent_is_refused_not_rounded() {
3197 let err = at9("7.0000000001").unwrap_err().to_string();
3198 assert!(err.contains("10 decimal places"), "{err}");
3199 assert!(err.contains("tier 1 open_fee"), "{err}");
3200 }
3201
3202 #[test]
3203 fn junk_is_refused() {
3204 for bad in ["", " ", "-1", "1.2.3", "abc", "1e9", ".5", "1_000"] {
3205 assert!(at9(bad).is_err(), "`{bad}` should not parse");
3206 }
3207 }
3208
3209 #[test]
3210 fn an_amount_past_u64_is_refused_rather_than_wrapping() {
3211 assert!(at9("18446744074").is_err());
3212 assert_eq!(at9("18446744073.709551615").unwrap(), u64::MAX);
3213 }
3214
3215 #[test]
3216 fn other_decimal_scales_are_honoured() {
3217 assert_eq!(collateral_amount("7.5", 6, "f").unwrap(), 7_500_000);
3218 assert_eq!(collateral_amount("7.5", 1, "f").unwrap(), 75);
3219 assert!(collateral_amount("7.5", 0, "f").is_err());
3220 assert_eq!(collateral_amount("7", 0, "f").unwrap(), 7);
3221 }
3222}