1mod sidecar;
7#[cfg(test)]
8mod tier_book_tests;
9pub use sidecar::activate_sidecars;
10
11use anyhow::Context;
12use fuel_core_client::client::types::primitives::{
13 ContractId,
14 Salt,
15};
16use fuel_core_types::fuel_types::BlockHeight;
17use fuels::{
18 accounts::{
19 Account,
20 ViewOnlyAccount,
21 },
22 prelude::Execution,
23 types::{
24 Identity,
25 SizedAsciiString,
26 },
27};
28use o2_api_types::{
29 domain::book::{
30 AssetConfig,
31 MarketIdAssets,
32 OrderBookConfig,
33 SidecarConfig,
34 },
35 parse::HexDisplayFromStr,
36};
37use o2_tools::{
38 order_book::OrderBookManager,
39 order_book_deploy::{
40 OrderBookBlacklist,
41 OrderBookConfigurables,
42 OrderBookDeploy,
43 OrderBookDeployConfig,
44 OrderBookWhitelist,
45 },
46 order_book_registry::{
47 OrderBookRegistryDeployConfig,
48 OrderBookRegistryManager,
49 },
50 trade_account_deploy::{
51 DeployConfig,
52 TradeAccountDeploy,
53 TradeAccountDeployConfig,
54 TradingAccountOracle,
55 },
56 trade_account_registry::{
57 TradeAccountRegistryConfigurables,
58 TradeAccountRegistryDeployConfig,
59 TradeAccountRegistryManager,
60 },
61 trial_trade_account_deploy::{
62 DeployConfig as TrialDeployConfig,
63 TrialTradeAccountDeploy,
64 TrialTradeAccountDeployConfig,
65 TrialTradingAccountOracle,
66 },
67};
68use serde_with::serde_as;
69use std::ops::{
70 Deref,
71 DerefMut,
72};
73
74pub use o2_tools::prop_deploy::{
75 ExistingRegistry,
76 PropDeployConfig,
77 PropDeployment,
78};
79
80const ORDERBOOK_MAINTAINER_ROLE: u64 = 1;
85
86const DISCOUNT_MANAGER_ROLE: u64 = 1;
93
94pub async fn deploy_prop_system<W>(
100 wallet: &W,
101 config: &PropDeployConfig,
102) -> anyhow::Result<PropDeployment<W>>
103where
104 W: Account + Clone,
105{
106 PropDeployment::deploy(wallet, config).await
107}
108
109fn to_registry_market_id(m: &MarketIdAssets) -> o2_tools::order_book_registry::MarketId {
110 o2_tools::order_book_registry::MarketId {
111 base_asset: m.base_asset,
112 quote_asset: m.quote_asset,
113 }
114}
115
116#[serde_as]
121#[derive(Debug, serde::Serialize, Clone, Default)]
122pub struct MarketsConfigOutput {
123 pub starting_height: u32,
124 #[serde_as(as = "HexDisplayFromStr")]
125 pub trade_account_registry_id: ContractId,
126 #[serde_as(as = "HexDisplayFromStr")]
127 pub trade_account_registry_blob_id: ContractId,
128 #[serde_as(as = "HexDisplayFromStr")]
129 pub trade_account_oracle_id: ContractId,
130 #[serde_as(as = "HexDisplayFromStr")]
131 pub trial_trade_account_oracle_id: ContractId,
132 #[serde_as(as = "HexDisplayFromStr")]
133 pub trade_account_root: ContractId,
134 #[serde_as(as = "HexDisplayFromStr")]
135 pub trade_account_proxy: ContractId,
136 #[serde_as(as = "HexDisplayFromStr")]
137 pub trade_account_blob_id: ContractId,
138 #[serde_as(as = "Option<HexDisplayFromStr>")]
139 pub order_book_whitelist_id: Option<ContractId>,
140 #[serde_as(as = "Option<HexDisplayFromStr>")]
141 pub order_book_blacklist_id: Option<ContractId>,
142 #[serde_as(as = "HexDisplayFromStr")]
143 pub order_book_registry_id: ContractId,
144 #[serde_as(as = "HexDisplayFromStr")]
145 pub order_book_registry_blob_id: ContractId,
146 #[serde_as(as = "Option<HexDisplayFromStr>")]
147 pub fast_bridge_asset_registry_proxy_id: Option<ContractId>,
148 #[serde_as(as = "Option<HexDisplayFromStr>")]
149 pub price_feed_id: Option<ContractId>,
150 #[serde_as(as = "Option<HexDisplayFromStr>")]
151 pub margin_pool_id: Option<ContractId>,
152 #[serde_as(as = "Option<HexDisplayFromStr>")]
153 pub margin_oracle_id: Option<ContractId>,
154 #[serde(skip_serializing_if = "Option::is_none")]
161 pub margin: Option<MarginConfig>,
162 pub pairs: Vec<OrderBookConfig>,
163}
164
165#[serde_as]
167#[derive(Debug, Clone, serde::Deserialize)]
168struct OrderBookConfigDeHelper {
169 #[serde_as(as = "Option<serde_with::DisplayFromStr>")]
170 blob_id: Option<ContractId>,
171 #[serde_as(as = "Option<serde_with::DisplayFromStr>")]
172 contract_id: Option<ContractId>,
173 #[serde_as(as = "serde_with::DisplayFromStr")]
174 taker_fee: u64,
175 #[serde_as(as = "serde_with::DisplayFromStr")]
176 maker_fee: u64,
177 #[serde_as(as = "serde_with::DisplayFromStr")]
178 min_order: u64,
179 #[serde_as(as = "serde_with::DisplayFromStr")]
180 dust: u64,
181 price_window: u8,
182 #[serde(default)]
183 allow_fractional_price: bool,
184 base: AssetConfig,
185 quote: AssetConfig,
186 #[serde(default)]
187 house_identities: Vec<String>,
188 #[serde(default)]
189 sidecar: Option<SidecarConfig>,
190}
191
192impl From<OrderBookConfigDeHelper> for OrderBookConfig {
193 fn from(h: OrderBookConfigDeHelper) -> Self {
194 let ids = MarketIdAssets {
195 base_asset: h.base.asset,
196 quote_asset: h.quote.asset,
197 };
198 let market_id = ids.market_id();
199 OrderBookConfig {
200 contract_id: h.contract_id,
201 blob_id: h.blob_id,
202 market_id,
203 taker_fee: h.taker_fee,
204 maker_fee: h.maker_fee,
205 min_order: h.min_order,
206 dust: h.dust,
207 price_window: h.price_window,
208 allow_fractional_price: h.allow_fractional_price,
209 base: h.base,
210 quote: h.quote,
211 house_identities: h.house_identities,
212 sidecar: h.sidecar,
213 }
214 }
215}
216
217#[derive(Debug, Clone, Default, serde::Serialize)]
218pub struct MarketsConfigPartial {
219 pub starting_height: u32,
220 pub trade_account_registry_id: Option<ContractId>,
221 pub order_book_registry_id: Option<ContractId>,
222 pub trade_account_oracle_id: Option<ContractId>,
223 pub trial_trade_account_oracle_id: Option<ContractId>,
224 pub order_book_whitelist_id: Option<ContractId>,
225 pub order_book_blacklist_id: Option<ContractId>,
226 pub fast_bridge_asset_registry_proxy_id: Option<ContractId>,
227 pub pairs: Vec<OrderBookConfig>,
228 pub margin: Option<MarginConfig>,
232}
233
234impl<'de> serde::Deserialize<'de> for MarketsConfigPartial {
235 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
236 where
237 D: serde::Deserializer<'de>,
238 {
239 #[derive(serde::Deserialize, Default)]
240 struct Helper {
241 #[serde(default)]
242 starting_height: u32,
243 trade_account_registry_id: Option<ContractId>,
244 order_book_registry_id: Option<ContractId>,
245 trade_account_oracle_id: Option<ContractId>,
246 trial_trade_account_oracle_id: Option<ContractId>,
247 order_book_whitelist_id: Option<ContractId>,
248 order_book_blacklist_id: Option<ContractId>,
249 fast_bridge_asset_registry_proxy_id: Option<ContractId>,
250 #[serde(default)]
251 pairs: Vec<OrderBookConfigDeHelper>,
252 #[serde(default)]
253 margin: Option<MarginConfig>,
254 }
255 let h = Helper::deserialize(deserializer)?;
256 if let Some(margin) = &h.margin
257 && margin.price_feed_id.is_some()
258 {
259 return Err(serde::de::Error::custom(
260 "`margin.price_feed_id` moved into `margin.price_feed.id`. \
261 Refusing to load a config that uses the retired spelling: it \
262 would be ignored, a fresh feed would be derived, and the pool \
263 would be repointed at an oracle with no publisher. Move the id \
264 (and `publishers` / `supported_assets`) under \
265 `margin.price_feed`.",
266 ));
267 }
268 Ok(MarketsConfigPartial {
269 starting_height: h.starting_height,
270 trade_account_registry_id: h.trade_account_registry_id,
271 order_book_registry_id: h.order_book_registry_id,
272 trade_account_oracle_id: h.trade_account_oracle_id,
273 trial_trade_account_oracle_id: h.trial_trade_account_oracle_id,
274 order_book_whitelist_id: h.order_book_whitelist_id,
275 order_book_blacklist_id: h.order_book_blacklist_id,
276 fast_bridge_asset_registry_proxy_id: h.fast_bridge_asset_registry_proxy_id,
277 pairs: h.pairs.into_iter().map(Into::into).collect(),
278 margin: h.margin,
279 })
280 }
281}
282
283#[serde_as]
287#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
288#[serde(deny_unknown_fields)]
289pub struct MarginTierConfig {
290 pub tier_id: u64,
291 #[serde_as(as = "serde_with::DisplayFromStr")]
293 pub line: u64,
294 pub leverage: u64,
295 pub duration: u64,
298 pub maintenance_bps: u64,
299 pub open_buffer_bps: u64,
300 pub liq_price_factor: u64,
301 pub prolong_fee: [String; 4],
307 pub max_credit_line_bps: u64,
308 pub max_price_age: u64,
309 pub open_fee: String,
312 pub profit_share_bps: u64,
314 pub price_band_bps: u64,
315 #[serde(default, skip_serializing_if = "Option::is_none")]
320 pub daily_loss_limit_bps: Option<u64>,
321 pub markets: Vec<String>,
324 #[serde(default)]
327 pub sidecar: bool,
328 #[serde(default, skip_serializing_if = "Option::is_none")]
338 pub turbo: Option<MarginTurboConfig>,
339 #[serde(default = "tier_enabled_default")]
348 pub enabled: bool,
349}
350
351fn tier_enabled_default() -> bool {
353 true
354}
355
356#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
360#[serde(deny_unknown_fields)]
361pub struct MarginTurboConfig {
362 pub max_loss_bps: [u64; 4],
366 pub rollover_profit_bps: u64,
369 pub max_rollovers: u64,
371}
372
373impl From<&MarginTurboConfig> for o2_tools::prop::TurboTerms {
374 fn from(turbo: &MarginTurboConfig) -> Self {
375 Self {
376 max_loss_bps: turbo.max_loss_bps,
377 rollover_profit_bps: turbo.rollover_profit_bps,
378 max_rollovers: turbo.max_rollovers,
379 }
380 }
381}
382
383#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
389pub struct MarginSupportedAsset {
390 pub asset: fuels::types::AssetId,
391 pub decimals: u8,
392}
393
394#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
398pub struct MarginPriceFeedConfig {
399 pub id: Option<ContractId>,
402 pub max_offchain_age_seconds: Option<u64>,
405 #[serde(default)]
411 pub publishers: Vec<String>,
412 #[serde(default)]
425 pub remove_publishers: Vec<String>,
426 #[serde(default)]
433 pub supported_assets: Vec<MarginSupportedAsset>,
434}
435
436#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
439pub struct MarginConfig {
440 #[serde(default)]
442 pub price_feed: MarginPriceFeedConfig,
443 #[serde(default, skip_serializing)]
453 pub price_feed_id: Option<ContractId>,
454 pub margin_pool_id: Option<ContractId>,
458 pub platform_payout: Option<String>,
461 pub expiry_cosigner: Option<fuels::types::Address>,
479 #[serde(default)]
491 pub discount_managers: Vec<String>,
492 #[serde(default)]
504 pub remove_discount_managers: Vec<String>,
505 pub collateral_asset: Option<fuels::types::AssetId>,
508 pub collateral_decimals: Option<u8>,
511 pub max_tier_books: Option<u64>,
513 pub base_repay_fee_ppm: Option<u64>,
521 #[serde(default)]
525 pub tiers: Vec<MarginTierConfig>,
526}
527
528#[derive(Debug, Clone, Default)]
529pub struct OwnershipTransferOptions {
530 pub new_proxy_owner: Option<fuels::types::Address>,
531 pub new_contract_owner: Option<fuels::types::Address>,
532 pub revoke_orderbook_maintainers: Vec<fuels::types::Address>,
536 pub new_orderbook_maintainers: Vec<fuels::types::Address>,
539}
540
541#[derive(Debug, Clone)]
543pub struct DeployParams {
544 pub deploy_config: MarketsConfigPartial,
545 pub output: Option<String>,
546 pub deploy_whitelist: bool,
547 pub deploy_blacklist: bool,
548 pub upgrade_bytecode: bool,
549 pub new_proxy_owner: Option<fuels::types::Address>,
550 pub new_contract_owner: Option<fuels::types::Address>,
551 pub trial_cosigner: Option<fuels::types::Address>,
557 pub trial_creator: Option<Identity>,
564 pub margin_tier_only: bool,
580 pub margin_cosigner: Option<fuels::types::Address>,
586 pub margin_liquidator: Option<Identity>,
591 pub margin_expiry_cosigner: Option<fuels::types::Address>,
604 pub revoke_orderbook_maintainers: Vec<fuels::types::Address>,
605 pub new_orderbook_maintainers: Vec<fuels::types::Address>,
606}
607
608pub fn load_config_from_file<T>(config_path: &str) -> anyhow::Result<T>
614where
615 T: Default + serde::de::DeserializeOwned,
616{
617 if config_path.is_empty() {
618 return Ok(T::default());
619 }
620 let current_dir = std::env::current_dir()?;
621 let path = current_dir.join(config_path);
622 tracing::info!("Loading config from {}", path.display());
623 let file = std::fs::File::open(&path)?;
624 let config: T = serde_json::from_reader(file)?;
625 Ok(config)
626}
627
628pub async fn deploy<W>(
637 wallet: W,
638 params: DeployParams,
639) -> anyhow::Result<MarketsConfigOutput>
640where
641 W: Account + ViewOnlyAccount + Clone + 'static,
642{
643 sidecar::validate_config(¶ms.deploy_config.pairs)?;
644 if let Some(margin) = ¶ms.deploy_config.margin {
645 validate_margin_tier_catalogue(margin)?;
648 if let Some(pool_id) = margin.margin_pool_id
649 && wallet.try_provider()?.contract_exists(&pool_id).await?
650 {
651 let pool =
654 o2_tools::prop::PropMarginPoolContract::new(pool_id, wallet.clone());
655 reconcile_disabled_margin_tiers(&pool, &margin.tiers).await?;
656 }
657 }
658 tracing::info!("Starting Fuel o2 Registries and Markets");
659 let mut markets_config_partial = params.deploy_config.clone();
660 let starting_height: BlockHeight = markets_config_partial.starting_height.into();
661 let trade_account_oracle_id = markets_config_partial.trade_account_oracle_id;
662 let trial_trade_account_oracle_id =
663 markets_config_partial.trial_trade_account_oracle_id;
664 let order_book_registry_id = markets_config_partial.order_book_registry_id;
665 let trade_account_registry_id = markets_config_partial.trade_account_registry_id;
666 let fast_bridge_asset_registry_proxy_id =
667 markets_config_partial.fast_bridge_asset_registry_proxy_id;
668
669 let mut salt = Salt::zeroed();
670 salt.deref_mut()[..4].copy_from_slice(&starting_height.deref().to_be_bytes());
671
672 let (trade_account_oracle_deploy, trade_account_blob_id) =
673 deploy_trade_account_oracle(
674 wallet.clone(),
675 params.upgrade_bytecode,
676 trade_account_oracle_id,
677 salt,
678 )
679 .await?;
680 let trial_trade_account_oracle_id = deploy_trial_trade_account_oracle(
684 wallet.clone(),
685 params.upgrade_bytecode,
686 trial_trade_account_oracle_id,
687 params.trial_cosigner,
688 salt,
689 )
690 .await?;
691 let (trade_account_registry, trade_account_registry_blob_id) =
692 deploy_trade_account_registry(
693 wallet.clone(),
694 params.upgrade_bytecode,
695 trade_account_oracle_deploy.clone(),
696 trial_trade_account_oracle_id,
697 trade_account_registry_id,
698 salt,
699 )
700 .await?;
701 let order_book_blacklist_id = deploy_order_book_blacklist(
702 wallet.clone(),
703 params.deploy_blacklist,
704 markets_config_partial.order_book_blacklist_id,
705 salt,
706 )
707 .await?;
708 let order_book_whitelist_id = deploy_order_book_whitelist(
709 wallet.clone(),
710 params.deploy_whitelist,
711 markets_config_partial.order_book_whitelist_id,
712 salt,
713 )
714 .await?;
715 let (order_book_registry, order_book_registry_blob_id) = deploy_order_book_registry(
716 wallet.clone(),
717 params.upgrade_bytecode,
718 order_book_registry_id,
719 salt,
720 )
721 .await?;
722 let pairs = deploy_order_books(
723 wallet.clone(),
724 params.upgrade_bytecode,
725 order_book_blacklist_id,
726 order_book_whitelist_id,
727 order_book_registry.clone(),
728 &mut markets_config_partial.pairs,
729 OwnershipTransferOptions {
730 new_proxy_owner: params.new_proxy_owner,
731 new_contract_owner: params.new_contract_owner,
732 revoke_orderbook_maintainers: params.revoke_orderbook_maintainers.clone(),
733 new_orderbook_maintainers: params.new_orderbook_maintainers.clone(),
734 },
735 )
736 .await?;
737
738 let order_book_registry_id = order_book_registry.contract_id;
739 let trade_account_registry_id = trade_account_registry.contract_id;
740 let trade_account_oracle_id = trade_account_oracle_deploy.oracle_id;
741
742 let trade_account_proxy = trade_account_registry
743 .registry
744 .methods()
745 .default_bytecode()
746 .simulate(Execution::state_read_only())
747 .await?
748 .value
749 .context(
750 "Trade account registry default bytecode should exist after initialization",
751 )?;
752 let trade_account_root = trade_account_registry
753 .registry
754 .methods()
755 .factory_bytecode_root()
756 .simulate(Execution::state_read_only())
757 .await?
758 .value
759 .context("Trade account registry factory bytecode root should exist after initialization")?;
760
761 let margin_ids = match &markets_config_partial.margin {
765 Some(margin) => Some(
766 deploy_margin(
767 &wallet,
768 margin,
769 salt,
770 params.margin_tier_only,
771 trade_account_registry_id,
772 trade_account_oracle_id,
773 trial_trade_account_oracle_id,
774 &pairs,
775 params.margin_cosigner,
776 params.margin_liquidator,
777 params.margin_expiry_cosigner,
778 params.upgrade_bytecode,
779 )
780 .await?,
781 ),
782 None => None,
783 };
784
785 if let Some(trial_creator) = params.trial_creator {
789 let current_creator = trade_account_registry
790 .get_trial_trade_account_creator()
791 .await?;
792 if current_creator != trial_creator {
793 tracing::info!("Setting trial trade account creator to {trial_creator:?}");
794 trade_account_registry
795 .set_trial_trade_account_creator(trial_creator)
796 .await?;
797 }
798 }
799
800 transfer_ownership(
801 &wallet,
802 ¶ms,
803 &order_book_registry,
804 &trade_account_registry,
805 &trade_account_oracle_deploy,
806 trial_trade_account_oracle_id,
807 order_book_blacklist_id,
808 order_book_whitelist_id,
809 )
810 .await?;
811
812 let deploy_result = MarketsConfigOutput {
813 starting_height: starting_height.into(),
814 trade_account_registry_id,
815 trade_account_registry_blob_id,
816 trade_account_proxy,
817 trade_account_blob_id,
818 trade_account_root: ContractId::from(trade_account_root.0),
819 trade_account_oracle_id,
820 trial_trade_account_oracle_id,
821 order_book_whitelist_id,
822 order_book_blacklist_id,
823 order_book_registry_id,
824 order_book_registry_blob_id,
825 pairs,
826 fast_bridge_asset_registry_proxy_id,
827 price_feed_id: margin_ids.map(|ids| ids.price_feed_id),
828 margin_pool_id: margin_ids.map(|ids| ids.margin_pool_id),
829 margin_oracle_id: margin_ids.and_then(|ids| ids.margin_oracle_id),
830 margin: markets_config_partial.margin.clone().map(|mut margin| {
837 if let Some(ids) = margin_ids {
838 margin.price_feed.id = Some(ids.price_feed_id);
839 margin.margin_pool_id = Some(ids.margin_pool_id);
840 }
841 margin
842 }),
843 };
844
845 if let Some(output_path) = params.output {
846 let json = serde_json::to_string_pretty(&deploy_result)?;
847 tracing::info!("Deploy result saved to {}", output_path);
848 std::fs::write(output_path, json)?;
849 }
850
851 Ok(deploy_result)
852}
853
854#[derive(Debug, Clone, Copy)]
860pub struct MarginIds {
861 pub price_feed_id: ContractId,
862 pub margin_pool_id: ContractId,
863 pub margin_oracle_id: Option<ContractId>,
864}
865
866#[allow(clippy::too_many_arguments)]
877async fn deploy_margin<W>(
878 wallet: &W,
879 margin: &MarginConfig,
880 salt: fuels::types::Salt,
881 tier_only: bool,
882 trade_account_registry_id: ContractId,
883 trade_account_oracle_id: ContractId,
884 trial_trade_account_oracle_id: ContractId,
885 pairs: &[OrderBookConfig],
886 margin_cosigner: Option<fuels::types::Address>,
887 margin_liquidator: Option<Identity>,
888 margin_expiry_cosigner: Option<fuels::types::Address>,
889 upgrade_bytecode: bool,
890) -> anyhow::Result<MarginIds>
891where
892 W: Account + ViewOnlyAccount + Clone + 'static,
893{
894 use o2_tools::prop::{
895 PriceFeedContract,
896 PropAccountOracleContract,
897 PropMarginPoolContract,
898 };
899
900 let collateral_asset = match margin.collateral_asset {
901 Some(collateral_asset) => collateral_asset,
902 None => {
903 pairs
904 .first()
905 .context(
906 "margin.collateral_asset is not set and there are no pairs to \
907 default it from",
908 )?
909 .quote
910 .asset
911 }
912 };
913 let cosigner = margin_cosigner;
918 let platform_payout = margin
919 .platform_payout
920 .as_deref()
921 .map(parse_margin_identity)
922 .transpose()?;
923 let liquidator = margin_liquidator;
924 let expiry_cosigner = margin_expiry_cosigner.or(margin.expiry_cosigner);
933
934 let mut prop_config = PropDeployConfig::new(collateral_asset);
935 prop_config.collateral_decimals = margin.collateral_decimals.context(
938 "margin.collateral_decimals is required - set it to the collateral \
939 asset's decimals in the deploy config",
940 )?;
941 prop_config.max_tier_books = margin.max_tier_books.unwrap_or(80);
942 prop_config.base_repay_fee_ppm = margin.base_repay_fee_ppm.unwrap_or(100);
943 prop_config.cosigner = cosigner;
944 prop_config.platform_payout = platform_payout;
945 prop_config.liquidator = liquidator;
946 prop_config.salt = salt;
947 prop_config.existing_price_feed = margin.price_feed.id;
948 prop_config.max_offchain_age_seconds = margin.price_feed.max_offchain_age_seconds;
949 prop_config.existing_pool = margin.margin_pool_id;
953 prop_config.existing_registry = Some(ExistingRegistry {
954 registry_id: trade_account_registry_id,
955 trade_account_oracle_id,
956 trial_trade_account_oracle_id,
957 });
958
959 anyhow::ensure!(
960 !(tier_only && margin.margin_pool_id.is_none()),
961 "--margin-tier-only was requested but the markets config names no \
962 `margin.margin_pool_id`: there is no pool to reconcile tiers against"
963 );
964
965 let (price_feed_id, margin_pool_id, margin_oracle_id) = if let (
967 true,
968 Some(margin_pool_id),
969 ) =
970 (tier_only, margin.margin_pool_id)
971 {
972 tracing::info!("Margin: tier-only mode against pool {margin_pool_id}");
973 let price_feed_id = match margin.price_feed.id {
974 Some(price_feed_id) => price_feed_id,
975 None => {
976 PropMarginPoolContract::new(margin_pool_id, wallet.clone())
977 .methods()
978 .price_feed()
979 .simulate(Execution::state_read_only())
980 .await
981 .context("read the pool's price feed")?
982 .value
983 }
984 };
985 let margin_oracle_id =
991 TradeAccountRegistryManager::new(wallet.clone(), trade_account_registry_id)
992 .registry
993 .methods()
994 .get_prop_oracle_id()
995 .simulate(Execution::state_read_only())
996 .await
997 .map(|result| result.value)
998 .ok()
999 .filter(|oracle_id| *oracle_id != ContractId::zeroed());
1000 (price_feed_id, margin_pool_id, margin_oracle_id)
1001 } else {
1002 let deployment = deploy_prop_system(wallet, &prop_config).await?;
1003 tracing::info!(
1004 "Margin: oracle {}, pool {}, feed {} (shared registry {} upgraded in place)",
1005 deployment.oracle_id,
1006 deployment.pool_id,
1007 deployment.price_feed_id,
1008 deployment.registry_id,
1009 );
1010 (
1011 deployment.price_feed_id,
1012 deployment.pool_id,
1013 Some(deployment.oracle_id),
1014 )
1015 };
1016
1017 let pool_deployed = wallet
1023 .try_provider()?
1024 .contract_exists(&margin_pool_id)
1025 .await?;
1026 if pool_deployed {
1027 let pool = PropMarginPoolContract::new(margin_pool_id, wallet.clone());
1028 if let Some(platform_payout) = platform_payout {
1029 let current = pool
1030 .methods()
1031 .platform_payout()
1032 .simulate(Execution::state_read_only())
1033 .await
1034 .context("read the pool's platform payout")?
1035 .value;
1036 if current != platform_payout {
1037 tracing::info!(
1038 "Margin: platform payout {current:?} -> {platform_payout:?}"
1039 );
1040 pool.methods()
1041 .set_platform_payout(platform_payout)
1042 .call()
1043 .await?;
1044 }
1045 }
1046 if let Some(liquidator) = liquidator {
1047 let current = pool
1048 .methods()
1049 .liquidator()
1050 .simulate(Execution::state_read_only())
1051 .await
1052 .context("read the pool's liquidator")?
1053 .value;
1054 if current != liquidator {
1055 tracing::info!("Margin: liquidator {current:?} -> {liquidator:?}");
1056 pool.methods().set_liquidator(liquidator).call().await?;
1057 }
1058 }
1059
1060 if let Some(expiry_cosigner) = expiry_cosigner {
1071 let current = pool
1072 .methods()
1073 .expiry_cosigner()
1074 .simulate(Execution::state_read_only())
1075 .await
1076 .context("read the pool's expiry cosigner")?
1077 .value;
1078 if current != Some(expiry_cosigner) {
1079 tracing::info!(
1080 "Margin: expiry cosigner {current:?} -> {expiry_cosigner:?}"
1081 );
1082 pool.methods()
1083 .set_expiry_cosigner(expiry_cosigner)
1084 .call()
1085 .await?;
1086 }
1087 } else {
1088 let current = pool
1092 .methods()
1093 .expiry_cosigner()
1094 .simulate(Execution::state_read_only())
1095 .await
1096 .context("read the pool's expiry cosigner")?
1097 .value;
1098 if current.is_none() {
1099 tracing::warn!(
1100 "Margin: no expiry cosigner configured and this pool has \
1101 none - PREPAID (Turbo) deadlines cannot be settled until \
1102 `margin.expiry_cosigner` names the address of the \
1103 backend's margin cosigner key."
1104 );
1105 }
1106 }
1107
1108 for entry in &margin.discount_managers {
1117 anyhow::ensure!(
1118 !margin.remove_discount_managers.contains(entry),
1119 "discount manager {entry} is listed in both \
1120 `discount_managers` and `remove_discount_managers`"
1121 );
1122 }
1123 for (entry, should_hold) in margin
1124 .discount_managers
1125 .iter()
1126 .map(|entry| (entry, true))
1127 .chain(
1128 margin
1129 .remove_discount_managers
1130 .iter()
1131 .map(|entry| (entry, false)),
1132 )
1133 {
1134 let manager = parse_margin_identity(entry)?;
1135 let holds = pool
1136 .methods()
1137 .has_role(DISCOUNT_MANAGER_ROLE, manager)
1138 .simulate(Execution::state_read_only())
1139 .await
1140 .context("read a discount manager's role")?
1141 .value;
1142 if holds == should_hold {
1143 continue;
1144 }
1145 if should_hold {
1146 tracing::info!("Margin: adding discount manager {manager:?}");
1147 pool.methods()
1148 .grant_role(DISCOUNT_MANAGER_ROLE, manager)
1149 .call()
1150 .await
1151 .context("grant a discount manager its role")?;
1152 } else {
1153 tracing::info!("Margin: removing discount manager {manager:?}");
1154 pool.methods()
1155 .revoke_role(DISCOUNT_MANAGER_ROLE, manager)
1156 .call()
1157 .await
1158 .context("revoke a discount manager's role")?;
1159 }
1160 }
1161 }
1162
1163 let margin_oracle_id = match margin_oracle_id {
1166 Some(oracle_id) => Some(oracle_id),
1167 None if cosigner.is_some() => {
1168 let registry = o2_tools::trade_account_registry::TradeAccountRegistry::new(
1169 trade_account_registry_id,
1170 wallet.clone(),
1171 );
1172 let oracle_id = registry
1173 .methods()
1174 .get_prop_oracle_id()
1175 .simulate(Execution::state_read_only())
1176 .await
1177 .context("read the registry's prop account oracle")?
1178 .value;
1179 (oracle_id != ContractId::zeroed()).then_some(oracle_id)
1180 }
1181 None => None,
1182 };
1183
1184 if let (Some(cosigner), Some(oracle_id)) = (cosigner, margin_oracle_id)
1189 && wallet.try_provider()?.contract_exists(&oracle_id).await?
1190 {
1191 let oracle = PropAccountOracleContract::new(oracle_id, wallet.clone());
1192 let current = oracle
1193 .methods()
1194 .get_cosigner()
1195 .simulate(Execution::state_read_only())
1196 .await
1197 .context("read the prop account oracle's cosigner")?
1198 .value;
1199 if current != Some(cosigner) {
1200 tracing::info!("Margin: cosigner {current:?} -> {cosigner:?}");
1201 oracle.methods().set_cosigner(cosigner).call().await?;
1202 }
1203 }
1204
1205 for entry in &margin.price_feed.publishers {
1213 anyhow::ensure!(
1214 !margin.price_feed.remove_publishers.contains(entry),
1215 "price feed publisher {entry} is listed in both `publishers` and \
1216 `remove_publishers`"
1217 );
1218 }
1219 let price_feed = PriceFeedContract::new(price_feed_id, wallet.clone());
1220 for (entry, should_hold) in margin
1221 .price_feed
1222 .publishers
1223 .iter()
1224 .map(|entry| (entry, true))
1225 .chain(
1226 margin
1227 .price_feed
1228 .remove_publishers
1229 .iter()
1230 .map(|entry| (entry, false)),
1231 )
1232 {
1233 let publisher = parse_margin_identity(entry)?;
1234 let holds = price_feed
1235 .methods()
1236 .has_role(o2_tools::prop_deploy::PRICE_SUBMITTER_ROLE, publisher)
1237 .simulate(Execution::state_read_only())
1238 .await
1239 .context("read a price feed publisher's submitter role")?
1240 .value;
1241 if holds == should_hold {
1242 continue;
1243 }
1244 if should_hold {
1245 tracing::info!("Margin: adding price feed publisher {publisher:?}");
1246 price_feed
1247 .methods()
1248 .add_publisher(publisher)
1249 .call()
1250 .await
1251 .context("grant a price feed publisher the submitter role")?;
1252 } else {
1253 tracing::info!("Margin: removing price feed publisher {publisher:?}");
1254 price_feed
1255 .methods()
1256 .remove_publisher(publisher)
1257 .call()
1258 .await
1259 .context("revoke a price feed publisher's submitter role")?;
1260 }
1261 }
1262 if upgrade_bytecode && !tier_only {
1271 o2_tools::prop_deploy::upgrade_price_feed_implementation(
1272 wallet,
1273 price_feed_id,
1274 prop_config
1277 .owner
1278 .unwrap_or(Identity::Address(wallet.address())),
1279 &prop_config,
1280 )
1281 .await
1282 .context("upgrade the price feed implementation")?;
1283 }
1284
1285 if let Some(seconds) = margin.price_feed.max_offchain_age_seconds {
1290 let current = price_feed
1291 .methods()
1292 .get_max_offchain_age()
1293 .simulate(Execution::state_read_only())
1294 .await
1295 .context("read the price feed's submission-age bound")?
1296 .value;
1297 if current != seconds {
1298 tracing::info!("Margin: submission-age bound {current} -> {seconds}");
1299 price_feed
1300 .methods()
1301 .set_max_offchain_age(seconds)
1302 .call()
1303 .await
1304 .context("set the price feed's submission-age bound")?;
1305 }
1306 }
1307
1308 for supported in &margin.price_feed.supported_assets {
1314 let current = price_feed
1315 .methods()
1316 .get_asset_decimals(supported.asset)
1317 .simulate(Execution::state_read_only())
1318 .await
1319 .context("read the price feed's asset decimals")?
1320 .value;
1321 match current {
1322 Some(decimals) if decimals == supported.decimals => continue,
1323 Some(decimals) => anyhow::bail!(
1327 "price feed already knows {} at {decimals} decimals, config says \
1328 {} - refusing to move it, since the pool pins decimals on first \
1329 use and rejects a change",
1330 supported.asset,
1331 supported.decimals,
1332 ),
1333 None => {
1334 tracing::info!(
1335 "Margin: registering {} at {} decimals on the feed",
1336 supported.asset,
1337 supported.decimals,
1338 );
1339 price_feed
1340 .methods()
1341 .set_asset_decimals(supported.asset, supported.decimals)
1342 .call()
1343 .await
1344 .context("register a supported asset on the price feed")?;
1345 }
1346 }
1347 }
1348
1349 let pool = PropMarginPoolContract::new(margin_pool_id, wallet.clone());
1350 let pool_reachable = wallet
1351 .try_provider()?
1352 .contract_exists(&margin_pool_id)
1353 .await?;
1354
1355 if pool_reachable {
1365 let live_feed = pool
1366 .methods()
1367 .price_feed()
1368 .simulate(Execution::state_read_only())
1369 .await
1370 .context("read the pool's price feed")?
1371 .value;
1372 if live_feed != price_feed_id {
1373 tracing::info!("Margin: pool price feed {live_feed} -> {price_feed_id}");
1374 pool.methods()
1375 .set_price_feed(price_feed_id)
1376 .with_contract_ids(&[price_feed_id])
1379 .call()
1380 .await
1381 .context(
1382 "point the pool at the configured price feed - the \
1383 replacement must already know every admitted asset at the \
1384 decimals the pool pinned",
1385 )?;
1386 }
1387 }
1388
1389 anyhow::ensure!(
1396 pool_reachable || margin.tiers.is_empty(),
1397 "margin pool {margin_pool_id} is not on chain, so its {} configured \
1398 tier(s) cannot be reconciled",
1399 margin.tiers.len()
1400 );
1401 if pool_reachable {
1402 reconcile_margin_tiers(
1403 &pool,
1404 price_feed_id,
1405 pairs,
1406 &margin.tiers,
1407 prop_config.collateral_decimals,
1408 )
1409 .await?;
1410 }
1411
1412 Ok(MarginIds {
1413 price_feed_id,
1414 margin_pool_id,
1415 margin_oracle_id,
1416 })
1417}
1418
1419async fn reconcile_margin_tiers<W>(
1429 pool: &o2_tools::prop::PropMarginPoolContract<W>,
1430 price_feed_id: ContractId,
1431 pairs: &[OrderBookConfig],
1432 tiers: &[MarginTierConfig],
1433 collateral_decimals: u8,
1434) -> anyhow::Result<()>
1435where
1436 W: Account + ViewOnlyAccount + Clone + 'static,
1437{
1438 reconcile_disabled_margin_tiers(pool, tiers).await?;
1440 for tier in tiers.iter().filter(|tier| tier.enabled) {
1441 let books = tier
1442 .markets
1443 .iter()
1444 .map(|market| resolve_tier_market(market, pairs, tier.sidecar))
1445 .collect::<anyhow::Result<Vec<_>>>()?;
1446 let params = tier_params(tier, collateral_decimals)?;
1447 let terms = required_turbo_terms(tier)?;
1448 validate_turbo_tier_config(tier, ¶ms, &terms)?;
1452 let current_version = pool
1453 .methods()
1454 .current_tier_version(tier.tier_id)
1455 .simulate(Execution::state_read_only())
1456 .await?
1457 .value;
1458
1459 let mut contract_ids = vec![price_feed_id];
1460 contract_ids.extend(books.iter().copied());
1461
1462 let Some(version) = current_version else {
1463 let version = publish_margin_tier(
1464 pool,
1465 tier.tier_id,
1466 params,
1467 terms.clone(),
1468 books,
1469 tier.daily_loss_limit_bps,
1470 &contract_ids,
1471 )
1472 .await?;
1473 tracing::info!(
1474 "Margin: tier {} published version {version} (prepaid)",
1475 tier.tier_id
1476 );
1477 reconcile_margin_tier_enabled(pool, tier).await?;
1478 continue;
1479 };
1480
1481 let live = pool
1482 .methods()
1483 .get_tier(tier.tier_id, version)
1484 .simulate(Execution::state_read_only())
1485 .await?
1486 .value
1487 .with_context(|| {
1488 format!("tier {} version {version} vanished mid-read", tier.tier_id)
1489 })?;
1490 let live_terms = pool
1496 .methods()
1497 .get_turbo_terms(tier.tier_id, version)
1498 .simulate(Execution::state_read_only())
1499 .await?
1500 .value;
1501 let live_daily_loss_limit = pool
1505 .methods()
1506 .get_daily_loss_limit(tier.tier_id, version)
1507 .simulate(Execution::state_read_only())
1508 .await?
1509 .value;
1510 let live_books = pool
1511 .methods()
1512 .tier_books(tier.tier_id, version)
1513 .simulate(Execution::state_read_only())
1514 .await?
1515 .value;
1516 let venue_changed = tier_book_route_changed(tier.sidecar, pairs, &live_books);
1519 if live != params
1520 || live_terms.as_ref() != Some(&terms)
1521 || live_daily_loss_limit != tier.daily_loss_limit_bps
1522 || venue_changed
1523 {
1524 let new_version = publish_margin_tier(
1525 pool,
1526 tier.tier_id,
1527 params,
1528 terms.clone(),
1529 books,
1530 tier.daily_loss_limit_bps,
1531 &contract_ids,
1532 )
1533 .await?;
1534 tracing::info!(
1535 "Margin: tier {} republished as version {new_version} ({})",
1536 tier.tier_id,
1537 if venue_changed {
1538 "book venue changed"
1539 } else if live_terms.as_ref() != Some(&terms) {
1540 "terms changed"
1541 } else if live_daily_loss_limit != tier.daily_loss_limit_bps {
1542 "daily loss policy changed"
1543 } else {
1544 "economics changed"
1545 }
1546 );
1547 reconcile_margin_tier_enabled(pool, tier).await?;
1548 continue;
1549 }
1550 reconcile_margin_tier_enabled(pool, tier).await?;
1551
1552 let missing: Vec<ContractId> = books
1553 .iter()
1554 .copied()
1555 .filter(|book| !live_books.contains(book))
1556 .collect();
1557 let shrunk = live_books
1558 .iter()
1559 .filter(|book| !books.contains(book))
1560 .count();
1561 if shrunk > 0 {
1562 tracing::warn!(
1563 "Margin: tier {} declares {shrunk} fewer book(s) than live version \
1564 {version}; books are append-only — publish a new version to drop \
1565 markets",
1566 tier.tier_id
1567 );
1568 }
1569 if missing.is_empty() {
1570 tracing::info!(
1571 "Margin: tier {} version {version} matches the declared catalogue — \
1572 unchanged",
1573 tier.tier_id
1574 );
1575 continue;
1576 }
1577 let mut add_contract_ids = vec![price_feed_id];
1578 add_contract_ids.extend(missing.iter().copied());
1579 pool.methods()
1580 .add_books(tier.tier_id, missing.clone())
1581 .with_contract_ids(&add_contract_ids)
1582 .call()
1583 .await?;
1584 tracing::info!(
1585 "Margin: tier {} version {version} gained {} book(s)",
1586 tier.tier_id,
1587 missing.len()
1588 );
1589 }
1590 Ok(())
1591}
1592
1593fn required_turbo_terms(
1595 tier: &MarginTierConfig,
1596) -> anyhow::Result<o2_tools::prop::TurboTerms> {
1597 tier.turbo
1598 .as_ref()
1599 .map(o2_tools::prop::TurboTerms::from)
1600 .with_context(|| {
1601 format!(
1602 "tier {} has no `turbo` terms: legacy tier publishing is disabled; \
1603 set `enabled` to false to retire it or configure valid prepaid \
1604 terms",
1605 tier.tier_id
1606 )
1607 })
1608}
1609
1610fn validate_margin_tier_catalogue(margin: &MarginConfig) -> anyhow::Result<()> {
1612 let decimals = margin.collateral_decimals.context(
1613 "margin.collateral_decimals is required - set it to the collateral \
1614 asset's decimals in the deploy config",
1615 )?;
1616 let mut ids = std::collections::BTreeSet::new();
1617 for tier in &margin.tiers {
1618 anyhow::ensure!(
1619 ids.insert(tier.tier_id),
1620 "tier {} appears more than once in the margin catalogue",
1621 tier.tier_id
1622 );
1623 if !tier.enabled {
1624 continue;
1625 }
1626 let terms = required_turbo_terms(tier)?;
1627 let params = tier_params(tier, decimals)?;
1628 validate_turbo_tier_config(tier, ¶ms, &terms)?;
1629 }
1630 Ok(())
1631}
1632
1633async fn reconcile_disabled_margin_tiers<W>(
1635 pool: &o2_tools::prop::PropMarginPoolContract<W>,
1636 tiers: &[MarginTierConfig],
1637) -> anyhow::Result<()>
1638where
1639 W: Account + ViewOnlyAccount + Clone + 'static,
1640{
1641 for tier in tiers.iter().filter(|tier| !tier.enabled) {
1642 let version = pool
1643 .methods()
1644 .current_tier_version(tier.tier_id)
1645 .simulate(Execution::state_read_only())
1646 .await?
1647 .value;
1648 if version.is_some() {
1651 reconcile_margin_tier_enabled(pool, tier).await?;
1652 }
1653 }
1654 Ok(())
1655}
1656
1657async fn publish_margin_tier<W>(
1659 pool: &o2_tools::prop::PropMarginPoolContract<W>,
1660 tier_id: u64,
1661 params: o2_tools::prop::TierParams,
1662 terms: o2_tools::prop::TurboTerms,
1663 books: Vec<ContractId>,
1664 daily_loss_limit_bps: Option<u64>,
1665 contract_ids: &[ContractId],
1666) -> anyhow::Result<u32>
1667where
1668 W: Account + ViewOnlyAccount + Clone + 'static,
1669{
1670 let version = pool
1671 .methods()
1672 .publish_tier_v3(tier_id, params, terms, books, daily_loss_limit_bps)
1673 .with_contract_ids(contract_ids)
1674 .call()
1675 .await?
1676 .value;
1677 Ok(version)
1678}
1679
1680async fn reconcile_margin_tier_enabled<W>(
1688 pool: &o2_tools::prop::PropMarginPoolContract<W>,
1689 tier: &MarginTierConfig,
1690) -> anyhow::Result<()>
1691where
1692 W: Account + ViewOnlyAccount + Clone + 'static,
1693{
1694 let live = pool
1695 .methods()
1696 .is_tier_enabled(tier.tier_id)
1697 .simulate(Execution::state_read_only())
1698 .await?
1699 .value;
1700 if live == tier.enabled {
1701 return Ok(());
1702 }
1703 pool.methods()
1704 .set_tier_enabled(tier.tier_id, tier.enabled)
1705 .call()
1706 .await?;
1707 tracing::info!(
1708 "Margin: tier {} now {} for new sessions",
1709 tier.tier_id,
1710 if tier.enabled { "OPEN" } else { "CLOSED" }
1711 );
1712 Ok(())
1713}
1714
1715fn validate_turbo_tier_config(
1721 tier: &MarginTierConfig,
1722 params: &o2_tools::prop::TierParams,
1723 terms: &o2_tools::prop::TurboTerms,
1724) -> anyhow::Result<()> {
1725 let id = tier.tier_id;
1726 if let Some(limit) = tier.daily_loss_limit_bps {
1727 anyhow::ensure!(
1728 limit <= 10_000,
1729 "tier {id} `daily_loss_limit_bps` must be <= 10000"
1730 );
1731 }
1732 anyhow::ensure!(params.line > 0, "tier {id} `line` must be greater than 0");
1733 anyhow::ensure!(
1734 params.leverage > 0 && params.leverage <= params.line,
1735 "tier {id} `leverage` must be positive and leave nonzero required collateral"
1736 );
1737 for (field, value) in [
1738 ("maintenance_bps", params.maintenance_bps),
1739 ("open_buffer_bps", params.open_buffer_bps),
1740 ("price_band_bps", params.price_band_bps),
1741 ] {
1742 anyhow::ensure!(value <= 10_000, "tier {id} `{field}` must be <= 10000");
1743 }
1744 anyhow::ensure!(
1745 (1..=10_000).contains(¶ms.liq_price_factor),
1746 "tier {id} `liq_price_factor` must be between 1 and 10000"
1747 );
1748 anyhow::ensure!(
1749 params.duration > 0,
1750 "tier {id} is prepaid, so `duration` must be greater than 0 seconds; \
1751 use u64::MAX for a perpetual term"
1752 );
1753 anyhow::ensure!(
1754 params.prolong_fee.iter().all(|fee| *fee == 0),
1755 "tier {id} is prepaid, so every `prolong_fee` must be \"0\" - a \
1756 prepaid term is extended by the FREE rollover and by nothing else"
1757 );
1758 anyhow::ensure!(
1759 params.profit_share_bps == 0,
1760 "tier {id} is prepaid, so `profit_share_bps` must be 0 - the user \
1761 keeps 100% of profits and the platform's take is the forfeited entry"
1762 );
1763 anyhow::ensure!(
1766 params.max_credit_line_bps > 10_000,
1767 "tier {id} is prepaid, so `max_credit_line_bps` must be > 10000 - \
1768 the cap must not truncate the line the session opens with"
1769 );
1770 anyhow::ensure!(
1771 terms.rollover_profit_bps <= 10_000,
1772 "tier {id} `rollover_profit_bps` must be <= 10000"
1773 );
1774 anyhow::ensure!(
1775 terms.max_loss_bps.iter().all(|bps| *bps <= 10_000),
1776 "tier {id} every `max_loss_bps` entry must be <= 10000 - a rate past \
1777 BPS would put the floor below zero for any line"
1778 );
1779 if terms.rollover_profit_bps != 0 {
1780 anyhow::ensure!(
1781 terms.max_rollovers != 0,
1782 "tier {id} offers a rollover, so `max_rollovers` must not be 0 - \
1783 an enabled feature must be a real one"
1784 );
1785 }
1786 let last_step = terms.max_rollovers.min(3) as usize;
1790 for step in 0..=last_step {
1791 anyhow::ensure!(
1792 terms.max_loss_bps[step] != 0,
1793 "tier {id} sells a drawdown escalator whose step {step} is 0, \
1794 which floors that term at the session's line - a \
1795 session reaching it is liquidatable immediately"
1796 );
1797 anyhow::ensure!(
1798 step == 0 || terms.max_loss_bps[step] >= terms.max_loss_bps[step - 1],
1799 "tier {id} drawdown step {step} is NARROWER than the step \
1800 before it - the schedule is sold as an escalator and must \
1801 never take back room a previous term already granted"
1802 );
1803 }
1804 let line = u128::from(params.line);
1807 let bps_up = |bps: u64| (line * u128::from(bps)).div_ceil(10_000);
1808 let maintenance = bps_up(params.maintenance_bps);
1809 let open_buffer = bps_up(params.open_buffer_bps);
1810 let cap = line * u128::from(params.max_credit_line_bps) / 10_000;
1811 anyhow::ensure!(
1812 cap <= u128::from(u64::MAX),
1813 "tier {id} credit-line cap does not fit a u64"
1814 );
1815 anyhow::ensure!(
1816 open_buffer > maintenance,
1817 "tier {id} `open_buffer_bps` must produce a buffer above maintenance"
1818 );
1819 let k = line - line / u128::from(params.leverage);
1820 anyhow::ensure!(
1821 k + open_buffer < cap,
1822 "tier {id} open buffer puts the freeze threshold at or above the credit-line cap"
1823 );
1824 let allowance = line * u128::from(terms.max_loss_bps[0]) / 10_000;
1825 let gap = open_buffer - maintenance;
1826 anyhow::ensure!(
1827 gap <= allowance,
1828 "tier {id} first drawdown step ({} bps) is narrower than the \
1829 tier's own `open_buffer_bps - maintenance_bps` gap, so every \
1830 session it sells opens already FROZEN and can never trade",
1831 terms.max_loss_bps[0]
1832 );
1833 Ok(())
1834}
1835
1836fn collateral_amount(value: &str, decimals: u8, field: &str) -> anyhow::Result<u64> {
1845 let raw = value.trim();
1846 anyhow::ensure!(!raw.is_empty(), "{field}: empty amount");
1847 anyhow::ensure!(
1848 !raw.starts_with('-'),
1849 "{field}: `{raw}` is negative; fees are unsigned"
1850 );
1851 let (whole, fraction) = match raw.split_once('.') {
1852 Some((whole, fraction)) => (whole, fraction),
1853 None => (raw, ""),
1854 };
1855 anyhow::ensure!(
1856 !fraction.contains('.'),
1857 "{field}: `{raw}` has more than one decimal point"
1858 );
1859 anyhow::ensure!(
1860 !whole.is_empty() && whole.bytes().all(|b| b.is_ascii_digit()),
1861 "{field}: `{raw}` is not a decimal amount"
1862 );
1863 anyhow::ensure!(
1864 fraction.bytes().all(|b| b.is_ascii_digit()),
1865 "{field}: `{raw}` is not a decimal amount"
1866 );
1867 let decimals = usize::from(decimals);
1868 anyhow::ensure!(
1869 fraction.len() <= decimals,
1870 "{field}: `{raw}` has {} decimal places, but the collateral asset has \
1871 only {decimals}; the chain cannot represent it",
1872 fraction.len()
1873 );
1874 let digits = format!("{whole}{fraction}{}", "0".repeat(decimals - fraction.len()));
1875 digits
1876 .parse::<u64>()
1877 .map_err(|e| anyhow::anyhow!("{field}: `{raw}` does not fit a u64: {e}"))
1878}
1879
1880fn tier_params(
1881 tier: &MarginTierConfig,
1882 collateral_decimals: u8,
1883) -> anyhow::Result<o2_tools::prop::TierParams> {
1884 let fee = |value: &str, what: &str| {
1885 collateral_amount(
1886 value,
1887 collateral_decimals,
1888 &format!("tier {} {what}", tier.tier_id),
1889 )
1890 };
1891 Ok(o2_tools::prop::TierParams {
1892 line: tier.line,
1893 leverage: tier.leverage,
1894 duration: tier.duration,
1895 maintenance_bps: tier.maintenance_bps,
1896 open_buffer_bps: tier.open_buffer_bps,
1897 liq_price_factor: tier.liq_price_factor,
1898 prolong_fee: [
1899 fee(&tier.prolong_fee[0], "prolong_fee[0]")?,
1900 fee(&tier.prolong_fee[1], "prolong_fee[1]")?,
1901 fee(&tier.prolong_fee[2], "prolong_fee[2]")?,
1902 fee(&tier.prolong_fee[3], "prolong_fee[3]")?,
1903 ],
1904 max_credit_line_bps: tier.max_credit_line_bps,
1905 max_price_age: tier.max_price_age,
1906 open_fee: fee(&tier.open_fee, "open_fee")?,
1907 profit_share_bps: tier.profit_share_bps,
1908 price_band_bps: tier.price_band_bps,
1909 })
1910}
1911
1912fn tier_book_route_changed(
1914 sidecar: bool,
1915 pairs: &[OrderBookConfig],
1916 live_books: &[ContractId],
1917) -> bool {
1918 pairs.iter().any(|pair| {
1919 let previous_book = if sidecar {
1920 pair.contract_id
1921 } else {
1922 pair.sidecar
1923 .as_ref()
1924 .and_then(|settings| settings.contract_id)
1925 };
1926 previous_book.is_some_and(|book| live_books.contains(&book))
1927 })
1928}
1929
1930fn resolve_tier_market(
1932 market: &str,
1933 pairs: &[OrderBookConfig],
1934 sidecar: bool,
1935) -> anyhow::Result<ContractId> {
1936 let wanted = market.trim();
1937 let wanted_id = wanted
1938 .strip_prefix("0x")
1939 .unwrap_or(wanted)
1940 .to_ascii_lowercase();
1941 let pair = pairs
1942 .iter()
1943 .find(|pair| {
1944 let symbol = format!("{}/{}", pair.base.symbol, pair.quote.symbol);
1945 symbol.eq_ignore_ascii_case(wanted)
1946 || hex::encode(*pair.market_id) == wanted_id
1947 })
1948 .with_context(|| format!("margin tier references unknown market `{market}`"))?;
1949 if sidecar {
1950 pair.sidecar
1951 .as_ref()
1952 .and_then(|settings| settings.contract_id)
1953 .with_context(|| {
1954 format!("margin tier market `{market}` has no deployed sidecar")
1955 })
1956 } else {
1957 pair.contract_id.with_context(|| {
1958 format!("margin tier market `{market}` has no deployed order book")
1959 })
1960 }
1961}
1962
1963fn parse_margin_identity(s: &str) -> anyhow::Result<Identity> {
1965 if let Some(hex_part) = s.strip_prefix("address:") {
1966 Ok(Identity::Address(fuels::types::Address::new(
1967 parse_margin_bytes32(hex_part)?,
1968 )))
1969 } else if let Some(hex_part) = s.strip_prefix("contract:") {
1970 Ok(Identity::ContractId(ContractId::new(parse_margin_bytes32(
1971 hex_part,
1972 )?)))
1973 } else {
1974 anyhow::bail!("expected `address:0x..` or `contract:0x..`, got `{s}`")
1975 }
1976}
1977
1978fn parse_margin_bytes32(s: &str) -> anyhow::Result<[u8; 32]> {
1979 let raw = s.trim().strip_prefix("0x").unwrap_or(s.trim());
1980 let bytes = hex::decode(raw)
1981 .map_err(|e| anyhow::anyhow!("expected 32 hex bytes, got `{s}`: {e}"))?;
1982 bytes
1983 .try_into()
1984 .map_err(|_| anyhow::anyhow!("expected 32 hex bytes, got `{s}`"))
1985}
1986
1987#[allow(clippy::too_many_arguments)]
1992async fn transfer_ownership<W>(
1993 wallet: &W,
1994 params: &DeployParams,
1995 order_book_registry: &OrderBookRegistryManager<W>,
1996 trade_account_registry: &TradeAccountRegistryManager<W>,
1997 trade_account_oracle_deploy: &TradeAccountDeploy<W>,
1998 trial_trade_account_oracle_id: ContractId,
1999 order_book_blacklist_id: Option<ContractId>,
2000 order_book_whitelist_id: Option<ContractId>,
2001) -> anyhow::Result<()>
2002where
2003 W: Account + ViewOnlyAccount + Clone + 'static,
2004{
2005 if let Some(new_proxy_owner) = params.new_proxy_owner {
2006 let new_identity = Identity::Address(new_proxy_owner);
2007 tracing::info!(
2008 "Transferring OrderBookRegistry proxy ownership to {}",
2009 new_proxy_owner
2010 );
2011 order_book_registry
2012 .registry_proxy
2013 .methods()
2014 .set_owner(new_identity)
2015 .call()
2016 .await?;
2017 tracing::info!(
2018 "Transferring TradeAccountRegistry proxy ownership to {}",
2019 new_proxy_owner
2020 );
2021 trade_account_registry
2022 .registry_proxy
2023 .methods()
2024 .set_owner(new_identity)
2025 .call()
2026 .await?;
2027 }
2028
2029 if let Some(new_contract_owner) = params.new_contract_owner {
2030 let new_identity = Identity::Address(new_contract_owner);
2031 tracing::info!(
2032 "Transferring TradeAccountOracle ownership to {}",
2033 new_contract_owner
2034 );
2035 trade_account_oracle_deploy
2036 .oracle
2037 .methods()
2038 .transfer_ownership(new_identity)
2039 .call()
2040 .await?;
2041 tracing::info!(
2042 "Transferring TrialTradeAccountOracle ownership to {}",
2043 new_contract_owner
2044 );
2045 TrialTradingAccountOracle::new(trial_trade_account_oracle_id, wallet.clone())
2046 .methods()
2047 .transfer_ownership(new_identity)
2048 .call()
2049 .await?;
2050 tracing::info!(
2051 "Transferring TradeAccountRegistry ownership to {}",
2052 new_contract_owner
2053 );
2054 trade_account_registry
2055 .registry
2056 .methods()
2057 .transfer_ownership(new_identity)
2058 .call()
2059 .await?;
2060 tracing::info!(
2061 "Transferring OrderBookRegistry ownership to {}",
2062 new_contract_owner
2063 );
2064 order_book_registry
2065 .registry
2066 .methods()
2067 .transfer_ownership(new_identity)
2068 .call()
2069 .await?;
2070 if let Some(blacklist_id) = order_book_blacklist_id {
2071 tracing::info!(
2072 "Transferring OrderBookBlacklist ownership to {}",
2073 new_contract_owner
2074 );
2075 OrderBookBlacklist::new(blacklist_id, wallet.clone())
2076 .methods()
2077 .transfer_ownership(new_identity)
2078 .call()
2079 .await?;
2080 }
2081 if let Some(whitelist_id) = order_book_whitelist_id {
2082 tracing::info!(
2083 "Transferring OrderBookWhitelist ownership to {}",
2084 new_contract_owner
2085 );
2086 OrderBookWhitelist::new(whitelist_id, wallet.clone())
2087 .methods()
2088 .transfer_ownership(new_identity)
2089 .call()
2090 .await?;
2091 }
2092 }
2093
2094 Ok(())
2095}
2096
2097async fn deploy_order_book_blacklist<W>(
2102 deployer_wallet: W,
2103 deploy_blacklist: bool,
2104 order_book_blacklist_id: Option<ContractId>,
2105 salt: Salt,
2106) -> anyhow::Result<Option<ContractId>>
2107where
2108 W: Account + ViewOnlyAccount + Clone + 'static,
2109{
2110 match order_book_blacklist_id {
2111 Some(order_book_blacklist_id) => {
2112 tracing::info!(
2113 "Using existing OrderBookBlacklist: {}",
2114 order_book_blacklist_id
2115 );
2116 Ok(Some(order_book_blacklist_id))
2117 }
2118 None => {
2119 if !deploy_blacklist {
2120 return Ok(None);
2121 }
2122 tracing::info!("Deploying OrderBookBlacklist");
2123 let order_book_blacklist = OrderBookDeploy::deploy_order_book_blacklist(
2124 &deployer_wallet,
2125 &Identity::Address(ViewOnlyAccount::address(&deployer_wallet)),
2126 &OrderBookDeployConfig {
2127 salt,
2128 ..Default::default()
2129 },
2130 )
2131 .await?;
2132 tracing::info!("OrderBookBlacklist: {}", order_book_blacklist.contract_id());
2133 Ok(Some(order_book_blacklist.contract_id()))
2134 }
2135 }
2136}
2137
2138async fn deploy_order_book_whitelist<W>(
2139 deployer_wallet: W,
2140 deploy_whitelist: bool,
2141 order_book_whitelist_id: Option<ContractId>,
2142 salt: Salt,
2143) -> anyhow::Result<Option<ContractId>>
2144where
2145 W: Account + ViewOnlyAccount + Clone + 'static,
2146{
2147 match (order_book_whitelist_id, deploy_whitelist) {
2148 (Some(order_book_whitelist_id), false)
2149 | (Some(order_book_whitelist_id), true) => {
2150 tracing::info!(
2151 "Using existing OrderBookWhitelist: {}",
2152 order_book_whitelist_id
2153 );
2154 Ok(Some(order_book_whitelist_id))
2155 }
2156 (None, false) => Ok(None),
2157 (None, true) => {
2158 tracing::info!("Deploying OrderBookWhitelist");
2159 let trade_account_whitelist = OrderBookDeploy::deploy_order_book_whitelist(
2160 &deployer_wallet,
2161 &Identity::Address(ViewOnlyAccount::address(&deployer_wallet)),
2162 &OrderBookDeployConfig {
2163 salt,
2164 ..Default::default()
2165 },
2166 )
2167 .await?;
2168 tracing::info!(
2169 "OrderBookWhitelist: {}",
2170 trade_account_whitelist.contract_id()
2171 );
2172 Ok(Some(trade_account_whitelist.contract_id()))
2173 }
2174 }
2175}
2176
2177async fn load_or_recover_trade_account_oracle<W>(
2181 deployer_wallet: &W,
2182 oracle_id: ContractId,
2183) -> anyhow::Result<(TradeAccountDeploy<W>, ContractId)>
2184where
2185 W: Account + ViewOnlyAccount + Clone + 'static,
2186{
2187 let oracle = TradingAccountOracle::new(oracle_id, deployer_wallet.clone());
2188 let impl_id = oracle
2189 .methods()
2190 .get_trade_account_impl()
2191 .simulate(Execution::state_read_only())
2192 .await?
2193 .value;
2194
2195 let blob_id = match impl_id {
2196 Some(id) => id,
2197 None => {
2198 tracing::info!(
2199 "Trade account implementation not set on oracle {}, deploying...",
2200 oracle_id
2201 );
2202 let blob = TradeAccountDeploy::trade_account_blob(
2203 deployer_wallet,
2204 &Default::default(),
2205 )
2206 .await?;
2207 TradeAccountDeploy::deploy_trade_account_blob(
2208 deployer_wallet,
2209 &DeployConfig::Latest(Default::default()),
2210 )
2211 .await?;
2212 oracle
2213 .methods()
2214 .set_trade_account_impl(ContractId::from(blob.id))
2215 .call()
2216 .await?;
2217 ContractId::from(blob.id)
2218 }
2219 };
2220
2221 let deploy = TradeAccountDeploy {
2222 oracle,
2223 oracle_id,
2224 trade_account_blob_id: blob_id.into(),
2225 deployer_wallet: deployer_wallet.clone(),
2226 proxy: None,
2227 proxy_id: None,
2228 };
2229 Ok((deploy, blob_id))
2230}
2231
2232async fn deploy_trade_account_oracle<W>(
2233 deployer_wallet: W,
2234 should_upgrade_bytecode: bool,
2235 trade_account_oracle_id: Option<ContractId>,
2236 salt: Salt,
2237) -> anyhow::Result<(TradeAccountDeploy<W>, ContractId)>
2238where
2239 W: Account + ViewOnlyAccount + Clone + 'static,
2240{
2241 let (trade_account_oracle_deploy, mut trade_account_blob_id) =
2242 match trade_account_oracle_id {
2243 Some(oracle_id) => {
2244 load_or_recover_trade_account_oracle(&deployer_wallet, oracle_id).await?
2245 }
2246 None => {
2247 let deploy = TradeAccountDeploy::deploy(
2248 &deployer_wallet,
2249 &DeployConfig::Latest(TradeAccountDeployConfig {
2250 salt,
2251 ..Default::default()
2252 }),
2253 )
2254 .await?;
2255 let blob_id = deploy
2256 .oracle
2257 .methods()
2258 .get_trade_account_impl()
2259 .simulate(Execution::state_read_only())
2260 .await?
2261 .value
2262 .context("Trade account impl should exist after fresh deploy")?;
2263 (deploy, blob_id)
2264 }
2265 };
2266 tracing::info!(
2267 "TradeAccountOracle: {}",
2268 trade_account_oracle_deploy.oracle_id
2269 );
2270
2271 if should_upgrade_bytecode {
2272 let trade_account_blob =
2273 TradeAccountDeploy::trade_account_blob(&deployer_wallet, &Default::default())
2274 .await?;
2275 if ContractId::from(trade_account_blob.id) != trade_account_blob_id {
2276 tracing::info!(
2277 "Update TradeAccountImpl on Oracle from {:?} to new blob {:?}",
2278 trade_account_blob_id,
2279 ContractId::from(trade_account_blob.id)
2280 );
2281 TradeAccountDeploy::deploy_trade_account_blob(
2282 &deployer_wallet,
2283 &DeployConfig::Latest(Default::default()),
2284 )
2285 .await?;
2286 trade_account_oracle_deploy
2287 .oracle
2288 .methods()
2289 .set_trade_account_impl(ContractId::from(trade_account_blob.id))
2290 .call()
2291 .await?;
2292 trade_account_blob_id = ContractId::from(trade_account_blob.id);
2293 }
2294 }
2295
2296 Ok((trade_account_oracle_deploy, trade_account_blob_id))
2297}
2298
2299async fn deploy_trial_trade_account_oracle<W>(
2307 deployer_wallet: W,
2308 should_upgrade_bytecode: bool,
2309 trial_trade_account_oracle_id: Option<ContractId>,
2310 trial_cosigner: Option<fuels::types::Address>,
2311 salt: Salt,
2312) -> anyhow::Result<ContractId>
2313where
2314 W: Account + ViewOnlyAccount + Clone + 'static,
2315{
2316 let mut trial_deploy_config = TrialTradeAccountDeployConfig {
2317 salt,
2318 ..Default::default()
2319 };
2320 if let Some(cosigner) = trial_cosigner {
2321 trial_deploy_config = trial_deploy_config.with_cosigner(cosigner);
2322 }
2323 let deploy_config = TrialDeployConfig::Latest(trial_deploy_config);
2324
2325 let oracle_id = match trial_trade_account_oracle_id {
2326 None => {
2327 let trial_deploy =
2328 TrialTradeAccountDeploy::deploy(&deployer_wallet, &deploy_config).await?;
2329 tracing::info!(
2330 "TrialTradeAccountOracle: {} (implementation {:?}, cosigner {:?})",
2331 trial_deploy.oracle_id,
2332 trial_deploy.trial_trade_account_blob_id,
2333 trial_cosigner,
2334 );
2335 trial_deploy.oracle_id
2336 }
2337 Some(oracle_id) => {
2338 let current_trial_impl =
2339 TrialTradingAccountOracle::new(oracle_id, deployer_wallet.clone())
2340 .methods()
2341 .get_trial_account_impl()
2342 .simulate(Execution::state_read_only())
2343 .await?
2344 .value;
2345 if current_trial_impl.is_none()
2346 || should_upgrade_bytecode
2347 || trial_cosigner.is_some()
2348 {
2349 let trial_deploy = TrialTradeAccountDeploy::deploy_to_oracle(
2350 &deployer_wallet,
2351 oracle_id,
2352 &deploy_config,
2353 )
2354 .await?;
2355 tracing::info!(
2356 "Trial implementation {:?} deployed to oracle {} (cosigner {:?})",
2357 trial_deploy.trial_trade_account_blob_id,
2358 oracle_id,
2359 trial_cosigner,
2360 );
2361 }
2362 oracle_id
2363 }
2364 };
2365
2366 Ok(oracle_id)
2367}
2368
2369async fn deploy_trade_account_registry<W>(
2370 deployer_wallet: W,
2371 should_upgrade_bytecode: bool,
2372 trade_account_deploy: TradeAccountDeploy<W>,
2373 trial_trade_account_oracle_id: ContractId,
2374 trade_account_registry_id: Option<ContractId>,
2375 salt: Salt,
2376) -> anyhow::Result<(TradeAccountRegistryManager<W>, ContractId)>
2377where
2378 W: Account + ViewOnlyAccount + Clone + 'static,
2379{
2380 let trade_account_oracle_id = trade_account_deploy.oracle_id;
2381 let trade_account_registry = match trade_account_registry_id {
2382 Some(trade_account_registry_contract_id) => TradeAccountRegistryManager::new(
2383 deployer_wallet.clone(),
2384 trade_account_registry_contract_id,
2385 ),
2386 None => {
2387 let trade_account_registry_deploy_config = TradeAccountRegistryDeployConfig {
2388 salt,
2389 ..Default::default()
2390 };
2391 TradeAccountRegistryManager::deploy(
2392 &deployer_wallet,
2393 trade_account_oracle_id,
2394 trial_trade_account_oracle_id,
2395 &trade_account_registry_deploy_config,
2396 )
2397 .await?
2398 }
2399 };
2400 tracing::info!(
2401 "TradeAccountRegistry: {}",
2402 trade_account_registry.contract_id
2403 );
2404 let mut trade_account_registry_blob_id = match trade_account_registry
2405 .registry_proxy
2406 .methods()
2407 .proxy_target()
2408 .simulate(Execution::state_read_only())
2409 .await?
2410 .value
2411 {
2412 Some(blob_id) => blob_id,
2413 None => {
2414 tracing::info!("TradeAccountRegistry proxy target not set, initializing...");
2415 trade_account_registry
2419 .registry_proxy
2420 .methods()
2421 .initialize_proxy()
2422 .call()
2423 .await?;
2424 trade_account_registry
2425 .registry
2426 .methods()
2427 .initialize()
2428 .call()
2429 .await?;
2430 trade_account_registry
2431 .registry_proxy
2432 .methods()
2433 .proxy_target()
2434 .simulate(Execution::state_read_only())
2435 .await?
2436 .value
2437 .context("TradeAccountRegistry proxy target should be set after initialization")?
2438 }
2439 };
2440
2441 if should_upgrade_bytecode {
2442 let trade_account_registry_deploy_config = TradeAccountRegistryDeployConfig {
2446 registry_config: trade_account_registry
2447 .live_prop_config(TradeAccountRegistryConfigurables::default())
2448 .await?,
2449 ..Default::default()
2450 };
2451 let trade_account_proxy_blob = TradeAccountRegistryManager::register_proxy_blob(
2452 &deployer_wallet,
2453 &trade_account_registry_deploy_config,
2454 )
2455 .await?;
2456 let trial_trade_account_proxy_blob =
2457 TradeAccountRegistryManager::register_trial_proxy_blob(
2458 &deployer_wallet,
2459 &trade_account_registry_deploy_config,
2460 )
2461 .await?;
2462
2463 let trade_account_register_blob = TradeAccountRegistryManager::register_blob(
2464 &deployer_wallet,
2465 trade_account_oracle_id,
2466 trial_trade_account_oracle_id,
2467 trade_account_proxy_blob.id,
2468 trial_trade_account_proxy_blob.id,
2469 &trade_account_registry_deploy_config,
2470 )
2471 .await?;
2472
2473 if trade_account_registry_blob_id
2474 != ContractId::from(trade_account_register_blob.id)
2475 {
2476 tracing::info!(
2477 "Upgrade TradeAccountRegistry blob from {:?} to {:?}",
2478 trade_account_registry.contract_id,
2479 ContractId::from(trade_account_register_blob.id)
2480 );
2481 trade_account_registry
2485 .upgrade(
2486 trade_account_oracle_id,
2487 trial_trade_account_oracle_id,
2488 &trade_account_registry_deploy_config,
2489 )
2490 .await?;
2491 trade_account_registry_blob_id = trade_account_register_blob.id.into();
2492 }
2493 }
2494 Ok((trade_account_registry, trade_account_registry_blob_id))
2495}
2496
2497async fn deploy_order_book_registry<W>(
2498 deployer_wallet: W,
2499 should_upgrade_bytecode: bool,
2500 order_book_registry_id: Option<ContractId>,
2501 salt: Salt,
2502) -> anyhow::Result<(OrderBookRegistryManager<W>, ContractId)>
2503where
2504 W: Account + ViewOnlyAccount + Clone + 'static,
2505{
2506 let order_book_registry = match order_book_registry_id {
2507 Some(registry_contract_id) => {
2508 OrderBookRegistryManager::new(deployer_wallet.clone(), registry_contract_id)
2509 }
2510 None => {
2511 OrderBookRegistryManager::deploy(
2512 &deployer_wallet,
2513 &OrderBookRegistryDeployConfig {
2514 salt,
2515 ..Default::default()
2516 },
2517 )
2518 .await?
2519 }
2520 };
2521 tracing::info!("OrderBookRegistry: {}", order_book_registry.contract_id);
2522 let mut order_book_registry_blob_id = match order_book_registry
2523 .registry_proxy
2524 .methods()
2525 .proxy_target()
2526 .simulate(Execution::state_read_only())
2527 .await?
2528 .value
2529 {
2530 Some(blob_id) => blob_id,
2531 None => {
2532 tracing::info!("OrderBookRegistry proxy target not set, initializing...");
2533 order_book_registry
2537 .registry_proxy
2538 .methods()
2539 .initialize_proxy()
2540 .call()
2541 .await?;
2542 order_book_registry
2543 .registry
2544 .methods()
2545 .initialize()
2546 .call()
2547 .await?;
2548 order_book_registry
2549 .registry_proxy
2550 .methods()
2551 .proxy_target()
2552 .simulate(Execution::state_read_only())
2553 .await?
2554 .value
2555 .context(
2556 "OrderBookRegistry proxy target should be set after initialization",
2557 )?
2558 }
2559 };
2560
2561 if should_upgrade_bytecode {
2562 let order_book_register_deploy_config = OrderBookRegistryDeployConfig::default();
2563 let order_book_register_blob = OrderBookRegistryManager::register_blob(
2564 &deployer_wallet,
2565 &order_book_register_deploy_config,
2566 )
2567 .await?;
2568 if order_book_registry_blob_id != order_book_register_blob.id.into() {
2569 tracing::info!(
2570 "Upgrade OrderBookRegistry blob from {:?} to {:?}",
2571 order_book_registry.contract_id,
2572 ContractId::from(order_book_register_blob.id)
2573 );
2574 order_book_registry
2575 .upgrade(&order_book_register_deploy_config)
2576 .await?;
2577 order_book_registry_blob_id = order_book_register_blob.id.into();
2578 }
2579 }
2580
2581 Ok((order_book_registry, order_book_registry_blob_id))
2582}
2583
2584async fn deploy_order_books<W>(
2585 deployer_wallet: W,
2586 should_upgrade_bytecode: bool,
2587 order_book_blacklist_id: Option<ContractId>,
2588 order_book_whitelist_id: Option<ContractId>,
2589 order_book_registry: OrderBookRegistryManager<W>,
2590 order_book_configs: &mut [OrderBookConfig],
2591 ownership_options: OwnershipTransferOptions,
2592) -> anyhow::Result<Vec<OrderBookConfig>>
2593where
2594 W: Account + ViewOnlyAccount + Clone + 'static,
2595{
2596 audit_order_book_precision(order_book_configs)?;
2601
2602 let mut pairs: Vec<OrderBookConfig> = Vec::with_capacity(order_book_configs.len());
2603
2604 for order_book_config in order_book_configs.iter_mut() {
2605 let pair = deploy_single_order_book(
2606 &deployer_wallet,
2607 should_upgrade_bytecode,
2608 order_book_blacklist_id,
2609 order_book_whitelist_id,
2610 &order_book_registry,
2611 order_book_config,
2612 &ownership_options,
2613 )
2614 .await?;
2615 pairs.push(pair);
2616 }
2617
2618 Ok(pairs)
2619}
2620
2621async fn deploy_single_order_book<W>(
2622 deployer_wallet: &W,
2623 should_upgrade_bytecode: bool,
2624 order_book_blacklist_id: Option<ContractId>,
2625 order_book_whitelist_id: Option<ContractId>,
2626 order_book_registry: &OrderBookRegistryManager<W>,
2627 order_book_config: &mut OrderBookConfig,
2628 ownership_options: &OwnershipTransferOptions,
2629) -> anyhow::Result<OrderBookConfig>
2630where
2631 W: Account + ViewOnlyAccount + Clone + 'static,
2632{
2633 let market_symbol = format!(
2634 "{}/{}",
2635 order_book_config.base.symbol, order_book_config.quote.symbol
2636 );
2637 let market_id = MarketIdAssets {
2638 base_asset: order_book_config.base.asset,
2639 quote_asset: order_book_config.quote.asset,
2640 };
2641 let order_book_configurables = build_order_book_configurables(
2642 order_book_config,
2643 order_book_blacklist_id,
2644 order_book_whitelist_id,
2645 deployer_wallet,
2646 )?;
2647
2648 let order_book = load_or_deploy_order_book(
2649 deployer_wallet,
2650 order_book_registry,
2651 &market_id,
2652 &market_symbol,
2653 &order_book_configurables,
2654 order_book_config,
2655 )
2656 .await?;
2657
2658 tracing::info!(
2659 "[{}] OrderBook: {}",
2660 market_symbol,
2661 order_book.contract.contract_id()
2662 );
2663
2664 let order_book_blob_id = maybe_upgrade_order_book(
2665 deployer_wallet,
2666 should_upgrade_bytecode,
2667 &order_book,
2668 order_book_config,
2669 order_book_configurables,
2670 &market_symbol,
2671 )
2672 .await?;
2673
2674 set_order_book_maintainer(&order_book, ownership_options, &market_symbol).await?;
2678
2679 sidecar::deploy_sidecar(
2680 deployer_wallet,
2681 &order_book,
2682 order_book_config,
2683 should_upgrade_bytecode,
2684 ownership_options,
2685 )
2686 .await?;
2687 sidecar::grant_house_roles(&order_book, &order_book_config.house_identities).await?;
2688
2689 transfer_order_book_ownership(&order_book, ownership_options, &market_symbol).await?;
2690
2691 order_book_config.contract_id = Some(order_book.contract.contract_id());
2692 order_book_config.blob_id = order_book_blob_id.into();
2693
2694 Ok(order_book_config.clone())
2695}
2696
2697pub fn audit_order_book_precision(configs: &[OrderBookConfig]) -> anyhow::Result<()> {
2705 for config in configs {
2706 order_book_precision_exponents(config)?;
2707 }
2708 Ok(())
2709}
2710
2711fn order_book_precision_exponents(config: &OrderBookConfig) -> anyhow::Result<(u8, u8)> {
2731 let price_precision = config
2732 .quote
2733 .decimals
2734 .checked_sub(config.quote.max_precision)
2735 .ok_or_else(|| {
2736 anyhow::anyhow!(
2737 "quote max_precision ({}) exceeds decimals ({})",
2738 config.quote.max_precision,
2739 config.quote.decimals
2740 )
2741 })?;
2742 let quantity_precision = config
2743 .base
2744 .decimals
2745 .checked_sub(config.base.max_precision)
2746 .ok_or_else(|| {
2747 anyhow::anyhow!(
2748 "base max_precision ({}) exceeds decimals ({})",
2749 config.base.max_precision,
2750 config.base.decimals
2751 )
2752 })?;
2753
2754 let spent = config.base.max_precision as u16 + config.quote.max_precision as u16;
2757 let budget = config.quote.decimals as u16;
2758
2759 if config.allow_fractional_price {
2760 if spent > budget {
2761 tracing::warn!(
2762 "{}/{}: base max_precision ({}) + quote max_precision ({}) = {spent} \
2763 exceeds the quote's {budget} decimals; ALLOW_FRACTIONAL_PRICE is set, \
2764 so notionals will silently truncate rather than revert",
2765 config.base.symbol,
2766 config.quote.symbol,
2767 config.base.max_precision,
2768 config.quote.max_precision,
2769 );
2770 }
2771 } else {
2772 anyhow::ensure!(
2773 spent <= budget,
2774 "{}/{}: base max_precision ({}) + quote max_precision ({}) = {spent} \
2775 exceeds the quote's {budget} decimals, so every order would revert \
2776 with FractionalPrice. Lower one of the two until they sum to {budget} \
2777 or less.",
2778 config.base.symbol,
2779 config.quote.symbol,
2780 config.base.max_precision,
2781 config.quote.max_precision,
2782 );
2783 }
2784
2785 Ok((price_precision, quantity_precision))
2786}
2787
2788fn build_order_book_configurables<W: ViewOnlyAccount>(
2789 config: &OrderBookConfig,
2790 order_book_blacklist_id: Option<ContractId>,
2791 order_book_whitelist_id: Option<ContractId>,
2792 deployer_wallet: &W,
2793) -> anyhow::Result<OrderBookConfigurables> {
2794 let (price_precision, quantity_precision) = order_book_precision_exponents(config)?;
2795
2796 Ok(OrderBookConfigurables::default()
2797 .with_MIN_ORDER(config.min_order)?
2798 .with_ALLOW_FRACTIONAL_PRICE(config.allow_fractional_price)?
2799 .with_TAKER_FEE(config.taker_fee.into())?
2800 .with_MAKER_FEE(config.maker_fee.into())?
2801 .with_DUST(config.dust)?
2802 .with_PRICE_WINDOW(config.price_window as u64)?
2803 .with_BASE_DECIMALS(10u64.pow(config.base.decimals as u32))?
2804 .with_QUOTE_DECIMALS(10u64.pow(config.quote.decimals as u32))?
2805 .with_BASE_SYMBOL(SizedAsciiString::new_with_right_whitespace_padding(
2806 config.base.symbol.clone(),
2807 )?)?
2808 .with_QUOTE_SYMBOL(SizedAsciiString::new_with_right_whitespace_padding(
2809 config.quote.symbol.clone(),
2810 )?)?
2811 .with_PRICE_PRECISION(10u64.pow(price_precision as u32))?
2812 .with_QUANTITY_PRECISION(10u64.pow(quantity_precision as u32))?
2813 .with_INITIAL_OWNER(o2_tools::order_book_deploy::State::Initialized(
2814 Identity::Address(ViewOnlyAccount::address(deployer_wallet)),
2815 ))?
2816 .with_WHITE_LIST_CONTRACT(order_book_whitelist_id)?
2817 .with_BLACK_LIST_CONTRACT(order_book_blacklist_id)?)
2818}
2819
2820async fn load_or_deploy_order_book<W>(
2821 deployer_wallet: &W,
2822 order_book_registry: &OrderBookRegistryManager<W>,
2823 market_id: &MarketIdAssets,
2824 market_symbol: &str,
2825 order_book_configurables: &OrderBookConfigurables,
2826 order_book_config: &OrderBookConfig,
2827) -> anyhow::Result<OrderBookManager<W>>
2828where
2829 W: Account + ViewOnlyAccount + Clone + 'static,
2830{
2831 let register_contract_id = order_book_registry
2832 .registry
2833 .methods()
2834 .get_order_book(to_registry_market_id(market_id))
2835 .simulate(Execution::state_read_only())
2836 .await?
2837 .value;
2838
2839 match register_contract_id {
2840 Some(contract_id) => {
2841 let order_book_deploy = OrderBookDeploy::new(
2842 deployer_wallet.clone(),
2843 contract_id,
2844 market_id.base_asset,
2845 market_id.quote_asset,
2846 );
2847 let proxy_target = order_book_deploy
2850 .order_book_proxy
2851 .methods()
2852 .proxy_target()
2853 .simulate(Execution::state_read_only())
2854 .await?
2855 .value;
2856 if proxy_target.is_none() {
2857 tracing::info!(
2858 "[{}] Proxy target not set, initializing...",
2859 market_symbol
2860 );
2861 order_book_deploy.initialize().await?;
2862 }
2863 Ok(OrderBookManager::new(
2864 deployer_wallet,
2865 10u64.pow(order_book_config.base.decimals as u32),
2866 10u64.pow(order_book_config.quote.decimals as u32),
2867 &order_book_deploy,
2868 ))
2869 }
2870 None => {
2871 let (order_book_deployment, initialization_required) =
2872 OrderBookDeploy::deploy_without_initialization(
2873 deployer_wallet,
2874 market_id.base_asset,
2875 market_id.quote_asset,
2876 &OrderBookDeployConfig {
2877 order_book_configurables: order_book_configurables.clone(),
2878 salt: Salt::from(*order_book_registry.contract_id),
2879 ..Default::default()
2880 },
2881 )
2882 .await?;
2883
2884 order_book_registry
2885 .register_order_book(
2886 to_registry_market_id(market_id),
2887 order_book_deployment.contract_id,
2888 )
2889 .await?;
2890
2891 if initialization_required {
2892 order_book_deployment.initialize().await?;
2893 }
2894 Ok(OrderBookManager::new(
2895 deployer_wallet,
2896 10u64.pow(order_book_config.base.decimals as u32),
2897 10u64.pow(order_book_config.quote.decimals as u32),
2898 &order_book_deployment,
2899 ))
2900 }
2901 }
2902}
2903
2904async fn maybe_upgrade_order_book<W>(
2905 deployer_wallet: &W,
2906 should_upgrade_bytecode: bool,
2907 order_book: &OrderBookManager<W>,
2908 order_book_config: &OrderBookConfig,
2909 order_book_configurables: OrderBookConfigurables,
2910 market_symbol: &str,
2911) -> anyhow::Result<ContractId>
2912where
2913 W: Account + ViewOnlyAccount + Clone + 'static,
2914{
2915 let mut order_book_blob_id = order_book
2916 .proxy
2917 .methods()
2918 .proxy_target()
2919 .simulate(Execution::state_read_only())
2920 .await?
2921 .value
2922 .context("Order book proxy target should be set after initialization")?;
2923
2924 if should_upgrade_bytecode {
2925 let order_book_deploy_config = OrderBookDeployConfig {
2926 order_book_configurables,
2927 ..Default::default()
2928 };
2929 let order_book_deploy = OrderBookDeploy::new(
2930 deployer_wallet.clone(),
2931 order_book.contract.contract_id(),
2932 order_book_config.base.asset,
2933 order_book_config.quote.asset,
2934 );
2935 let order_book_manager = OrderBookManager::new(
2936 deployer_wallet,
2937 10u64.pow(order_book_config.base.decimals as u32),
2938 10u64.pow(order_book_config.quote.decimals as u32),
2939 &order_book_deploy,
2940 );
2941 let order_book_blob = OrderBookDeploy::order_book_blob(
2942 deployer_wallet,
2943 order_book_config.base.asset,
2944 order_book_config.quote.asset,
2945 &order_book_deploy_config,
2946 )
2947 .await?;
2948
2949 if order_book_blob_id != order_book_blob.id.into() {
2950 tracing::info!(
2951 "[{}] Upgrade OrderBook blob from {:?} to {:?}",
2952 market_symbol,
2953 order_book_blob_id,
2954 ContractId::from(order_book_blob.id)
2955 );
2956 order_book_manager
2957 .upgrade(&order_book_deploy_config)
2958 .await?;
2959 tracing::info!(
2960 "[{}] Emit new configuration event for {}",
2961 market_symbol,
2962 order_book.contract.contract_id()
2963 );
2964 order_book_manager.emit_config().await?;
2965 order_book_blob_id = order_book_blob.id.into();
2966 }
2967 }
2968
2969 Ok(order_book_blob_id)
2970}
2971
2972async fn set_order_book_maintainer<W>(
2973 order_book: &OrderBookManager<W>,
2974 ownership_options: &OwnershipTransferOptions,
2975 market_symbol: &str,
2976) -> anyhow::Result<()>
2977where
2978 W: Account + ViewOnlyAccount + Clone + 'static,
2979{
2980 for account in &ownership_options.revoke_orderbook_maintainers {
2984 tracing::info!(
2985 "[{}] Revoking ORDERBOOK_MAINTAINER_ROLE from {}",
2986 market_symbol,
2987 account
2988 );
2989 order_book
2990 .contract
2991 .methods()
2992 .owner_revoke_role(ORDERBOOK_MAINTAINER_ROLE, Identity::Address(*account))
2993 .call()
2994 .await?;
2995 }
2996
2997 for account in &ownership_options.new_orderbook_maintainers {
2998 tracing::info!(
2999 "[{}] Granting ORDERBOOK_MAINTAINER_ROLE to {}",
3000 market_symbol,
3001 account
3002 );
3003 order_book
3004 .contract
3005 .methods()
3006 .owner_grant_role(ORDERBOOK_MAINTAINER_ROLE, Identity::Address(*account))
3007 .call()
3008 .await?;
3009 }
3010
3011 Ok(())
3012}
3013
3014async fn transfer_order_book_ownership<W>(
3015 order_book: &OrderBookManager<W>,
3016 ownership_options: &OwnershipTransferOptions,
3017 market_symbol: &str,
3018) -> anyhow::Result<()>
3019where
3020 W: Account + ViewOnlyAccount + Clone + 'static,
3021{
3022 if let Some(new_owner) = ownership_options.new_proxy_owner {
3023 let new_identity = Identity::Address(new_owner);
3024 tracing::info!(
3025 "[{}] Transferring OrderBook proxy ownership to {}",
3026 market_symbol,
3027 new_owner
3028 );
3029 order_book
3030 .proxy
3031 .methods()
3032 .set_owner(new_identity)
3033 .call()
3034 .await?;
3035 }
3036
3037 if let Some(new_owner) = ownership_options.new_contract_owner {
3038 let new_identity = Identity::Address(new_owner);
3039 tracing::info!(
3040 "[{}] Transferring OrderBook contract ownership to {}",
3041 market_symbol,
3042 new_owner
3043 );
3044 order_book
3045 .contract
3046 .methods()
3047 .transfer_ownership(new_identity)
3048 .call()
3049 .await?;
3050 }
3051
3052 Ok(())
3053}
3054
3055#[cfg(test)]
3056mod tests {
3057 use super::*;
3058
3059 #[test]
3060 fn load_config_empty_path_returns_default() {
3061 let result: MarketsConfigPartial = load_config_from_file("").unwrap();
3062 assert!(result.pairs.is_empty());
3063 }
3064
3065 #[test]
3066 fn load_config_missing_file_errors() {
3067 let result: Result<MarketsConfigPartial, _> =
3068 load_config_from_file("nonexistent_file_12345.json");
3069 assert!(result.is_err());
3070 }
3071
3072 #[test]
3073 fn checked_sub_catches_overflow() {
3074 let decimals: u32 = 6;
3076 let max_precision: u32 = 8; let result = decimals.checked_sub(max_precision);
3079 assert!(
3080 result.is_none(),
3081 "should return None when max_precision > decimals"
3082 );
3083
3084 let result = 9u32.checked_sub(6);
3086 assert_eq!(result, Some(3));
3087 }
3088
3089 #[test]
3090 fn markets_config_partial_default_has_empty_pairs() {
3091 let config = MarketsConfigPartial::default();
3092 assert!(config.pairs.is_empty());
3093 }
3094
3095 pub(super) const LIVE_LEGACY_TIER: &str = r#"{
3098 "tier_id": 11,
3099 "line": "2500000000000",
3100 "leverage": 40,
3101 "duration": 0,
3102 "maintenance_bps": 150,
3103 "open_buffer_bps": 151,
3104 "liq_price_factor": 9900,
3105 "prolong_fee": ["10", "18", "60", "200"],
3106 "max_credit_line_bps": 20000,
3107 "max_price_age": 300,
3108 "open_fee": "0",
3109 "profit_share_bps": 0,
3110 "price_band_bps": 85,
3111 "markets": ["fwBTC/fUSDC", "fETH/fUSDC", "fXAUT/fUSDC"]
3112 }"#;
3113
3114 fn turbo_tier(turbo: &str) -> String {
3115 format!(
3116 r#"{{
3117 "tier_id": 33,
3118 "line": "10000000000000",
3119 "leverage": 40,
3120 "duration": 604800,
3121 "maintenance_bps": 100,
3122 "open_buffer_bps": 101,
3123 "liq_price_factor": 9900,
3124 "prolong_fee": ["0", "0", "0", "0"],
3125 "max_credit_line_bps": 20000,
3126 "max_price_age": 300,
3127 "open_fee": "0",
3128 "profit_share_bps": 0,
3129 "price_band_bps": 100,
3130 "markets": ["fwBTC/fUSDC"],
3131 "turbo": {turbo}
3132 }}"#
3133 )
3134 }
3135
3136 const SHIPPED_TERMS: &str = r#"{
3137 "max_loss_bps": [150, 200, 250, 300],
3138 "rollover_profit_bps": 900,
3139 "max_rollovers": 3
3140 }"#;
3141
3142 #[test]
3145 fn a_legacy_tier_parses_with_enabled_default_but_is_not_publishable() {
3146 let tier: MarginTierConfig = serde_json::from_str(LIVE_LEGACY_TIER).unwrap();
3147 assert_eq!(tier.tier_id, 11);
3148 assert!(tier.turbo.is_none(), "no terms means the legacy cohort");
3149 assert!(tier.enabled, "the historical parsing default stays enabled");
3150 assert!(!tier.sidecar, "omitted selection keeps canonical books");
3151 assert!(
3152 required_turbo_terms(&tier)
3153 .unwrap_err()
3154 .to_string()
3155 .contains("legacy tier publishing is disabled")
3156 );
3157 }
3158
3159 #[test]
3163 fn a_misspelled_tier_key_is_refused_rather_than_ignored() {
3164 let typo = LIVE_LEGACY_TIER.replace("\"leverage\"", "\"leverag\"");
3165 let err = serde_json::from_str::<MarginTierConfig>(&typo).unwrap_err();
3166 assert!(err.to_string().contains("leverag"), "{err}");
3167
3168 let typo = turbo_tier(&SHIPPED_TERMS.replace("max_loss_bps", "max_los_bps"));
3169 let err = serde_json::from_str::<MarginTierConfig>(&typo).unwrap_err();
3170 assert!(err.to_string().contains("max_los_bps"), "{err}");
3171 }
3172
3173 #[test]
3174 fn a_prepaid_tier_carries_its_terms_through_to_the_pools_shape() {
3175 let tier: MarginTierConfig =
3176 serde_json::from_str(&turbo_tier(SHIPPED_TERMS)).unwrap();
3177 assert!(!tier.sidecar, "prepaid terms do not select the sidecar");
3178 let turbo = tier.turbo.as_ref().expect("the tier declares terms");
3179 let terms = o2_tools::prop::TurboTerms::from(turbo);
3180 assert_eq!(terms.max_loss_bps, [150, 200, 250, 300]);
3181 assert_eq!(terms.rollover_profit_bps, 900);
3182 assert_eq!(terms.max_rollovers, 3);
3183
3184 let params = tier_params(&tier, 9).unwrap();
3185 validate_turbo_tier_config(&tier, ¶ms, &terms)
3186 .expect("the shipped shape is publishable");
3187 }
3188
3189 #[test]
3190 fn prepaid_durations_parse_exactly_and_allow_valid_rollover_terms() {
3191 for (duration, json_duration) in [
3192 (1, "1"),
3193 (21_600, "21600"),
3194 (86_400, "86400"),
3195 (604_800, "604800"),
3196 (1_209_600, "1209600"),
3197 (u64::MAX - 1, "18446744073709551614"),
3198 (u64::MAX, "18446744073709551615"),
3199 ] {
3200 let json = turbo_tier(SHIPPED_TERMS).replace(
3201 "\"duration\": 604800",
3202 &format!("\"duration\": {json_duration}"),
3203 );
3204 let tier: MarginTierConfig = serde_json::from_str(&json).unwrap();
3205 assert_eq!(tier.duration, duration);
3206 let params = tier_params(&tier, 9).unwrap();
3207 assert_eq!(params.duration, duration);
3208 let terms = o2_tools::prop::TurboTerms::from(tier.turbo.as_ref().unwrap());
3209 validate_turbo_tier_config(&tier, ¶ms, &terms)
3210 .expect("positive durations allow valid rollover terms, including perpetual tiers");
3211 }
3212 }
3213
3214 #[test]
3215 fn a_three_percent_drawdown_needs_no_rollover() {
3216 let json = turbo_tier(
3217 r#"{
3218 "max_loss_bps": [300, 0, 0, 0],
3219 "rollover_profit_bps": 0,
3220 "max_rollovers": 0
3221 }"#,
3222 );
3223 for duration in [21_600, 604_800, u64::MAX] {
3224 let mut tier: MarginTierConfig = serde_json::from_str(&json).unwrap();
3225 tier.duration = duration;
3226 let params = tier_params(&tier, 9).unwrap();
3227 let terms = o2_tools::prop::TurboTerms::from(tier.turbo.as_ref().unwrap());
3228 validate_turbo_tier_config(&tier, ¶ms, &terms)
3229 .expect("the line-based floor needs no rollover state");
3230 }
3231 }
3232
3233 #[test]
3234 fn daily_loss_policy_accepts_disabled_zero_and_the_full_basis_point_range() {
3235 let mut tier: MarginTierConfig =
3236 serde_json::from_str(&turbo_tier(SHIPPED_TERMS)).unwrap();
3237 let params = tier_params(&tier, 9).unwrap();
3238 let terms = required_turbo_terms(&tier).unwrap();
3239 for limit in [None, Some(0), Some(1), Some(10_000)] {
3240 tier.daily_loss_limit_bps = limit;
3241 validate_turbo_tier_config(&tier, ¶ms, &terms).unwrap();
3242 }
3243 }
3244
3245 #[test]
3249 fn a_config_the_pool_would_refuse_is_refused_before_it_costs_a_transaction() {
3250 let check = |mutate: &dyn Fn(&mut MarginTierConfig), expect: &str| {
3251 let mut tier: MarginTierConfig =
3252 serde_json::from_str(&turbo_tier(SHIPPED_TERMS)).unwrap();
3253 mutate(&mut tier);
3254 let params = tier_params(&tier, 9).unwrap();
3255 let terms = required_turbo_terms(&tier).unwrap();
3256 let err = validate_turbo_tier_config(&tier, ¶ms, &terms)
3257 .expect_err("must be refused");
3258 assert!(
3259 err.to_string().contains(expect),
3260 "expected {expect:?} in: {err}"
3261 );
3262 };
3263
3264 check(
3265 &|t| t.duration = 0,
3266 "duration` must be greater than 0 seconds",
3267 );
3268 check(&|t| t.line = 0, "line");
3269 check(&|t| t.leverage = 0, "leverage");
3270 check(&|t| t.leverage = t.line + 1, "leverage");
3271 check(&|t| t.maintenance_bps = 10_001, "maintenance_bps");
3272 check(&|t| t.open_buffer_bps = 10_001, "open_buffer_bps");
3273 check(
3274 &|t| t.open_buffer_bps = t.maintenance_bps,
3275 "above maintenance",
3276 );
3277 check(&|t| t.price_band_bps = 10_001, "price_band_bps");
3278 check(
3279 &|t| t.daily_loss_limit_bps = Some(10_001),
3280 "daily_loss_limit_bps",
3281 );
3282 check(
3283 &|t| t.daily_loss_limit_bps = Some(u64::MAX),
3284 "daily_loss_limit_bps",
3285 );
3286 check(&|t| t.liq_price_factor = 0, "liq_price_factor");
3287 check(&|t| t.liq_price_factor = 10_001, "liq_price_factor");
3288 check(&|t| t.max_credit_line_bps = u64::MAX, "cap does not fit");
3289 check(
3290 &|t| {
3291 t.open_buffer_bps = 9_999;
3292 t.max_credit_line_bps = 10_001;
3293 },
3294 "at or above the credit-line cap",
3295 );
3296 check(&|t| t.prolong_fee[2] = "60".to_owned(), "prolong_fee");
3297 check(&|t| t.profit_share_bps = 500, "profit_share_bps");
3298 for cap in [9_999, 10_000] {
3299 check(&|t| t.max_credit_line_bps = cap, "max_credit_line_bps");
3300 }
3301 check(&|t| t.open_buffer_bps = 251, "FROZEN");
3302 check(
3305 &|t| t.turbo.as_mut().unwrap().max_loss_bps = [0, 200, 250, 300],
3306 "step 0 is 0",
3307 );
3308 check(
3309 &|t| t.turbo.as_mut().unwrap().max_loss_bps = [0; 4],
3310 "step 0 is 0",
3311 );
3312 check(
3316 &|t| t.turbo.as_mut().unwrap().max_loss_bps = [150, 0, 250, 300],
3317 "step 1 is 0",
3318 );
3319 check(
3320 &|t| t.turbo.as_mut().unwrap().max_loss_bps = [150, 200, 250, 0],
3321 "step 3 is 0",
3322 );
3323 check(
3325 &|t| t.turbo.as_mut().unwrap().max_loss_bps = [150, 100, 250, 300],
3326 "NARROWER",
3327 );
3328 {
3330 let mut tier: MarginTierConfig =
3331 serde_json::from_str(&turbo_tier(SHIPPED_TERMS)).unwrap();
3332 let turbo = tier.turbo.as_mut().unwrap();
3333 turbo.max_rollovers = 1;
3334 turbo.max_loss_bps = [150, 200, 0, 0];
3335 let params = tier_params(&tier, 9).unwrap();
3336 let terms = required_turbo_terms(&tier).unwrap();
3337 validate_turbo_tier_config(&tier, ¶ms, &terms)
3338 .expect("only reachable steps are policed");
3339 }
3340 check(
3342 &|t| {
3343 let turbo = t.turbo.as_mut().unwrap();
3344 turbo.max_rollovers = 0;
3345 },
3346 "max_rollovers",
3347 );
3348 check(
3349 &|t| t.turbo.as_mut().unwrap().max_loss_bps = [150, 200, 250, 10_001],
3350 "<= 10000",
3351 );
3352 check(
3353 &|t| t.turbo.as_mut().unwrap().rollover_profit_bps = 10_001,
3354 "rollover_profit_bps",
3355 );
3356 }
3357
3358 #[test]
3359 fn legacy_tiers_may_be_retired_but_not_enabled() {
3360 for json in [LIVE_LEGACY_TIER.to_owned(), turbo_tier("null")] {
3361 let tier: MarginTierConfig = serde_json::from_str(&json).unwrap();
3362 let mut margin = MarginConfig {
3363 collateral_decimals: Some(9),
3364 tiers: vec![tier],
3365 ..Default::default()
3366 };
3367 let err = validate_margin_tier_catalogue(&margin)
3368 .unwrap_err()
3369 .to_string();
3370 assert!(
3371 err.contains(&format!("tier {}", margin.tiers[0].tier_id)),
3372 "{err}"
3373 );
3374 assert!(err.contains("legacy tier publishing is disabled"), "{err}");
3375 margin.tiers[0].enabled = false;
3376 validate_margin_tier_catalogue(&margin).unwrap();
3377 }
3378 }
3379
3380 #[test]
3381 fn the_retired_catalogue_accepts_historical_terms_and_perpetual_active_tiers() {
3382 let margin: MarginConfig = serde_json::from_str(include_str!(
3384 "../tests/fixtures/retired-margin-catalogue.json"
3385 ))
3386 .unwrap();
3387 validate_margin_tier_catalogue(&margin).unwrap();
3388 assert_eq!(margin.tiers.iter().filter(|tier| !tier.enabled).count(), 9);
3389 for tier in margin.tiers.iter().filter(|tier| tier.enabled) {
3390 assert!([14, 24, 34].contains(&tier.tier_id));
3391 assert_eq!(tier.duration, u64::MAX);
3392 }
3393 for index in 0..9 {
3394 let mut reenabled = margin.clone();
3395 reenabled.tiers[index].enabled = true;
3396 let error = validate_margin_tier_catalogue(&reenabled).unwrap_err();
3397 assert!(
3398 error
3399 .to_string()
3400 .contains(&format!("tier {}", margin.tiers[index].tier_id))
3401 );
3402 }
3403 }
3404
3405 #[test]
3406 fn a_catalogue_cannot_both_retire_and_enable_the_same_tier() {
3407 let mut tier: MarginTierConfig =
3408 serde_json::from_str(&turbo_tier(SHIPPED_TERMS)).unwrap();
3409 let mut margin = MarginConfig {
3410 collateral_decimals: Some(9),
3411 tiers: vec![tier.clone()],
3412 ..Default::default()
3413 };
3414 tier.enabled = false;
3415 margin.tiers.push(tier);
3416 let error = validate_margin_tier_catalogue(&margin).unwrap_err();
3417 assert!(error.to_string().contains("tier 33 appears more than once"));
3418 }
3419}
3420
3421#[cfg(test)]
3422mod precision_budget_tests {
3423 use super::*;
3424
3425 fn pair(base_max: u8, quote_max: u8) -> OrderBookConfig {
3428 let asset = |symbol: &str, max_precision: u8| AssetConfig {
3429 symbol: symbol.to_string(),
3430 asset: Default::default(),
3431 decimals: 9,
3432 min_precision: 0,
3433 max_precision,
3434 };
3435 OrderBookConfig {
3436 contract_id: None,
3437 blob_id: None,
3438 house_identities: vec![],
3439 sidecar: None,
3440 market_id: Default::default(),
3441 taker_fee: 100,
3442 maker_fee: 0,
3443 min_order: 1_000_000_000,
3444 dust: 1_000,
3445 price_window: 0,
3446 allow_fractional_price: false,
3447 base: asset("TKN", base_max),
3448 quote: asset("USDC", quote_max),
3449 }
3450 }
3451
3452 #[test]
3453 fn a_pair_that_spends_its_whole_budget_yields_the_contract_exponents() {
3454 let (price, quantity) = order_book_precision_exponents(&pair(2, 7)).unwrap();
3456 assert_eq!(price, 2, "PRICE_PRECISION = 10^(9-7)");
3457 assert_eq!(quantity, 7, "QUANTITY_PRECISION = 10^(9-2)");
3458 }
3459
3460 #[test]
3461 fn one_digit_over_budget_is_refused() {
3462 let err = order_book_precision_exponents(&pair(4, 6))
3464 .unwrap_err()
3465 .to_string();
3466 assert!(err.contains("FractionalPrice"), "{err}");
3467 assert!(err.contains("TKN/USDC"), "{err}");
3468 assert!(err.contains("= 10"), "{err}");
3469 }
3470
3471 #[test]
3472 fn fractional_prices_downgrade_the_breach_to_a_warning() {
3473 let mut config = pair(4, 6);
3476 config.allow_fractional_price = true;
3477 assert!(order_book_precision_exponents(&config).is_ok());
3478 }
3479
3480 #[test]
3481 fn max_precision_beyond_the_asset_decimals_is_still_refused() {
3482 let err = order_book_precision_exponents(&pair(0, 10))
3483 .unwrap_err()
3484 .to_string();
3485 assert!(err.contains("exceeds decimals"), "{err}");
3486 }
3487
3488 #[test]
3489 fn the_budget_tracks_the_quote_decimals_not_a_hardcoded_nine() {
3490 let mut config = pair(4, 2);
3491 config.quote.decimals = 6;
3492 assert!(
3493 order_book_precision_exponents(&config).is_ok(),
3494 "4 + 2 fits a 6-decimal quote"
3495 );
3496
3497 config.quote.max_precision = 3;
3498 assert!(
3499 order_book_precision_exponents(&config).is_err(),
3500 "4 + 3 does not"
3501 );
3502 }
3503}
3504
3505#[cfg(test)]
3506mod fee_amount_tests {
3507 use super::collateral_amount;
3508
3509 fn at9(value: &str) -> anyhow::Result<u64> {
3510 collateral_amount(value, 9, "tier 1 open_fee")
3511 }
3512
3513 #[test]
3514 fn whole_and_fractional_units_scale_by_the_asset_decimals() {
3515 assert_eq!(at9("7.5").unwrap(), 7_500_000_000);
3516 assert_eq!(at9("7").unwrap(), 7_000_000_000);
3517 assert_eq!(at9("0").unwrap(), 0);
3518 assert_eq!(at9("0.000000001").unwrap(), 1);
3519 assert_eq!(at9("13.5").unwrap(), 13_500_000_000);
3520 assert_eq!(at9("7.1").unwrap(), 7_100_000_000);
3522 }
3523
3524 #[test]
3525 fn a_figure_the_asset_cannot_represent_is_refused_not_rounded() {
3526 let err = at9("7.0000000001").unwrap_err().to_string();
3527 assert!(err.contains("10 decimal places"), "{err}");
3528 assert!(err.contains("tier 1 open_fee"), "{err}");
3529 }
3530
3531 #[test]
3532 fn junk_is_refused() {
3533 for bad in ["", " ", "-1", "1.2.3", "abc", "1e9", ".5", "1_000"] {
3534 assert!(at9(bad).is_err(), "`{bad}` should not parse");
3535 }
3536 }
3537
3538 #[test]
3539 fn an_amount_past_u64_is_refused_rather_than_wrapping() {
3540 assert!(at9("18446744074").is_err());
3541 assert_eq!(at9("18446744073.709551615").unwrap(), u64::MAX);
3542 }
3543
3544 #[test]
3545 fn other_decimal_scales_are_honoured() {
3546 assert_eq!(collateral_amount("7.5", 6, "f").unwrap(), 7_500_000);
3547 assert_eq!(collateral_amount("7.5", 1, "f").unwrap(), 75);
3548 assert!(collateral_amount("7.5", 0, "f").is_err());
3549 assert_eq!(collateral_amount("7", 0, "f").unwrap(), 7);
3550 }
3551}