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