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