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