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 || params.duration == u64::MAX,
1614 "tier {id} is prepaid, so `duration` must be 0 for a weekly term \
1615 or u64::MAX for a perpetual term"
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!(
1630 params.max_credit_line_bps > 10_000,
1631 "tier {id} is prepaid, so `max_credit_line_bps` must be > 10000 - \
1632 the cap must not truncate the line the session opens with"
1633 );
1634 anyhow::ensure!(
1635 terms.rollover_profit_bps <= 10_000,
1636 "tier {id} `rollover_profit_bps` must be <= 10000"
1637 );
1638 anyhow::ensure!(
1639 terms.max_loss_bps.iter().all(|bps| *bps <= 10_000),
1640 "tier {id} every `max_loss_bps` entry must be <= 10000 - a rate past \
1641 BPS would put the floor below zero for any line"
1642 );
1643 if terms.rollover_profit_bps != 0 {
1644 anyhow::ensure!(
1645 terms.max_rollovers != 0,
1646 "tier {id} offers a rollover, so `max_rollovers` must not be 0 - \
1647 an enabled feature must be a real one"
1648 );
1649 }
1650 let last_step = terms.max_rollovers.min(3) as usize;
1654 for step in 0..=last_step {
1655 anyhow::ensure!(
1656 terms.max_loss_bps[step] != 0,
1657 "tier {id} sells a drawdown escalator whose step {step} is 0, \
1658 which floors that term at the session's line - a \
1659 session reaching it is liquidatable immediately"
1660 );
1661 anyhow::ensure!(
1662 step == 0 || terms.max_loss_bps[step] >= terms.max_loss_bps[step - 1],
1663 "tier {id} drawdown step {step} is NARROWER than the step \
1664 before it - the schedule is sold as an escalator and must \
1665 never take back room a previous term already granted"
1666 );
1667 }
1668 let line = u128::from(params.line);
1671 let bps_up = |bps: u64| (line * u128::from(bps)).div_ceil(10_000);
1672 let allowance = line * u128::from(terms.max_loss_bps[0]) / 10_000;
1673 let gap =
1674 bps_up(params.open_buffer_bps).saturating_sub(bps_up(params.maintenance_bps));
1675 anyhow::ensure!(
1676 gap <= allowance,
1677 "tier {id} first drawdown step ({} bps) is narrower than the \
1678 tier's own `open_buffer_bps - maintenance_bps` gap, so every \
1679 session it sells opens already FROZEN and can never trade",
1680 terms.max_loss_bps[0]
1681 );
1682 Ok(())
1683}
1684
1685fn collateral_amount(value: &str, decimals: u8, field: &str) -> anyhow::Result<u64> {
1694 let raw = value.trim();
1695 anyhow::ensure!(!raw.is_empty(), "{field}: empty amount");
1696 anyhow::ensure!(
1697 !raw.starts_with('-'),
1698 "{field}: `{raw}` is negative; fees are unsigned"
1699 );
1700 let (whole, fraction) = match raw.split_once('.') {
1701 Some((whole, fraction)) => (whole, fraction),
1702 None => (raw, ""),
1703 };
1704 anyhow::ensure!(
1705 !fraction.contains('.'),
1706 "{field}: `{raw}` has more than one decimal point"
1707 );
1708 anyhow::ensure!(
1709 !whole.is_empty() && whole.bytes().all(|b| b.is_ascii_digit()),
1710 "{field}: `{raw}` is not a decimal amount"
1711 );
1712 anyhow::ensure!(
1713 fraction.bytes().all(|b| b.is_ascii_digit()),
1714 "{field}: `{raw}` is not a decimal amount"
1715 );
1716 let decimals = usize::from(decimals);
1717 anyhow::ensure!(
1718 fraction.len() <= decimals,
1719 "{field}: `{raw}` has {} decimal places, but the collateral asset has \
1720 only {decimals}; the chain cannot represent it",
1721 fraction.len()
1722 );
1723 let digits = format!("{whole}{fraction}{}", "0".repeat(decimals - fraction.len()));
1724 digits
1725 .parse::<u64>()
1726 .map_err(|e| anyhow::anyhow!("{field}: `{raw}` does not fit a u64: {e}"))
1727}
1728
1729fn tier_params(
1730 tier: &MarginTierConfig,
1731 collateral_decimals: u8,
1732) -> anyhow::Result<o2_tools::prop::TierParams> {
1733 let fee = |value: &str, what: &str| {
1734 collateral_amount(
1735 value,
1736 collateral_decimals,
1737 &format!("tier {} {what}", tier.tier_id),
1738 )
1739 };
1740 Ok(o2_tools::prop::TierParams {
1741 line: tier.line,
1742 leverage: tier.leverage,
1743 duration: tier.duration,
1744 maintenance_bps: tier.maintenance_bps,
1745 open_buffer_bps: tier.open_buffer_bps,
1746 liq_price_factor: tier.liq_price_factor,
1747 prolong_fee: [
1748 fee(&tier.prolong_fee[0], "prolong_fee[0]")?,
1749 fee(&tier.prolong_fee[1], "prolong_fee[1]")?,
1750 fee(&tier.prolong_fee[2], "prolong_fee[2]")?,
1751 fee(&tier.prolong_fee[3], "prolong_fee[3]")?,
1752 ],
1753 max_credit_line_bps: tier.max_credit_line_bps,
1754 max_price_age: tier.max_price_age,
1755 open_fee: fee(&tier.open_fee, "open_fee")?,
1756 profit_share_bps: tier.profit_share_bps,
1757 price_band_bps: tier.price_band_bps,
1758 })
1759}
1760
1761fn resolve_tier_market(
1764 market: &str,
1765 pairs: &[OrderBookConfig],
1766) -> anyhow::Result<ContractId> {
1767 let wanted = market.trim();
1768 let wanted_id = wanted
1769 .strip_prefix("0x")
1770 .unwrap_or(wanted)
1771 .to_ascii_lowercase();
1772 let pair = pairs
1773 .iter()
1774 .find(|pair| {
1775 let symbol = format!("{}/{}", pair.base.symbol, pair.quote.symbol);
1776 symbol.eq_ignore_ascii_case(wanted)
1777 || hex::encode(*pair.market_id) == wanted_id
1778 })
1779 .with_context(|| format!("margin tier references unknown market `{market}`"))?;
1780 pair.contract_id.with_context(|| {
1781 format!("margin tier market `{market}` has no deployed order book")
1782 })
1783}
1784
1785fn parse_margin_identity(s: &str) -> anyhow::Result<Identity> {
1787 if let Some(hex_part) = s.strip_prefix("address:") {
1788 Ok(Identity::Address(fuels::types::Address::new(
1789 parse_margin_bytes32(hex_part)?,
1790 )))
1791 } else if let Some(hex_part) = s.strip_prefix("contract:") {
1792 Ok(Identity::ContractId(ContractId::new(parse_margin_bytes32(
1793 hex_part,
1794 )?)))
1795 } else {
1796 anyhow::bail!("expected `address:0x..` or `contract:0x..`, got `{s}`")
1797 }
1798}
1799
1800fn parse_margin_bytes32(s: &str) -> anyhow::Result<[u8; 32]> {
1801 let raw = s.trim().strip_prefix("0x").unwrap_or(s.trim());
1802 let bytes = hex::decode(raw)
1803 .map_err(|e| anyhow::anyhow!("expected 32 hex bytes, got `{s}`: {e}"))?;
1804 bytes
1805 .try_into()
1806 .map_err(|_| anyhow::anyhow!("expected 32 hex bytes, got `{s}`"))
1807}
1808
1809#[allow(clippy::too_many_arguments)]
1814async fn transfer_ownership<W>(
1815 wallet: &W,
1816 params: &DeployParams,
1817 order_book_registry: &OrderBookRegistryManager<W>,
1818 trade_account_registry: &TradeAccountRegistryManager<W>,
1819 trade_account_oracle_deploy: &TradeAccountDeploy<W>,
1820 trial_trade_account_oracle_id: ContractId,
1821 order_book_blacklist_id: Option<ContractId>,
1822 order_book_whitelist_id: Option<ContractId>,
1823) -> anyhow::Result<()>
1824where
1825 W: Account + ViewOnlyAccount + Clone + 'static,
1826{
1827 if let Some(new_proxy_owner) = params.new_proxy_owner {
1828 let new_identity = Identity::Address(new_proxy_owner);
1829 tracing::info!(
1830 "Transferring OrderBookRegistry proxy ownership to {}",
1831 new_proxy_owner
1832 );
1833 order_book_registry
1834 .registry_proxy
1835 .methods()
1836 .set_owner(new_identity)
1837 .call()
1838 .await?;
1839 tracing::info!(
1840 "Transferring TradeAccountRegistry proxy ownership to {}",
1841 new_proxy_owner
1842 );
1843 trade_account_registry
1844 .registry_proxy
1845 .methods()
1846 .set_owner(new_identity)
1847 .call()
1848 .await?;
1849 }
1850
1851 if let Some(new_contract_owner) = params.new_contract_owner {
1852 let new_identity = Identity::Address(new_contract_owner);
1853 tracing::info!(
1854 "Transferring TradeAccountOracle ownership to {}",
1855 new_contract_owner
1856 );
1857 trade_account_oracle_deploy
1858 .oracle
1859 .methods()
1860 .transfer_ownership(new_identity)
1861 .call()
1862 .await?;
1863 tracing::info!(
1864 "Transferring TrialTradeAccountOracle ownership to {}",
1865 new_contract_owner
1866 );
1867 TrialTradingAccountOracle::new(trial_trade_account_oracle_id, wallet.clone())
1868 .methods()
1869 .transfer_ownership(new_identity)
1870 .call()
1871 .await?;
1872 tracing::info!(
1873 "Transferring TradeAccountRegistry ownership to {}",
1874 new_contract_owner
1875 );
1876 trade_account_registry
1877 .registry
1878 .methods()
1879 .transfer_ownership(new_identity)
1880 .call()
1881 .await?;
1882 tracing::info!(
1883 "Transferring OrderBookRegistry ownership to {}",
1884 new_contract_owner
1885 );
1886 order_book_registry
1887 .registry
1888 .methods()
1889 .transfer_ownership(new_identity)
1890 .call()
1891 .await?;
1892 if let Some(blacklist_id) = order_book_blacklist_id {
1893 tracing::info!(
1894 "Transferring OrderBookBlacklist ownership to {}",
1895 new_contract_owner
1896 );
1897 OrderBookBlacklist::new(blacklist_id, wallet.clone())
1898 .methods()
1899 .transfer_ownership(new_identity)
1900 .call()
1901 .await?;
1902 }
1903 if let Some(whitelist_id) = order_book_whitelist_id {
1904 tracing::info!(
1905 "Transferring OrderBookWhitelist ownership to {}",
1906 new_contract_owner
1907 );
1908 OrderBookWhitelist::new(whitelist_id, wallet.clone())
1909 .methods()
1910 .transfer_ownership(new_identity)
1911 .call()
1912 .await?;
1913 }
1914 }
1915
1916 Ok(())
1917}
1918
1919async fn deploy_order_book_blacklist<W>(
1924 deployer_wallet: W,
1925 deploy_blacklist: bool,
1926 order_book_blacklist_id: Option<ContractId>,
1927 salt: Salt,
1928) -> anyhow::Result<Option<ContractId>>
1929where
1930 W: Account + ViewOnlyAccount + Clone + 'static,
1931{
1932 match order_book_blacklist_id {
1933 Some(order_book_blacklist_id) => {
1934 tracing::info!(
1935 "Using existing OrderBookBlacklist: {}",
1936 order_book_blacklist_id
1937 );
1938 Ok(Some(order_book_blacklist_id))
1939 }
1940 None => {
1941 if !deploy_blacklist {
1942 return Ok(None);
1943 }
1944 tracing::info!("Deploying OrderBookBlacklist");
1945 let order_book_blacklist = OrderBookDeploy::deploy_order_book_blacklist(
1946 &deployer_wallet,
1947 &Identity::Address(ViewOnlyAccount::address(&deployer_wallet)),
1948 &OrderBookDeployConfig {
1949 salt,
1950 ..Default::default()
1951 },
1952 )
1953 .await?;
1954 tracing::info!("OrderBookBlacklist: {}", order_book_blacklist.contract_id());
1955 Ok(Some(order_book_blacklist.contract_id()))
1956 }
1957 }
1958}
1959
1960async fn deploy_order_book_whitelist<W>(
1961 deployer_wallet: W,
1962 deploy_whitelist: bool,
1963 order_book_whitelist_id: Option<ContractId>,
1964 salt: Salt,
1965) -> anyhow::Result<Option<ContractId>>
1966where
1967 W: Account + ViewOnlyAccount + Clone + 'static,
1968{
1969 match (order_book_whitelist_id, deploy_whitelist) {
1970 (Some(order_book_whitelist_id), false)
1971 | (Some(order_book_whitelist_id), true) => {
1972 tracing::info!(
1973 "Using existing OrderBookWhitelist: {}",
1974 order_book_whitelist_id
1975 );
1976 Ok(Some(order_book_whitelist_id))
1977 }
1978 (None, false) => Ok(None),
1979 (None, true) => {
1980 tracing::info!("Deploying OrderBookWhitelist");
1981 let trade_account_whitelist = OrderBookDeploy::deploy_order_book_whitelist(
1982 &deployer_wallet,
1983 &Identity::Address(ViewOnlyAccount::address(&deployer_wallet)),
1984 &OrderBookDeployConfig {
1985 salt,
1986 ..Default::default()
1987 },
1988 )
1989 .await?;
1990 tracing::info!(
1991 "OrderBookWhitelist: {}",
1992 trade_account_whitelist.contract_id()
1993 );
1994 Ok(Some(trade_account_whitelist.contract_id()))
1995 }
1996 }
1997}
1998
1999async fn load_or_recover_trade_account_oracle<W>(
2003 deployer_wallet: &W,
2004 oracle_id: ContractId,
2005) -> anyhow::Result<(TradeAccountDeploy<W>, ContractId)>
2006where
2007 W: Account + ViewOnlyAccount + Clone + 'static,
2008{
2009 let oracle = TradingAccountOracle::new(oracle_id, deployer_wallet.clone());
2010 let impl_id = oracle
2011 .methods()
2012 .get_trade_account_impl()
2013 .simulate(Execution::state_read_only())
2014 .await?
2015 .value;
2016
2017 let blob_id = match impl_id {
2018 Some(id) => id,
2019 None => {
2020 tracing::info!(
2021 "Trade account implementation not set on oracle {}, deploying...",
2022 oracle_id
2023 );
2024 let blob = TradeAccountDeploy::trade_account_blob(
2025 deployer_wallet,
2026 &Default::default(),
2027 )
2028 .await?;
2029 TradeAccountDeploy::deploy_trade_account_blob(
2030 deployer_wallet,
2031 &DeployConfig::Latest(Default::default()),
2032 )
2033 .await?;
2034 oracle
2035 .methods()
2036 .set_trade_account_impl(ContractId::from(blob.id))
2037 .call()
2038 .await?;
2039 ContractId::from(blob.id)
2040 }
2041 };
2042
2043 let deploy = TradeAccountDeploy {
2044 oracle,
2045 oracle_id,
2046 trade_account_blob_id: blob_id.into(),
2047 deployer_wallet: deployer_wallet.clone(),
2048 proxy: None,
2049 proxy_id: None,
2050 };
2051 Ok((deploy, blob_id))
2052}
2053
2054async fn deploy_trade_account_oracle<W>(
2055 deployer_wallet: W,
2056 should_upgrade_bytecode: bool,
2057 trade_account_oracle_id: Option<ContractId>,
2058 salt: Salt,
2059) -> anyhow::Result<(TradeAccountDeploy<W>, ContractId)>
2060where
2061 W: Account + ViewOnlyAccount + Clone + 'static,
2062{
2063 let (trade_account_oracle_deploy, mut trade_account_blob_id) =
2064 match trade_account_oracle_id {
2065 Some(oracle_id) => {
2066 load_or_recover_trade_account_oracle(&deployer_wallet, oracle_id).await?
2067 }
2068 None => {
2069 let deploy = TradeAccountDeploy::deploy(
2070 &deployer_wallet,
2071 &DeployConfig::Latest(TradeAccountDeployConfig {
2072 salt,
2073 ..Default::default()
2074 }),
2075 )
2076 .await?;
2077 let blob_id = deploy
2078 .oracle
2079 .methods()
2080 .get_trade_account_impl()
2081 .simulate(Execution::state_read_only())
2082 .await?
2083 .value
2084 .context("Trade account impl should exist after fresh deploy")?;
2085 (deploy, blob_id)
2086 }
2087 };
2088 tracing::info!(
2089 "TradeAccountOracle: {}",
2090 trade_account_oracle_deploy.oracle_id
2091 );
2092
2093 if should_upgrade_bytecode {
2094 let trade_account_blob =
2095 TradeAccountDeploy::trade_account_blob(&deployer_wallet, &Default::default())
2096 .await?;
2097 if ContractId::from(trade_account_blob.id) != trade_account_blob_id {
2098 tracing::info!(
2099 "Update TradeAccountImpl on Oracle from {:?} to new blob {:?}",
2100 trade_account_blob_id,
2101 ContractId::from(trade_account_blob.id)
2102 );
2103 TradeAccountDeploy::deploy_trade_account_blob(
2104 &deployer_wallet,
2105 &DeployConfig::Latest(Default::default()),
2106 )
2107 .await?;
2108 trade_account_oracle_deploy
2109 .oracle
2110 .methods()
2111 .set_trade_account_impl(ContractId::from(trade_account_blob.id))
2112 .call()
2113 .await?;
2114 trade_account_blob_id = ContractId::from(trade_account_blob.id);
2115 }
2116 }
2117
2118 Ok((trade_account_oracle_deploy, trade_account_blob_id))
2119}
2120
2121async fn deploy_trial_trade_account_oracle<W>(
2129 deployer_wallet: W,
2130 should_upgrade_bytecode: bool,
2131 trial_trade_account_oracle_id: Option<ContractId>,
2132 trial_cosigner: Option<fuels::types::Address>,
2133 salt: Salt,
2134) -> anyhow::Result<ContractId>
2135where
2136 W: Account + ViewOnlyAccount + Clone + 'static,
2137{
2138 let mut trial_deploy_config = TrialTradeAccountDeployConfig {
2139 salt,
2140 ..Default::default()
2141 };
2142 if let Some(cosigner) = trial_cosigner {
2143 trial_deploy_config = trial_deploy_config.with_cosigner(cosigner);
2144 }
2145 let deploy_config = TrialDeployConfig::Latest(trial_deploy_config);
2146
2147 let oracle_id = match trial_trade_account_oracle_id {
2148 None => {
2149 let trial_deploy =
2150 TrialTradeAccountDeploy::deploy(&deployer_wallet, &deploy_config).await?;
2151 tracing::info!(
2152 "TrialTradeAccountOracle: {} (implementation {:?}, cosigner {:?})",
2153 trial_deploy.oracle_id,
2154 trial_deploy.trial_trade_account_blob_id,
2155 trial_cosigner,
2156 );
2157 trial_deploy.oracle_id
2158 }
2159 Some(oracle_id) => {
2160 let current_trial_impl =
2161 TrialTradingAccountOracle::new(oracle_id, deployer_wallet.clone())
2162 .methods()
2163 .get_trial_account_impl()
2164 .simulate(Execution::state_read_only())
2165 .await?
2166 .value;
2167 if current_trial_impl.is_none()
2168 || should_upgrade_bytecode
2169 || trial_cosigner.is_some()
2170 {
2171 let trial_deploy = TrialTradeAccountDeploy::deploy_to_oracle(
2172 &deployer_wallet,
2173 oracle_id,
2174 &deploy_config,
2175 )
2176 .await?;
2177 tracing::info!(
2178 "Trial implementation {:?} deployed to oracle {} (cosigner {:?})",
2179 trial_deploy.trial_trade_account_blob_id,
2180 oracle_id,
2181 trial_cosigner,
2182 );
2183 }
2184 oracle_id
2185 }
2186 };
2187
2188 Ok(oracle_id)
2189}
2190
2191async fn deploy_trade_account_registry<W>(
2192 deployer_wallet: W,
2193 should_upgrade_bytecode: bool,
2194 trade_account_deploy: TradeAccountDeploy<W>,
2195 trial_trade_account_oracle_id: ContractId,
2196 trade_account_registry_id: Option<ContractId>,
2197 salt: Salt,
2198) -> anyhow::Result<(TradeAccountRegistryManager<W>, ContractId)>
2199where
2200 W: Account + ViewOnlyAccount + Clone + 'static,
2201{
2202 let trade_account_oracle_id = trade_account_deploy.oracle_id;
2203 let trade_account_registry = match trade_account_registry_id {
2204 Some(trade_account_registry_contract_id) => TradeAccountRegistryManager::new(
2205 deployer_wallet.clone(),
2206 trade_account_registry_contract_id,
2207 ),
2208 None => {
2209 let trade_account_registry_deploy_config = TradeAccountRegistryDeployConfig {
2210 salt,
2211 ..Default::default()
2212 };
2213 TradeAccountRegistryManager::deploy(
2214 &deployer_wallet,
2215 trade_account_oracle_id,
2216 trial_trade_account_oracle_id,
2217 &trade_account_registry_deploy_config,
2218 )
2219 .await?
2220 }
2221 };
2222 tracing::info!(
2223 "TradeAccountRegistry: {}",
2224 trade_account_registry.contract_id
2225 );
2226 let mut trade_account_registry_blob_id = match trade_account_registry
2227 .registry_proxy
2228 .methods()
2229 .proxy_target()
2230 .simulate(Execution::state_read_only())
2231 .await?
2232 .value
2233 {
2234 Some(blob_id) => blob_id,
2235 None => {
2236 tracing::info!("TradeAccountRegistry proxy target not set, initializing...");
2237 trade_account_registry
2241 .registry_proxy
2242 .methods()
2243 .initialize_proxy()
2244 .call()
2245 .await?;
2246 trade_account_registry
2247 .registry
2248 .methods()
2249 .initialize()
2250 .call()
2251 .await?;
2252 trade_account_registry
2253 .registry_proxy
2254 .methods()
2255 .proxy_target()
2256 .simulate(Execution::state_read_only())
2257 .await?
2258 .value
2259 .context("TradeAccountRegistry proxy target should be set after initialization")?
2260 }
2261 };
2262
2263 if should_upgrade_bytecode {
2264 let trade_account_registry_deploy_config = TradeAccountRegistryDeployConfig {
2268 registry_config: trade_account_registry
2269 .live_prop_config(TradeAccountRegistryConfigurables::default())
2270 .await?,
2271 ..Default::default()
2272 };
2273 let trade_account_proxy_blob = TradeAccountRegistryManager::register_proxy_blob(
2274 &deployer_wallet,
2275 &trade_account_registry_deploy_config,
2276 )
2277 .await?;
2278 let trial_trade_account_proxy_blob =
2279 TradeAccountRegistryManager::register_trial_proxy_blob(
2280 &deployer_wallet,
2281 &trade_account_registry_deploy_config,
2282 )
2283 .await?;
2284
2285 let trade_account_register_blob = TradeAccountRegistryManager::register_blob(
2286 &deployer_wallet,
2287 trade_account_oracle_id,
2288 trial_trade_account_oracle_id,
2289 trade_account_proxy_blob.id,
2290 trial_trade_account_proxy_blob.id,
2291 &trade_account_registry_deploy_config,
2292 )
2293 .await?;
2294
2295 if trade_account_registry_blob_id
2296 != ContractId::from(trade_account_register_blob.id)
2297 {
2298 tracing::info!(
2299 "Upgrade TradeAccountRegistry blob from {:?} to {:?}",
2300 trade_account_registry.contract_id,
2301 ContractId::from(trade_account_register_blob.id)
2302 );
2303 trade_account_registry
2307 .upgrade(
2308 trade_account_oracle_id,
2309 trial_trade_account_oracle_id,
2310 &trade_account_registry_deploy_config,
2311 )
2312 .await?;
2313 trade_account_registry_blob_id = trade_account_register_blob.id.into();
2314 }
2315 }
2316 Ok((trade_account_registry, trade_account_registry_blob_id))
2317}
2318
2319async fn deploy_order_book_registry<W>(
2320 deployer_wallet: W,
2321 should_upgrade_bytecode: bool,
2322 order_book_registry_id: Option<ContractId>,
2323 salt: Salt,
2324) -> anyhow::Result<(OrderBookRegistryManager<W>, ContractId)>
2325where
2326 W: Account + ViewOnlyAccount + Clone + 'static,
2327{
2328 let order_book_registry = match order_book_registry_id {
2329 Some(registry_contract_id) => {
2330 OrderBookRegistryManager::new(deployer_wallet.clone(), registry_contract_id)
2331 }
2332 None => {
2333 OrderBookRegistryManager::deploy(
2334 &deployer_wallet,
2335 &OrderBookRegistryDeployConfig {
2336 salt,
2337 ..Default::default()
2338 },
2339 )
2340 .await?
2341 }
2342 };
2343 tracing::info!("OrderBookRegistry: {}", order_book_registry.contract_id);
2344 let mut order_book_registry_blob_id = match order_book_registry
2345 .registry_proxy
2346 .methods()
2347 .proxy_target()
2348 .simulate(Execution::state_read_only())
2349 .await?
2350 .value
2351 {
2352 Some(blob_id) => blob_id,
2353 None => {
2354 tracing::info!("OrderBookRegistry proxy target not set, initializing...");
2355 order_book_registry
2359 .registry_proxy
2360 .methods()
2361 .initialize_proxy()
2362 .call()
2363 .await?;
2364 order_book_registry
2365 .registry
2366 .methods()
2367 .initialize()
2368 .call()
2369 .await?;
2370 order_book_registry
2371 .registry_proxy
2372 .methods()
2373 .proxy_target()
2374 .simulate(Execution::state_read_only())
2375 .await?
2376 .value
2377 .context(
2378 "OrderBookRegistry proxy target should be set after initialization",
2379 )?
2380 }
2381 };
2382
2383 if should_upgrade_bytecode {
2384 let order_book_register_deploy_config = OrderBookRegistryDeployConfig::default();
2385 let order_book_register_blob = OrderBookRegistryManager::register_blob(
2386 &deployer_wallet,
2387 &order_book_register_deploy_config,
2388 )
2389 .await?;
2390 if order_book_registry_blob_id != order_book_register_blob.id.into() {
2391 tracing::info!(
2392 "Upgrade OrderBookRegistry blob from {:?} to {:?}",
2393 order_book_registry.contract_id,
2394 ContractId::from(order_book_register_blob.id)
2395 );
2396 order_book_registry
2397 .upgrade(&order_book_register_deploy_config)
2398 .await?;
2399 order_book_registry_blob_id = order_book_register_blob.id.into();
2400 }
2401 }
2402
2403 Ok((order_book_registry, order_book_registry_blob_id))
2404}
2405
2406async fn deploy_order_books<W>(
2407 deployer_wallet: W,
2408 should_upgrade_bytecode: bool,
2409 order_book_blacklist_id: Option<ContractId>,
2410 order_book_whitelist_id: Option<ContractId>,
2411 order_book_registry: OrderBookRegistryManager<W>,
2412 order_book_configs: &mut [OrderBookConfig],
2413 ownership_options: OwnershipTransferOptions,
2414) -> anyhow::Result<Vec<OrderBookConfig>>
2415where
2416 W: Account + ViewOnlyAccount + Clone + 'static,
2417{
2418 audit_order_book_precision(order_book_configs)?;
2423
2424 let mut pairs: Vec<OrderBookConfig> = Vec::with_capacity(order_book_configs.len());
2425
2426 for order_book_config in order_book_configs.iter_mut() {
2427 let pair = deploy_single_order_book(
2428 &deployer_wallet,
2429 should_upgrade_bytecode,
2430 order_book_blacklist_id,
2431 order_book_whitelist_id,
2432 &order_book_registry,
2433 order_book_config,
2434 &ownership_options,
2435 )
2436 .await?;
2437 pairs.push(pair);
2438 }
2439
2440 Ok(pairs)
2441}
2442
2443async fn deploy_single_order_book<W>(
2444 deployer_wallet: &W,
2445 should_upgrade_bytecode: bool,
2446 order_book_blacklist_id: Option<ContractId>,
2447 order_book_whitelist_id: Option<ContractId>,
2448 order_book_registry: &OrderBookRegistryManager<W>,
2449 order_book_config: &mut OrderBookConfig,
2450 ownership_options: &OwnershipTransferOptions,
2451) -> anyhow::Result<OrderBookConfig>
2452where
2453 W: Account + ViewOnlyAccount + Clone + 'static,
2454{
2455 let market_symbol = format!(
2456 "{}/{}",
2457 order_book_config.base.symbol, order_book_config.quote.symbol
2458 );
2459 let market_id = MarketIdAssets {
2460 base_asset: order_book_config.base.asset,
2461 quote_asset: order_book_config.quote.asset,
2462 };
2463 let order_book_configurables = build_order_book_configurables(
2464 order_book_config,
2465 order_book_blacklist_id,
2466 order_book_whitelist_id,
2467 deployer_wallet,
2468 )?;
2469
2470 let order_book = load_or_deploy_order_book(
2471 deployer_wallet,
2472 order_book_registry,
2473 &market_id,
2474 &market_symbol,
2475 &order_book_configurables,
2476 order_book_config,
2477 )
2478 .await?;
2479
2480 tracing::info!(
2481 "[{}] OrderBook: {}",
2482 market_symbol,
2483 order_book.contract.contract_id()
2484 );
2485
2486 let order_book_blob_id = maybe_upgrade_order_book(
2487 deployer_wallet,
2488 should_upgrade_bytecode,
2489 &order_book,
2490 order_book_config,
2491 order_book_configurables,
2492 &market_symbol,
2493 )
2494 .await?;
2495
2496 set_order_book_maintainer(&order_book, ownership_options, &market_symbol).await?;
2500
2501 transfer_order_book_ownership(&order_book, ownership_options, &market_symbol).await?;
2502
2503 order_book_config.contract_id = Some(order_book.contract.contract_id());
2504 order_book_config.blob_id = order_book_blob_id.into();
2505
2506 Ok(order_book_config.clone())
2507}
2508
2509pub fn audit_order_book_precision(configs: &[OrderBookConfig]) -> anyhow::Result<()> {
2517 for config in configs {
2518 order_book_precision_exponents(config)?;
2519 }
2520 Ok(())
2521}
2522
2523fn order_book_precision_exponents(config: &OrderBookConfig) -> anyhow::Result<(u8, u8)> {
2543 let price_precision = config
2544 .quote
2545 .decimals
2546 .checked_sub(config.quote.max_precision)
2547 .ok_or_else(|| {
2548 anyhow::anyhow!(
2549 "quote max_precision ({}) exceeds decimals ({})",
2550 config.quote.max_precision,
2551 config.quote.decimals
2552 )
2553 })?;
2554 let quantity_precision = config
2555 .base
2556 .decimals
2557 .checked_sub(config.base.max_precision)
2558 .ok_or_else(|| {
2559 anyhow::anyhow!(
2560 "base max_precision ({}) exceeds decimals ({})",
2561 config.base.max_precision,
2562 config.base.decimals
2563 )
2564 })?;
2565
2566 let spent = config.base.max_precision as u16 + config.quote.max_precision as u16;
2569 let budget = config.quote.decimals as u16;
2570
2571 if config.allow_fractional_price {
2572 if spent > budget {
2573 tracing::warn!(
2574 "{}/{}: base max_precision ({}) + quote max_precision ({}) = {spent} \
2575 exceeds the quote's {budget} decimals; ALLOW_FRACTIONAL_PRICE is set, \
2576 so notionals will silently truncate rather than revert",
2577 config.base.symbol,
2578 config.quote.symbol,
2579 config.base.max_precision,
2580 config.quote.max_precision,
2581 );
2582 }
2583 } else {
2584 anyhow::ensure!(
2585 spent <= budget,
2586 "{}/{}: base max_precision ({}) + quote max_precision ({}) = {spent} \
2587 exceeds the quote's {budget} decimals, so every order would revert \
2588 with FractionalPrice. Lower one of the two until they sum to {budget} \
2589 or less.",
2590 config.base.symbol,
2591 config.quote.symbol,
2592 config.base.max_precision,
2593 config.quote.max_precision,
2594 );
2595 }
2596
2597 Ok((price_precision, quantity_precision))
2598}
2599
2600fn build_order_book_configurables<W: ViewOnlyAccount>(
2601 config: &OrderBookConfig,
2602 order_book_blacklist_id: Option<ContractId>,
2603 order_book_whitelist_id: Option<ContractId>,
2604 deployer_wallet: &W,
2605) -> anyhow::Result<OrderBookConfigurables> {
2606 let (price_precision, quantity_precision) = order_book_precision_exponents(config)?;
2607
2608 Ok(OrderBookConfigurables::default()
2609 .with_MIN_ORDER(config.min_order)?
2610 .with_ALLOW_FRACTIONAL_PRICE(config.allow_fractional_price)?
2611 .with_TAKER_FEE(config.taker_fee.into())?
2612 .with_MAKER_FEE(config.maker_fee.into())?
2613 .with_DUST(config.dust)?
2614 .with_PRICE_WINDOW(config.price_window as u64)?
2615 .with_BASE_DECIMALS(10u64.pow(config.base.decimals as u32))?
2616 .with_QUOTE_DECIMALS(10u64.pow(config.quote.decimals as u32))?
2617 .with_BASE_SYMBOL(SizedAsciiString::new_with_right_whitespace_padding(
2618 config.base.symbol.clone(),
2619 )?)?
2620 .with_QUOTE_SYMBOL(SizedAsciiString::new_with_right_whitespace_padding(
2621 config.quote.symbol.clone(),
2622 )?)?
2623 .with_PRICE_PRECISION(10u64.pow(price_precision as u32))?
2624 .with_QUANTITY_PRECISION(10u64.pow(quantity_precision as u32))?
2625 .with_INITIAL_OWNER(o2_tools::order_book_deploy::State::Initialized(
2626 Identity::Address(ViewOnlyAccount::address(deployer_wallet)),
2627 ))?
2628 .with_WHITE_LIST_CONTRACT(order_book_whitelist_id)?
2629 .with_BLACK_LIST_CONTRACT(order_book_blacklist_id)?)
2630}
2631
2632async fn load_or_deploy_order_book<W>(
2633 deployer_wallet: &W,
2634 order_book_registry: &OrderBookRegistryManager<W>,
2635 market_id: &MarketIdAssets,
2636 market_symbol: &str,
2637 order_book_configurables: &OrderBookConfigurables,
2638 order_book_config: &OrderBookConfig,
2639) -> anyhow::Result<OrderBookManager<W>>
2640where
2641 W: Account + ViewOnlyAccount + Clone + 'static,
2642{
2643 let register_contract_id = order_book_registry
2644 .registry
2645 .methods()
2646 .get_order_book(to_registry_market_id(market_id))
2647 .simulate(Execution::state_read_only())
2648 .await?
2649 .value;
2650
2651 match register_contract_id {
2652 Some(contract_id) => {
2653 let order_book_deploy = OrderBookDeploy::new(
2654 deployer_wallet.clone(),
2655 contract_id,
2656 market_id.base_asset,
2657 market_id.quote_asset,
2658 );
2659 let proxy_target = order_book_deploy
2662 .order_book_proxy
2663 .methods()
2664 .proxy_target()
2665 .simulate(Execution::state_read_only())
2666 .await?
2667 .value;
2668 if proxy_target.is_none() {
2669 tracing::info!(
2670 "[{}] Proxy target not set, initializing...",
2671 market_symbol
2672 );
2673 order_book_deploy.initialize().await?;
2674 }
2675 Ok(OrderBookManager::new(
2676 deployer_wallet,
2677 10u64.pow(order_book_config.base.decimals as u32),
2678 10u64.pow(order_book_config.quote.decimals as u32),
2679 &order_book_deploy,
2680 ))
2681 }
2682 None => {
2683 let (order_book_deployment, initialization_required) =
2684 OrderBookDeploy::deploy_without_initialization(
2685 deployer_wallet,
2686 market_id.base_asset,
2687 market_id.quote_asset,
2688 &OrderBookDeployConfig {
2689 order_book_configurables: order_book_configurables.clone(),
2690 salt: Salt::from(*order_book_registry.contract_id),
2691 ..Default::default()
2692 },
2693 )
2694 .await?;
2695
2696 order_book_registry
2697 .register_order_book(
2698 to_registry_market_id(market_id),
2699 order_book_deployment.contract_id,
2700 )
2701 .await?;
2702
2703 if initialization_required {
2704 order_book_deployment.initialize().await?;
2705 }
2706 Ok(OrderBookManager::new(
2707 deployer_wallet,
2708 10u64.pow(order_book_config.base.decimals as u32),
2709 10u64.pow(order_book_config.quote.decimals as u32),
2710 &order_book_deployment,
2711 ))
2712 }
2713 }
2714}
2715
2716async fn maybe_upgrade_order_book<W>(
2717 deployer_wallet: &W,
2718 should_upgrade_bytecode: bool,
2719 order_book: &OrderBookManager<W>,
2720 order_book_config: &OrderBookConfig,
2721 order_book_configurables: OrderBookConfigurables,
2722 market_symbol: &str,
2723) -> anyhow::Result<ContractId>
2724where
2725 W: Account + ViewOnlyAccount + Clone + 'static,
2726{
2727 let mut order_book_blob_id = order_book
2728 .proxy
2729 .methods()
2730 .proxy_target()
2731 .simulate(Execution::state_read_only())
2732 .await?
2733 .value
2734 .context("Order book proxy target should be set after initialization")?;
2735
2736 if should_upgrade_bytecode {
2737 let order_book_deploy_config = OrderBookDeployConfig {
2738 order_book_configurables,
2739 ..Default::default()
2740 };
2741 let order_book_deploy = OrderBookDeploy::new(
2742 deployer_wallet.clone(),
2743 order_book.contract.contract_id(),
2744 order_book_config.base.asset,
2745 order_book_config.quote.asset,
2746 );
2747 let order_book_manager = OrderBookManager::new(
2748 deployer_wallet,
2749 10u64.pow(order_book_config.base.decimals as u32),
2750 10u64.pow(order_book_config.quote.decimals as u32),
2751 &order_book_deploy,
2752 );
2753 let order_book_blob = OrderBookDeploy::order_book_blob(
2754 deployer_wallet,
2755 order_book_config.base.asset,
2756 order_book_config.quote.asset,
2757 &order_book_deploy_config,
2758 )
2759 .await?;
2760
2761 if order_book_blob_id != order_book_blob.id.into() {
2762 tracing::info!(
2763 "[{}] Upgrade OrderBook blob from {:?} to {:?}",
2764 market_symbol,
2765 order_book_blob_id,
2766 ContractId::from(order_book_blob.id)
2767 );
2768 order_book_manager
2769 .upgrade(&order_book_deploy_config)
2770 .await?;
2771 tracing::info!(
2772 "[{}] Emit new configuration event for {}",
2773 market_symbol,
2774 order_book.contract.contract_id()
2775 );
2776 order_book_manager.emit_config().await?;
2777 order_book_blob_id = order_book_blob.id.into();
2778 }
2779 }
2780
2781 Ok(order_book_blob_id)
2782}
2783
2784async fn set_order_book_maintainer<W>(
2785 order_book: &OrderBookManager<W>,
2786 ownership_options: &OwnershipTransferOptions,
2787 market_symbol: &str,
2788) -> anyhow::Result<()>
2789where
2790 W: Account + ViewOnlyAccount + Clone + 'static,
2791{
2792 for account in &ownership_options.revoke_orderbook_maintainers {
2796 tracing::info!(
2797 "[{}] Revoking ORDERBOOK_MAINTAINER_ROLE from {}",
2798 market_symbol,
2799 account
2800 );
2801 order_book
2802 .contract
2803 .methods()
2804 .owner_revoke_role(ORDERBOOK_MAINTAINER_ROLE, Identity::Address(*account))
2805 .call()
2806 .await?;
2807 }
2808
2809 for account in &ownership_options.new_orderbook_maintainers {
2810 tracing::info!(
2811 "[{}] Granting ORDERBOOK_MAINTAINER_ROLE to {}",
2812 market_symbol,
2813 account
2814 );
2815 order_book
2816 .contract
2817 .methods()
2818 .owner_grant_role(ORDERBOOK_MAINTAINER_ROLE, Identity::Address(*account))
2819 .call()
2820 .await?;
2821 }
2822
2823 Ok(())
2824}
2825
2826async fn transfer_order_book_ownership<W>(
2827 order_book: &OrderBookManager<W>,
2828 ownership_options: &OwnershipTransferOptions,
2829 market_symbol: &str,
2830) -> anyhow::Result<()>
2831where
2832 W: Account + ViewOnlyAccount + Clone + 'static,
2833{
2834 if let Some(new_owner) = ownership_options.new_proxy_owner {
2835 let new_identity = Identity::Address(new_owner);
2836 tracing::info!(
2837 "[{}] Transferring OrderBook proxy ownership to {}",
2838 market_symbol,
2839 new_owner
2840 );
2841 order_book
2842 .proxy
2843 .methods()
2844 .set_owner(new_identity)
2845 .call()
2846 .await?;
2847 }
2848
2849 if let Some(new_owner) = ownership_options.new_contract_owner {
2850 let new_identity = Identity::Address(new_owner);
2851 tracing::info!(
2852 "[{}] Transferring OrderBook contract ownership to {}",
2853 market_symbol,
2854 new_owner
2855 );
2856 order_book
2857 .contract
2858 .methods()
2859 .transfer_ownership(new_identity)
2860 .call()
2861 .await?;
2862 }
2863
2864 Ok(())
2865}
2866
2867#[cfg(test)]
2868mod tests {
2869 use super::*;
2870
2871 #[test]
2872 fn load_config_empty_path_returns_default() {
2873 let result: MarketsConfigPartial = load_config_from_file("").unwrap();
2874 assert!(result.pairs.is_empty());
2875 }
2876
2877 #[test]
2878 fn load_config_missing_file_errors() {
2879 let result: Result<MarketsConfigPartial, _> =
2880 load_config_from_file("nonexistent_file_12345.json");
2881 assert!(result.is_err());
2882 }
2883
2884 #[test]
2885 fn checked_sub_catches_overflow() {
2886 let decimals: u32 = 6;
2888 let max_precision: u32 = 8; let result = decimals.checked_sub(max_precision);
2891 assert!(
2892 result.is_none(),
2893 "should return None when max_precision > decimals"
2894 );
2895
2896 let result = 9u32.checked_sub(6);
2898 assert_eq!(result, Some(3));
2899 }
2900
2901 #[test]
2902 fn markets_config_partial_default_has_empty_pairs() {
2903 let config = MarketsConfigPartial::default();
2904 assert!(config.pairs.is_empty());
2905 }
2906
2907 const LIVE_LEGACY_TIER: &str = r#"{
2910 "tier_id": 11,
2911 "line": "2500000000000",
2912 "leverage": 40,
2913 "duration": 0,
2914 "maintenance_bps": 150,
2915 "open_buffer_bps": 151,
2916 "liq_price_factor": 9900,
2917 "prolong_fee": ["10", "18", "60", "200"],
2918 "max_credit_line_bps": 20000,
2919 "max_price_age": 300,
2920 "open_fee": "0",
2921 "profit_share_bps": 0,
2922 "price_band_bps": 85,
2923 "markets": ["fwBTC/fUSDC", "fETH/fUSDC", "fXAUT/fUSDC"]
2924 }"#;
2925
2926 fn turbo_tier(turbo: &str) -> String {
2927 format!(
2928 r#"{{
2929 "tier_id": 33,
2930 "line": "10000000000000",
2931 "leverage": 40,
2932 "duration": 0,
2933 "maintenance_bps": 100,
2934 "open_buffer_bps": 101,
2935 "liq_price_factor": 9900,
2936 "prolong_fee": ["0", "0", "0", "0"],
2937 "max_credit_line_bps": 20000,
2938 "max_price_age": 300,
2939 "open_fee": "0",
2940 "profit_share_bps": 0,
2941 "price_band_bps": 100,
2942 "markets": ["fwBTC/fUSDC"],
2943 "turbo": {turbo}
2944 }}"#
2945 )
2946 }
2947
2948 const SHIPPED_TERMS: &str = r#"{
2949 "max_loss_bps": [150, 200, 250, 300],
2950 "rollover_profit_bps": 900,
2951 "max_rollovers": 3
2952 }"#;
2953
2954 #[test]
2958 fn a_live_legacy_tier_still_parses_and_defaults_to_selling() {
2959 let tier: MarginTierConfig = serde_json::from_str(LIVE_LEGACY_TIER).unwrap();
2960 assert_eq!(tier.tier_id, 11);
2961 assert!(tier.turbo.is_none(), "no terms means the legacy cohort");
2962 assert!(tier.enabled, "a tier with no opinion sells");
2963 }
2964
2965 #[test]
2969 fn a_misspelled_tier_key_is_refused_rather_than_ignored() {
2970 let typo = LIVE_LEGACY_TIER.replace("\"leverage\"", "\"leverag\"");
2971 let err = serde_json::from_str::<MarginTierConfig>(&typo).unwrap_err();
2972 assert!(err.to_string().contains("leverag"), "{err}");
2973
2974 let typo = turbo_tier(&SHIPPED_TERMS.replace("max_loss_bps", "max_los_bps"));
2975 let err = serde_json::from_str::<MarginTierConfig>(&typo).unwrap_err();
2976 assert!(err.to_string().contains("max_los_bps"), "{err}");
2977 }
2978
2979 #[test]
2980 fn a_prepaid_tier_carries_its_terms_through_to_the_pools_shape() {
2981 let tier: MarginTierConfig =
2982 serde_json::from_str(&turbo_tier(SHIPPED_TERMS)).unwrap();
2983 let turbo = tier.turbo.as_ref().expect("the tier declares terms");
2984 let terms = o2_tools::prop::TurboTerms::from(turbo);
2985 assert_eq!(terms.max_loss_bps, [150, 200, 250, 300]);
2986 assert_eq!(terms.rollover_profit_bps, 900);
2987 assert_eq!(terms.max_rollovers, 3);
2988
2989 let params = tier_params(&tier, 9).unwrap();
2990 validate_turbo_tier_config(&tier, ¶ms, Some(&terms))
2991 .expect("the shipped shape is publishable");
2992 }
2993
2994 #[test]
2995 fn prepaid_durations_parse_exactly_and_allow_valid_rollover_terms() {
2996 for (duration, json_duration) in [(0, "0"), (u64::MAX, "18446744073709551615")] {
2997 let json = turbo_tier(SHIPPED_TERMS)
2998 .replace("\"duration\": 0", &format!("\"duration\": {json_duration}"));
2999 let tier: MarginTierConfig = serde_json::from_str(&json).unwrap();
3000 assert_eq!(tier.duration, duration);
3001 let params = tier_params(&tier, 9).unwrap();
3002 assert_eq!(params.duration, duration);
3003 let terms = o2_tools::prop::TurboTerms::from(tier.turbo.as_ref().unwrap());
3004 validate_turbo_tier_config(&tier, ¶ms, Some(&terms))
3005 .expect("valid rollover terms are allowed even when expiry is perpetual");
3006 }
3007 }
3008
3009 #[test]
3010 fn a_three_percent_drawdown_needs_no_rollover() {
3011 let json = turbo_tier(
3012 r#"{
3013 "max_loss_bps": [300, 0, 0, 0],
3014 "rollover_profit_bps": 0,
3015 "max_rollovers": 0
3016 }"#,
3017 );
3018 for duration in [0, u64::MAX] {
3019 let mut tier: MarginTierConfig = serde_json::from_str(&json).unwrap();
3020 tier.duration = duration;
3021 let params = tier_params(&tier, 9).unwrap();
3022 let terms = o2_tools::prop::TurboTerms::from(tier.turbo.as_ref().unwrap());
3023 validate_turbo_tier_config(&tier, ¶ms, Some(&terms))
3024 .expect("the line-based floor needs no rollover state");
3025 }
3026 }
3027
3028 #[test]
3032 fn a_config_the_pool_would_refuse_is_refused_before_it_costs_a_transaction() {
3033 let check = |mutate: &dyn Fn(&mut MarginTierConfig), expect: &str| {
3034 let mut tier: MarginTierConfig =
3035 serde_json::from_str(&turbo_tier(SHIPPED_TERMS)).unwrap();
3036 mutate(&mut tier);
3037 let params = tier_params(&tier, 9).unwrap();
3038 let terms = tier.turbo.as_ref().map(o2_tools::prop::TurboTerms::from);
3039 let err = validate_turbo_tier_config(&tier, ¶ms, terms.as_ref())
3040 .expect_err("must be refused");
3041 assert!(
3042 err.to_string().contains(expect),
3043 "expected {expect:?} in: {err}"
3044 );
3045 };
3046
3047 for duration in [1, 604_800, u64::MAX - 1] {
3048 check(&|t| t.duration = duration, "duration");
3049 }
3050 check(&|t| t.prolong_fee[2] = "60".to_owned(), "prolong_fee");
3051 check(&|t| t.profit_share_bps = 500, "profit_share_bps");
3052 for cap in [9_999, 10_000] {
3053 check(&|t| t.max_credit_line_bps = cap, "max_credit_line_bps");
3054 }
3055 check(&|t| t.open_buffer_bps = 251, "FROZEN");
3056 check(
3059 &|t| t.turbo.as_mut().unwrap().max_loss_bps = [0, 200, 250, 300],
3060 "step 0 is 0",
3061 );
3062 check(
3063 &|t| t.turbo.as_mut().unwrap().max_loss_bps = [0; 4],
3064 "step 0 is 0",
3065 );
3066 check(
3070 &|t| t.turbo.as_mut().unwrap().max_loss_bps = [150, 0, 250, 300],
3071 "step 1 is 0",
3072 );
3073 check(
3074 &|t| t.turbo.as_mut().unwrap().max_loss_bps = [150, 200, 250, 0],
3075 "step 3 is 0",
3076 );
3077 check(
3079 &|t| t.turbo.as_mut().unwrap().max_loss_bps = [150, 100, 250, 300],
3080 "NARROWER",
3081 );
3082 {
3084 let mut tier: MarginTierConfig =
3085 serde_json::from_str(&turbo_tier(SHIPPED_TERMS)).unwrap();
3086 let turbo = tier.turbo.as_mut().unwrap();
3087 turbo.max_rollovers = 1;
3088 turbo.max_loss_bps = [150, 200, 0, 0];
3089 let params = tier_params(&tier, 9).unwrap();
3090 let terms = tier.turbo.as_ref().map(o2_tools::prop::TurboTerms::from);
3091 validate_turbo_tier_config(&tier, ¶ms, terms.as_ref())
3092 .expect("only reachable steps are policed");
3093 }
3094 check(
3096 &|t| {
3097 let turbo = t.turbo.as_mut().unwrap();
3098 turbo.max_rollovers = 0;
3099 },
3100 "max_rollovers",
3101 );
3102 check(
3103 &|t| t.turbo.as_mut().unwrap().max_loss_bps = [150, 200, 250, 10_001],
3104 "<= 10000",
3105 );
3106 check(
3107 &|t| t.turbo.as_mut().unwrap().rollover_profit_bps = 10_001,
3108 "rollover_profit_bps",
3109 );
3110 }
3111
3112 #[test]
3115 fn a_legacy_tier_is_not_held_to_the_prepaid_invariants() {
3116 let tier: MarginTierConfig = serde_json::from_str(LIVE_LEGACY_TIER).unwrap();
3117 let params = tier_params(&tier, 9).unwrap();
3118 validate_turbo_tier_config(&tier, ¶ms, None).unwrap();
3119 }
3120}
3121
3122#[cfg(test)]
3123mod precision_budget_tests {
3124 use super::*;
3125
3126 fn pair(base_max: u8, quote_max: u8) -> OrderBookConfig {
3129 let asset = |symbol: &str, max_precision: u8| AssetConfig {
3130 symbol: symbol.to_string(),
3131 asset: Default::default(),
3132 decimals: 9,
3133 min_precision: 0,
3134 max_precision,
3135 };
3136 OrderBookConfig {
3137 contract_id: None,
3138 blob_id: None,
3139 market_id: Default::default(),
3140 taker_fee: 100,
3141 maker_fee: 0,
3142 min_order: 1_000_000_000,
3143 dust: 1_000,
3144 price_window: 0,
3145 allow_fractional_price: false,
3146 base: asset("TKN", base_max),
3147 quote: asset("USDC", quote_max),
3148 }
3149 }
3150
3151 #[test]
3152 fn a_pair_that_spends_its_whole_budget_yields_the_contract_exponents() {
3153 let (price, quantity) = order_book_precision_exponents(&pair(2, 7)).unwrap();
3155 assert_eq!(price, 2, "PRICE_PRECISION = 10^(9-7)");
3156 assert_eq!(quantity, 7, "QUANTITY_PRECISION = 10^(9-2)");
3157 }
3158
3159 #[test]
3160 fn one_digit_over_budget_is_refused() {
3161 let err = order_book_precision_exponents(&pair(4, 6))
3163 .unwrap_err()
3164 .to_string();
3165 assert!(err.contains("FractionalPrice"), "{err}");
3166 assert!(err.contains("TKN/USDC"), "{err}");
3167 assert!(err.contains("= 10"), "{err}");
3168 }
3169
3170 #[test]
3171 fn fractional_prices_downgrade_the_breach_to_a_warning() {
3172 let mut config = pair(4, 6);
3175 config.allow_fractional_price = true;
3176 assert!(order_book_precision_exponents(&config).is_ok());
3177 }
3178
3179 #[test]
3180 fn max_precision_beyond_the_asset_decimals_is_still_refused() {
3181 let err = order_book_precision_exponents(&pair(0, 10))
3182 .unwrap_err()
3183 .to_string();
3184 assert!(err.contains("exceeds decimals"), "{err}");
3185 }
3186
3187 #[test]
3188 fn the_budget_tracks_the_quote_decimals_not_a_hardcoded_nine() {
3189 let mut config = pair(4, 2);
3190 config.quote.decimals = 6;
3191 assert!(
3192 order_book_precision_exponents(&config).is_ok(),
3193 "4 + 2 fits a 6-decimal quote"
3194 );
3195
3196 config.quote.max_precision = 3;
3197 assert!(
3198 order_book_precision_exponents(&config).is_err(),
3199 "4 + 3 does not"
3200 );
3201 }
3202}
3203
3204#[cfg(test)]
3205mod fee_amount_tests {
3206 use super::collateral_amount;
3207
3208 fn at9(value: &str) -> anyhow::Result<u64> {
3209 collateral_amount(value, 9, "tier 1 open_fee")
3210 }
3211
3212 #[test]
3213 fn whole_and_fractional_units_scale_by_the_asset_decimals() {
3214 assert_eq!(at9("7.5").unwrap(), 7_500_000_000);
3215 assert_eq!(at9("7").unwrap(), 7_000_000_000);
3216 assert_eq!(at9("0").unwrap(), 0);
3217 assert_eq!(at9("0.000000001").unwrap(), 1);
3218 assert_eq!(at9("13.5").unwrap(), 13_500_000_000);
3219 assert_eq!(at9("7.1").unwrap(), 7_100_000_000);
3221 }
3222
3223 #[test]
3224 fn a_figure_the_asset_cannot_represent_is_refused_not_rounded() {
3225 let err = at9("7.0000000001").unwrap_err().to_string();
3226 assert!(err.contains("10 decimal places"), "{err}");
3227 assert!(err.contains("tier 1 open_fee"), "{err}");
3228 }
3229
3230 #[test]
3231 fn junk_is_refused() {
3232 for bad in ["", " ", "-1", "1.2.3", "abc", "1e9", ".5", "1_000"] {
3233 assert!(at9(bad).is_err(), "`{bad}` should not parse");
3234 }
3235 }
3236
3237 #[test]
3238 fn an_amount_past_u64_is_refused_rather_than_wrapping() {
3239 assert!(at9("18446744074").is_err());
3240 assert_eq!(at9("18446744073.709551615").unwrap(), u64::MAX);
3241 }
3242
3243 #[test]
3244 fn other_decimal_scales_are_honoured() {
3245 assert_eq!(collateral_amount("7.5", 6, "f").unwrap(), 7_500_000);
3246 assert_eq!(collateral_amount("7.5", 1, "f").unwrap(), 75);
3247 assert!(collateral_amount("7.5", 0, "f").is_err());
3248 assert_eq!(collateral_amount("7", 0, "f").unwrap(), 7);
3249 }
3250}