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