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