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