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