1mod sidecar;
7#[cfg(test)]
8mod tier_book_tests;
9pub use sidecar::activate_sidecars;
10
11use anyhow::Context;
12use fuel_core_client::client::types::primitives::{
13 ContractId,
14 Salt,
15};
16use fuel_core_types::fuel_types::BlockHeight;
17use fuels::{
18 accounts::{
19 Account,
20 ViewOnlyAccount,
21 },
22 prelude::Execution,
23 types::{
24 Identity,
25 SizedAsciiString,
26 },
27};
28use o2_api_types::{
29 domain::book::{
30 AssetConfig,
31 MarketIdAssets,
32 OrderBookConfig,
33 SidecarConfig,
34 },
35 parse::HexDisplayFromStr,
36};
37use o2_tools::{
38 order_book::OrderBookManager,
39 order_book_deploy::{
40 OrderBookBlacklist,
41 OrderBookConfigurables,
42 OrderBookDeploy,
43 OrderBookDeployConfig,
44 OrderBookWhitelist,
45 },
46 order_book_registry::{
47 OrderBookRegistryDeployConfig,
48 OrderBookRegistryManager,
49 },
50 trade_account_deploy::{
51 DeployConfig,
52 TradeAccountDeploy,
53 TradeAccountDeployConfig,
54 TradingAccountOracle,
55 },
56 trade_account_registry::{
57 TradeAccountRegistryConfigurables,
58 TradeAccountRegistryDeployConfig,
59 TradeAccountRegistryManager,
60 },
61 trial_trade_account_deploy::{
62 DeployConfig as TrialDeployConfig,
63 TrialTradeAccountDeploy,
64 TrialTradeAccountDeployConfig,
65 TrialTradingAccountOracle,
66 },
67};
68use serde_with::serde_as;
69use std::ops::{
70 Deref,
71 DerefMut,
72};
73
74pub use o2_tools::prop_deploy::{
75 ExistingRegistry,
76 PropDeployConfig,
77 PropDeployment,
78};
79
80const ORDERBOOK_MAINTAINER_ROLE: u64 = 1;
85
86const DISCOUNT_MANAGER_ROLE: u64 = 1;
93
94pub async fn deploy_prop_system<W>(
100 wallet: &W,
101 config: &PropDeployConfig,
102) -> anyhow::Result<PropDeployment<W>>
103where
104 W: Account + Clone,
105{
106 PropDeployment::deploy(wallet, config).await
107}
108
109fn to_registry_market_id(m: &MarketIdAssets) -> o2_tools::order_book_registry::MarketId {
110 o2_tools::order_book_registry::MarketId {
111 base_asset: m.base_asset,
112 quote_asset: m.quote_asset,
113 }
114}
115
116#[serde_as]
121#[derive(Debug, serde::Serialize, Clone, Default)]
122pub struct MarketsConfigOutput {
123 pub starting_height: u32,
124 #[serde_as(as = "HexDisplayFromStr")]
125 pub trade_account_registry_id: ContractId,
126 #[serde_as(as = "HexDisplayFromStr")]
127 pub trade_account_registry_blob_id: ContractId,
128 #[serde_as(as = "HexDisplayFromStr")]
129 pub trade_account_oracle_id: ContractId,
130 #[serde_as(as = "HexDisplayFromStr")]
131 pub trial_trade_account_oracle_id: ContractId,
132 #[serde_as(as = "HexDisplayFromStr")]
133 pub trade_account_root: ContractId,
134 #[serde_as(as = "HexDisplayFromStr")]
135 pub trade_account_proxy: ContractId,
136 #[serde_as(as = "HexDisplayFromStr")]
137 pub trade_account_blob_id: ContractId,
138 #[serde_as(as = "Option<HexDisplayFromStr>")]
139 pub order_book_whitelist_id: Option<ContractId>,
140 #[serde_as(as = "Option<HexDisplayFromStr>")]
141 pub order_book_blacklist_id: Option<ContractId>,
142 #[serde_as(as = "HexDisplayFromStr")]
143 pub order_book_registry_id: ContractId,
144 #[serde_as(as = "HexDisplayFromStr")]
145 pub order_book_registry_blob_id: ContractId,
146 #[serde_as(as = "Option<HexDisplayFromStr>")]
147 pub fast_bridge_asset_registry_proxy_id: Option<ContractId>,
148 #[serde_as(as = "Option<HexDisplayFromStr>")]
149 pub price_feed_id: Option<ContractId>,
150 #[serde_as(as = "Option<HexDisplayFromStr>")]
151 pub margin_pool_id: Option<ContractId>,
152 #[serde_as(as = "Option<HexDisplayFromStr>")]
153 pub margin_oracle_id: Option<ContractId>,
154 #[serde(skip_serializing_if = "Option::is_none")]
161 pub margin: Option<MarginConfig>,
162 pub pairs: Vec<OrderBookConfig>,
163}
164
165#[serde_as]
167#[derive(Debug, Clone, serde::Deserialize)]
168struct OrderBookConfigDeHelper {
169 #[serde_as(as = "Option<serde_with::DisplayFromStr>")]
170 blob_id: Option<ContractId>,
171 #[serde_as(as = "Option<serde_with::DisplayFromStr>")]
172 contract_id: Option<ContractId>,
173 #[serde_as(as = "serde_with::DisplayFromStr")]
174 taker_fee: u64,
175 #[serde_as(as = "serde_with::DisplayFromStr")]
176 maker_fee: u64,
177 #[serde_as(as = "serde_with::DisplayFromStr")]
178 min_order: u64,
179 #[serde_as(as = "serde_with::DisplayFromStr")]
180 dust: u64,
181 price_window: u8,
182 #[serde(default)]
183 allow_fractional_price: bool,
184 base: AssetConfig,
185 quote: AssetConfig,
186 #[serde(default)]
187 house_identities: Vec<String>,
188 #[serde(default)]
189 sidecar: Option<SidecarConfig>,
190}
191
192impl From<OrderBookConfigDeHelper> for OrderBookConfig {
193 fn from(h: OrderBookConfigDeHelper) -> Self {
194 let ids = MarketIdAssets {
195 base_asset: h.base.asset,
196 quote_asset: h.quote.asset,
197 };
198 let market_id = ids.market_id();
199 OrderBookConfig {
200 contract_id: h.contract_id,
201 blob_id: h.blob_id,
202 market_id,
203 taker_fee: h.taker_fee,
204 maker_fee: h.maker_fee,
205 min_order: h.min_order,
206 dust: h.dust,
207 price_window: h.price_window,
208 allow_fractional_price: h.allow_fractional_price,
209 base: h.base,
210 quote: h.quote,
211 house_identities: h.house_identities,
212 sidecar: h.sidecar,
213 }
214 }
215}
216
217#[derive(Debug, Clone, Default, serde::Serialize)]
218pub struct MarketsConfigPartial {
219 pub starting_height: u32,
220 pub trade_account_registry_id: Option<ContractId>,
221 pub order_book_registry_id: Option<ContractId>,
222 pub trade_account_oracle_id: Option<ContractId>,
223 pub trial_trade_account_oracle_id: Option<ContractId>,
224 pub order_book_whitelist_id: Option<ContractId>,
225 pub order_book_blacklist_id: Option<ContractId>,
226 pub fast_bridge_asset_registry_proxy_id: Option<ContractId>,
227 pub pairs: Vec<OrderBookConfig>,
228 pub margin: Option<MarginConfig>,
232}
233
234impl<'de> serde::Deserialize<'de> for MarketsConfigPartial {
235 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
236 where
237 D: serde::Deserializer<'de>,
238 {
239 #[derive(serde::Deserialize, Default)]
240 struct Helper {
241 #[serde(default)]
242 starting_height: u32,
243 trade_account_registry_id: Option<ContractId>,
244 order_book_registry_id: Option<ContractId>,
245 trade_account_oracle_id: Option<ContractId>,
246 trial_trade_account_oracle_id: Option<ContractId>,
247 order_book_whitelist_id: Option<ContractId>,
248 order_book_blacklist_id: Option<ContractId>,
249 fast_bridge_asset_registry_proxy_id: Option<ContractId>,
250 #[serde(default)]
251 pairs: Vec<OrderBookConfigDeHelper>,
252 #[serde(default)]
253 margin: Option<MarginConfig>,
254 }
255 let h = Helper::deserialize(deserializer)?;
256 if let Some(margin) = &h.margin
257 && margin.price_feed_id.is_some()
258 {
259 return Err(serde::de::Error::custom(
260 "`margin.price_feed_id` moved into `margin.price_feed.id`. \
261 Refusing to load a config that uses the retired spelling: it \
262 would be ignored, a fresh feed would be derived, and the pool \
263 would be repointed at an oracle with no publisher. Move the id \
264 (and `publishers` / `supported_assets`) under \
265 `margin.price_feed`.",
266 ));
267 }
268 Ok(MarketsConfigPartial {
269 starting_height: h.starting_height,
270 trade_account_registry_id: h.trade_account_registry_id,
271 order_book_registry_id: h.order_book_registry_id,
272 trade_account_oracle_id: h.trade_account_oracle_id,
273 trial_trade_account_oracle_id: h.trial_trade_account_oracle_id,
274 order_book_whitelist_id: h.order_book_whitelist_id,
275 order_book_blacklist_id: h.order_book_blacklist_id,
276 fast_bridge_asset_registry_proxy_id: h.fast_bridge_asset_registry_proxy_id,
277 pairs: h.pairs.into_iter().map(Into::into).collect(),
278 margin: h.margin,
279 })
280 }
281}
282
283#[serde_as]
287#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
288#[serde(deny_unknown_fields)]
289pub struct MarginTierConfig {
290 pub tier_id: u64,
291 #[serde_as(as = "serde_with::DisplayFromStr")]
293 pub line: u64,
294 pub leverage: u64,
295 pub duration: u64,
296 pub maintenance_bps: u64,
297 pub open_buffer_bps: u64,
298 pub liq_price_factor: u64,
299 pub prolong_fee: [String; 4],
304 pub max_credit_line_bps: u64,
305 pub max_price_age: u64,
306 pub open_fee: String,
309 pub profit_share_bps: u64,
310 pub price_band_bps: u64,
311 pub markets: Vec<String>,
314 #[serde(default)]
317 pub sidecar: bool,
318 #[serde(default, skip_serializing_if = "Option::is_none")]
326 pub turbo: Option<MarginTurboConfig>,
327 #[serde(default = "tier_enabled_default")]
334 pub enabled: bool,
335}
336
337fn tier_enabled_default() -> bool {
339 true
340}
341
342#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
346#[serde(deny_unknown_fields)]
347pub struct MarginTurboConfig {
348 pub max_loss_bps: [u64; 4],
352 pub rollover_profit_bps: u64,
355 pub max_rollovers: u64,
357}
358
359impl From<&MarginTurboConfig> for o2_tools::prop::TurboTerms {
360 fn from(turbo: &MarginTurboConfig) -> Self {
361 Self {
362 max_loss_bps: turbo.max_loss_bps,
363 rollover_profit_bps: turbo.rollover_profit_bps,
364 max_rollovers: turbo.max_rollovers,
365 }
366 }
367}
368
369#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
375pub struct MarginSupportedAsset {
376 pub asset: fuels::types::AssetId,
377 pub decimals: u8,
378}
379
380#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
384pub struct MarginPriceFeedConfig {
385 pub id: Option<ContractId>,
388 pub max_offchain_age_seconds: Option<u64>,
391 #[serde(default)]
397 pub publishers: Vec<String>,
398 #[serde(default)]
411 pub remove_publishers: Vec<String>,
412 #[serde(default)]
419 pub supported_assets: Vec<MarginSupportedAsset>,
420}
421
422#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
425pub struct MarginConfig {
426 #[serde(default)]
428 pub price_feed: MarginPriceFeedConfig,
429 #[serde(default, skip_serializing)]
439 pub price_feed_id: Option<ContractId>,
440 pub margin_pool_id: Option<ContractId>,
444 pub platform_payout: Option<String>,
447 pub expiry_cosigner: Option<fuels::types::Address>,
465 #[serde(default)]
477 pub discount_managers: Vec<String>,
478 #[serde(default)]
490 pub remove_discount_managers: Vec<String>,
491 pub collateral_asset: Option<fuels::types::AssetId>,
494 pub collateral_decimals: Option<u8>,
497 pub max_tier_books: Option<u64>,
499 pub base_repay_fee_ppm: Option<u64>,
507 #[serde(default)]
511 pub tiers: Vec<MarginTierConfig>,
512}
513
514#[derive(Debug, Clone, Default)]
515pub struct OwnershipTransferOptions {
516 pub new_proxy_owner: Option<fuels::types::Address>,
517 pub new_contract_owner: Option<fuels::types::Address>,
518 pub revoke_orderbook_maintainers: Vec<fuels::types::Address>,
522 pub new_orderbook_maintainers: Vec<fuels::types::Address>,
525}
526
527#[derive(Debug, Clone)]
529pub struct DeployParams {
530 pub deploy_config: MarketsConfigPartial,
531 pub output: Option<String>,
532 pub deploy_whitelist: bool,
533 pub deploy_blacklist: bool,
534 pub upgrade_bytecode: bool,
535 pub new_proxy_owner: Option<fuels::types::Address>,
536 pub new_contract_owner: Option<fuels::types::Address>,
537 pub trial_cosigner: Option<fuels::types::Address>,
543 pub trial_creator: Option<Identity>,
550 pub margin_tier_only: bool,
566 pub margin_cosigner: Option<fuels::types::Address>,
572 pub margin_liquidator: Option<Identity>,
577 pub margin_expiry_cosigner: Option<fuels::types::Address>,
590 pub revoke_orderbook_maintainers: Vec<fuels::types::Address>,
591 pub new_orderbook_maintainers: Vec<fuels::types::Address>,
592}
593
594pub fn load_config_from_file<T>(config_path: &str) -> anyhow::Result<T>
600where
601 T: Default + serde::de::DeserializeOwned,
602{
603 if config_path.is_empty() {
604 return Ok(T::default());
605 }
606 let current_dir = std::env::current_dir()?;
607 let path = current_dir.join(config_path);
608 tracing::info!("Loading config from {}", path.display());
609 let file = std::fs::File::open(&path)?;
610 let config: T = serde_json::from_reader(file)?;
611 Ok(config)
612}
613
614pub async fn deploy<W>(
623 wallet: W,
624 params: DeployParams,
625) -> anyhow::Result<MarketsConfigOutput>
626where
627 W: Account + ViewOnlyAccount + Clone + 'static,
628{
629 sidecar::validate_config(¶ms.deploy_config.pairs)?;
630 tracing::info!("Starting Fuel o2 Registries and Markets");
631 let mut markets_config_partial = params.deploy_config.clone();
632 let starting_height: BlockHeight = markets_config_partial.starting_height.into();
633 let trade_account_oracle_id = markets_config_partial.trade_account_oracle_id;
634 let trial_trade_account_oracle_id =
635 markets_config_partial.trial_trade_account_oracle_id;
636 let order_book_registry_id = markets_config_partial.order_book_registry_id;
637 let trade_account_registry_id = markets_config_partial.trade_account_registry_id;
638 let fast_bridge_asset_registry_proxy_id =
639 markets_config_partial.fast_bridge_asset_registry_proxy_id;
640
641 let mut salt = Salt::zeroed();
642 salt.deref_mut()[..4].copy_from_slice(&starting_height.deref().to_be_bytes());
643
644 let (trade_account_oracle_deploy, trade_account_blob_id) =
645 deploy_trade_account_oracle(
646 wallet.clone(),
647 params.upgrade_bytecode,
648 trade_account_oracle_id,
649 salt,
650 )
651 .await?;
652 let trial_trade_account_oracle_id = deploy_trial_trade_account_oracle(
656 wallet.clone(),
657 params.upgrade_bytecode,
658 trial_trade_account_oracle_id,
659 params.trial_cosigner,
660 salt,
661 )
662 .await?;
663 let (trade_account_registry, trade_account_registry_blob_id) =
664 deploy_trade_account_registry(
665 wallet.clone(),
666 params.upgrade_bytecode,
667 trade_account_oracle_deploy.clone(),
668 trial_trade_account_oracle_id,
669 trade_account_registry_id,
670 salt,
671 )
672 .await?;
673 let order_book_blacklist_id = deploy_order_book_blacklist(
674 wallet.clone(),
675 params.deploy_blacklist,
676 markets_config_partial.order_book_blacklist_id,
677 salt,
678 )
679 .await?;
680 let order_book_whitelist_id = deploy_order_book_whitelist(
681 wallet.clone(),
682 params.deploy_whitelist,
683 markets_config_partial.order_book_whitelist_id,
684 salt,
685 )
686 .await?;
687 let (order_book_registry, order_book_registry_blob_id) = deploy_order_book_registry(
688 wallet.clone(),
689 params.upgrade_bytecode,
690 order_book_registry_id,
691 salt,
692 )
693 .await?;
694 let pairs = deploy_order_books(
695 wallet.clone(),
696 params.upgrade_bytecode,
697 order_book_blacklist_id,
698 order_book_whitelist_id,
699 order_book_registry.clone(),
700 &mut markets_config_partial.pairs,
701 OwnershipTransferOptions {
702 new_proxy_owner: params.new_proxy_owner,
703 new_contract_owner: params.new_contract_owner,
704 revoke_orderbook_maintainers: params.revoke_orderbook_maintainers.clone(),
705 new_orderbook_maintainers: params.new_orderbook_maintainers.clone(),
706 },
707 )
708 .await?;
709
710 let order_book_registry_id = order_book_registry.contract_id;
711 let trade_account_registry_id = trade_account_registry.contract_id;
712 let trade_account_oracle_id = trade_account_oracle_deploy.oracle_id;
713
714 let trade_account_proxy = trade_account_registry
715 .registry
716 .methods()
717 .default_bytecode()
718 .simulate(Execution::state_read_only())
719 .await?
720 .value
721 .context(
722 "Trade account registry default bytecode should exist after initialization",
723 )?;
724 let trade_account_root = trade_account_registry
725 .registry
726 .methods()
727 .factory_bytecode_root()
728 .simulate(Execution::state_read_only())
729 .await?
730 .value
731 .context("Trade account registry factory bytecode root should exist after initialization")?;
732
733 let margin_ids = match &markets_config_partial.margin {
737 Some(margin) => Some(
738 deploy_margin(
739 &wallet,
740 margin,
741 salt,
742 params.margin_tier_only,
743 trade_account_registry_id,
744 trade_account_oracle_id,
745 trial_trade_account_oracle_id,
746 &pairs,
747 params.margin_cosigner,
748 params.margin_liquidator,
749 params.margin_expiry_cosigner,
750 params.upgrade_bytecode,
751 )
752 .await?,
753 ),
754 None => None,
755 };
756
757 if let Some(trial_creator) = params.trial_creator {
761 let current_creator = trade_account_registry
762 .get_trial_trade_account_creator()
763 .await?;
764 if current_creator != trial_creator {
765 tracing::info!("Setting trial trade account creator to {trial_creator:?}");
766 trade_account_registry
767 .set_trial_trade_account_creator(trial_creator)
768 .await?;
769 }
770 }
771
772 transfer_ownership(
773 &wallet,
774 ¶ms,
775 &order_book_registry,
776 &trade_account_registry,
777 &trade_account_oracle_deploy,
778 trial_trade_account_oracle_id,
779 order_book_blacklist_id,
780 order_book_whitelist_id,
781 )
782 .await?;
783
784 let deploy_result = MarketsConfigOutput {
785 starting_height: starting_height.into(),
786 trade_account_registry_id,
787 trade_account_registry_blob_id,
788 trade_account_proxy,
789 trade_account_blob_id,
790 trade_account_root: ContractId::from(trade_account_root.0),
791 trade_account_oracle_id,
792 trial_trade_account_oracle_id,
793 order_book_whitelist_id,
794 order_book_blacklist_id,
795 order_book_registry_id,
796 order_book_registry_blob_id,
797 pairs,
798 fast_bridge_asset_registry_proxy_id,
799 price_feed_id: margin_ids.map(|ids| ids.price_feed_id),
800 margin_pool_id: margin_ids.map(|ids| ids.margin_pool_id),
801 margin_oracle_id: margin_ids.and_then(|ids| ids.margin_oracle_id),
802 margin: markets_config_partial.margin.clone().map(|mut margin| {
809 if let Some(ids) = margin_ids {
810 margin.price_feed.id = Some(ids.price_feed_id);
811 margin.margin_pool_id = Some(ids.margin_pool_id);
812 }
813 margin
814 }),
815 };
816
817 if let Some(output_path) = params.output {
818 let json = serde_json::to_string_pretty(&deploy_result)?;
819 tracing::info!("Deploy result saved to {}", output_path);
820 std::fs::write(output_path, json)?;
821 }
822
823 Ok(deploy_result)
824}
825
826#[derive(Debug, Clone, Copy)]
832pub struct MarginIds {
833 pub price_feed_id: ContractId,
834 pub margin_pool_id: ContractId,
835 pub margin_oracle_id: Option<ContractId>,
836}
837
838#[allow(clippy::too_many_arguments)]
849async fn deploy_margin<W>(
850 wallet: &W,
851 margin: &MarginConfig,
852 salt: fuels::types::Salt,
853 tier_only: bool,
854 trade_account_registry_id: ContractId,
855 trade_account_oracle_id: ContractId,
856 trial_trade_account_oracle_id: ContractId,
857 pairs: &[OrderBookConfig],
858 margin_cosigner: Option<fuels::types::Address>,
859 margin_liquidator: Option<Identity>,
860 margin_expiry_cosigner: Option<fuels::types::Address>,
861 upgrade_bytecode: bool,
862) -> anyhow::Result<MarginIds>
863where
864 W: Account + ViewOnlyAccount + Clone + 'static,
865{
866 use o2_tools::prop::{
867 PriceFeedContract,
868 PropAccountOracleContract,
869 PropMarginPoolContract,
870 };
871
872 let collateral_asset = match margin.collateral_asset {
873 Some(collateral_asset) => collateral_asset,
874 None => {
875 pairs
876 .first()
877 .context(
878 "margin.collateral_asset is not set and there are no pairs to \
879 default it from",
880 )?
881 .quote
882 .asset
883 }
884 };
885 let cosigner = margin_cosigner;
890 let platform_payout = margin
891 .platform_payout
892 .as_deref()
893 .map(parse_margin_identity)
894 .transpose()?;
895 let liquidator = margin_liquidator;
896 let expiry_cosigner = margin_expiry_cosigner.or(margin.expiry_cosigner);
905
906 let mut prop_config = PropDeployConfig::new(collateral_asset);
907 prop_config.collateral_decimals = margin.collateral_decimals.context(
910 "margin.collateral_decimals is required - set it to the collateral \
911 asset's decimals in the deploy config",
912 )?;
913 prop_config.max_tier_books = margin.max_tier_books.unwrap_or(80);
914 prop_config.base_repay_fee_ppm = margin.base_repay_fee_ppm.unwrap_or(100);
915 prop_config.cosigner = cosigner;
916 prop_config.platform_payout = platform_payout;
917 prop_config.liquidator = liquidator;
918 prop_config.salt = salt;
919 prop_config.existing_price_feed = margin.price_feed.id;
920 prop_config.max_offchain_age_seconds = margin.price_feed.max_offchain_age_seconds;
921 prop_config.existing_pool = margin.margin_pool_id;
925 prop_config.existing_registry = Some(ExistingRegistry {
926 registry_id: trade_account_registry_id,
927 trade_account_oracle_id,
928 trial_trade_account_oracle_id,
929 });
930
931 anyhow::ensure!(
932 !(tier_only && margin.margin_pool_id.is_none()),
933 "--margin-tier-only was requested but the markets config names no \
934 `margin.margin_pool_id`: there is no pool to reconcile tiers against"
935 );
936
937 let (price_feed_id, margin_pool_id, margin_oracle_id) = if let (
939 true,
940 Some(margin_pool_id),
941 ) =
942 (tier_only, margin.margin_pool_id)
943 {
944 tracing::info!("Margin: tier-only mode against pool {margin_pool_id}");
945 let price_feed_id = match margin.price_feed.id {
946 Some(price_feed_id) => price_feed_id,
947 None => {
948 PropMarginPoolContract::new(margin_pool_id, wallet.clone())
949 .methods()
950 .price_feed()
951 .simulate(Execution::state_read_only())
952 .await
953 .context("read the pool's price feed")?
954 .value
955 }
956 };
957 let margin_oracle_id =
963 TradeAccountRegistryManager::new(wallet.clone(), trade_account_registry_id)
964 .registry
965 .methods()
966 .get_prop_oracle_id()
967 .simulate(Execution::state_read_only())
968 .await
969 .map(|result| result.value)
970 .ok()
971 .filter(|oracle_id| *oracle_id != ContractId::zeroed());
972 (price_feed_id, margin_pool_id, margin_oracle_id)
973 } else {
974 let deployment = deploy_prop_system(wallet, &prop_config).await?;
975 tracing::info!(
976 "Margin: oracle {}, pool {}, feed {} (shared registry {} upgraded in place)",
977 deployment.oracle_id,
978 deployment.pool_id,
979 deployment.price_feed_id,
980 deployment.registry_id,
981 );
982 (
983 deployment.price_feed_id,
984 deployment.pool_id,
985 Some(deployment.oracle_id),
986 )
987 };
988
989 let pool_deployed = wallet
995 .try_provider()?
996 .contract_exists(&margin_pool_id)
997 .await?;
998 if pool_deployed {
999 let pool = PropMarginPoolContract::new(margin_pool_id, wallet.clone());
1000 if let Some(platform_payout) = platform_payout {
1001 let current = pool
1002 .methods()
1003 .platform_payout()
1004 .simulate(Execution::state_read_only())
1005 .await
1006 .context("read the pool's platform payout")?
1007 .value;
1008 if current != platform_payout {
1009 tracing::info!(
1010 "Margin: platform payout {current:?} -> {platform_payout:?}"
1011 );
1012 pool.methods()
1013 .set_platform_payout(platform_payout)
1014 .call()
1015 .await?;
1016 }
1017 }
1018 if let Some(liquidator) = liquidator {
1019 let current = pool
1020 .methods()
1021 .liquidator()
1022 .simulate(Execution::state_read_only())
1023 .await
1024 .context("read the pool's liquidator")?
1025 .value;
1026 if current != liquidator {
1027 tracing::info!("Margin: liquidator {current:?} -> {liquidator:?}");
1028 pool.methods().set_liquidator(liquidator).call().await?;
1029 }
1030 }
1031
1032 if let Some(expiry_cosigner) = expiry_cosigner {
1043 let current = pool
1044 .methods()
1045 .expiry_cosigner()
1046 .simulate(Execution::state_read_only())
1047 .await
1048 .context("read the pool's expiry cosigner")?
1049 .value;
1050 if current != Some(expiry_cosigner) {
1051 tracing::info!(
1052 "Margin: expiry cosigner {current:?} -> {expiry_cosigner:?}"
1053 );
1054 pool.methods()
1055 .set_expiry_cosigner(expiry_cosigner)
1056 .call()
1057 .await?;
1058 }
1059 } else {
1060 let current = pool
1064 .methods()
1065 .expiry_cosigner()
1066 .simulate(Execution::state_read_only())
1067 .await
1068 .context("read the pool's expiry cosigner")?
1069 .value;
1070 if current.is_none() {
1071 tracing::warn!(
1072 "Margin: no expiry cosigner configured and this pool has \
1073 none - PREPAID (Turbo) deadlines cannot be settled until \
1074 `margin.expiry_cosigner` names the address of the \
1075 backend's margin cosigner key."
1076 );
1077 }
1078 }
1079
1080 for entry in &margin.discount_managers {
1089 anyhow::ensure!(
1090 !margin.remove_discount_managers.contains(entry),
1091 "discount manager {entry} is listed in both \
1092 `discount_managers` and `remove_discount_managers`"
1093 );
1094 }
1095 for (entry, should_hold) in margin
1096 .discount_managers
1097 .iter()
1098 .map(|entry| (entry, true))
1099 .chain(
1100 margin
1101 .remove_discount_managers
1102 .iter()
1103 .map(|entry| (entry, false)),
1104 )
1105 {
1106 let manager = parse_margin_identity(entry)?;
1107 let holds = pool
1108 .methods()
1109 .has_role(DISCOUNT_MANAGER_ROLE, manager)
1110 .simulate(Execution::state_read_only())
1111 .await
1112 .context("read a discount manager's role")?
1113 .value;
1114 if holds == should_hold {
1115 continue;
1116 }
1117 if should_hold {
1118 tracing::info!("Margin: adding discount manager {manager:?}");
1119 pool.methods()
1120 .grant_role(DISCOUNT_MANAGER_ROLE, manager)
1121 .call()
1122 .await
1123 .context("grant a discount manager its role")?;
1124 } else {
1125 tracing::info!("Margin: removing discount manager {manager:?}");
1126 pool.methods()
1127 .revoke_role(DISCOUNT_MANAGER_ROLE, manager)
1128 .call()
1129 .await
1130 .context("revoke a discount manager's role")?;
1131 }
1132 }
1133 }
1134
1135 let margin_oracle_id = match margin_oracle_id {
1138 Some(oracle_id) => Some(oracle_id),
1139 None if cosigner.is_some() => {
1140 let registry = o2_tools::trade_account_registry::TradeAccountRegistry::new(
1141 trade_account_registry_id,
1142 wallet.clone(),
1143 );
1144 let oracle_id = registry
1145 .methods()
1146 .get_prop_oracle_id()
1147 .simulate(Execution::state_read_only())
1148 .await
1149 .context("read the registry's prop account oracle")?
1150 .value;
1151 (oracle_id != ContractId::zeroed()).then_some(oracle_id)
1152 }
1153 None => None,
1154 };
1155
1156 if let (Some(cosigner), Some(oracle_id)) = (cosigner, margin_oracle_id)
1161 && wallet.try_provider()?.contract_exists(&oracle_id).await?
1162 {
1163 let oracle = PropAccountOracleContract::new(oracle_id, wallet.clone());
1164 let current = oracle
1165 .methods()
1166 .get_cosigner()
1167 .simulate(Execution::state_read_only())
1168 .await
1169 .context("read the prop account oracle's cosigner")?
1170 .value;
1171 if current != Some(cosigner) {
1172 tracing::info!("Margin: cosigner {current:?} -> {cosigner:?}");
1173 oracle.methods().set_cosigner(cosigner).call().await?;
1174 }
1175 }
1176
1177 for entry in &margin.price_feed.publishers {
1185 anyhow::ensure!(
1186 !margin.price_feed.remove_publishers.contains(entry),
1187 "price feed publisher {entry} is listed in both `publishers` and \
1188 `remove_publishers`"
1189 );
1190 }
1191 let price_feed = PriceFeedContract::new(price_feed_id, wallet.clone());
1192 for (entry, should_hold) in margin
1193 .price_feed
1194 .publishers
1195 .iter()
1196 .map(|entry| (entry, true))
1197 .chain(
1198 margin
1199 .price_feed
1200 .remove_publishers
1201 .iter()
1202 .map(|entry| (entry, false)),
1203 )
1204 {
1205 let publisher = parse_margin_identity(entry)?;
1206 let holds = price_feed
1207 .methods()
1208 .has_role(o2_tools::prop_deploy::PRICE_SUBMITTER_ROLE, publisher)
1209 .simulate(Execution::state_read_only())
1210 .await
1211 .context("read a price feed publisher's submitter role")?
1212 .value;
1213 if holds == should_hold {
1214 continue;
1215 }
1216 if should_hold {
1217 tracing::info!("Margin: adding price feed publisher {publisher:?}");
1218 price_feed
1219 .methods()
1220 .add_publisher(publisher)
1221 .call()
1222 .await
1223 .context("grant a price feed publisher the submitter role")?;
1224 } else {
1225 tracing::info!("Margin: removing price feed publisher {publisher:?}");
1226 price_feed
1227 .methods()
1228 .remove_publisher(publisher)
1229 .call()
1230 .await
1231 .context("revoke a price feed publisher's submitter role")?;
1232 }
1233 }
1234 if upgrade_bytecode && !tier_only {
1243 o2_tools::prop_deploy::upgrade_price_feed_implementation(
1244 wallet,
1245 price_feed_id,
1246 prop_config
1249 .owner
1250 .unwrap_or(Identity::Address(wallet.address())),
1251 &prop_config,
1252 )
1253 .await
1254 .context("upgrade the price feed implementation")?;
1255 }
1256
1257 if let Some(seconds) = margin.price_feed.max_offchain_age_seconds {
1262 let current = price_feed
1263 .methods()
1264 .get_max_offchain_age()
1265 .simulate(Execution::state_read_only())
1266 .await
1267 .context("read the price feed's submission-age bound")?
1268 .value;
1269 if current != seconds {
1270 tracing::info!("Margin: submission-age bound {current} -> {seconds}");
1271 price_feed
1272 .methods()
1273 .set_max_offchain_age(seconds)
1274 .call()
1275 .await
1276 .context("set the price feed's submission-age bound")?;
1277 }
1278 }
1279
1280 for supported in &margin.price_feed.supported_assets {
1286 let current = price_feed
1287 .methods()
1288 .get_asset_decimals(supported.asset)
1289 .simulate(Execution::state_read_only())
1290 .await
1291 .context("read the price feed's asset decimals")?
1292 .value;
1293 match current {
1294 Some(decimals) if decimals == supported.decimals => continue,
1295 Some(decimals) => anyhow::bail!(
1299 "price feed already knows {} at {decimals} decimals, config says \
1300 {} - refusing to move it, since the pool pins decimals on first \
1301 use and rejects a change",
1302 supported.asset,
1303 supported.decimals,
1304 ),
1305 None => {
1306 tracing::info!(
1307 "Margin: registering {} at {} decimals on the feed",
1308 supported.asset,
1309 supported.decimals,
1310 );
1311 price_feed
1312 .methods()
1313 .set_asset_decimals(supported.asset, supported.decimals)
1314 .call()
1315 .await
1316 .context("register a supported asset on the price feed")?;
1317 }
1318 }
1319 }
1320
1321 let pool = PropMarginPoolContract::new(margin_pool_id, wallet.clone());
1322 let pool_reachable = wallet
1323 .try_provider()?
1324 .contract_exists(&margin_pool_id)
1325 .await?;
1326
1327 if pool_reachable {
1337 let live_feed = pool
1338 .methods()
1339 .price_feed()
1340 .simulate(Execution::state_read_only())
1341 .await
1342 .context("read the pool's price feed")?
1343 .value;
1344 if live_feed != price_feed_id {
1345 tracing::info!("Margin: pool price feed {live_feed} -> {price_feed_id}");
1346 pool.methods()
1347 .set_price_feed(price_feed_id)
1348 .with_contract_ids(&[price_feed_id])
1351 .call()
1352 .await
1353 .context(
1354 "point the pool at the configured price feed - the \
1355 replacement must already know every admitted asset at the \
1356 decimals the pool pinned",
1357 )?;
1358 }
1359 }
1360
1361 anyhow::ensure!(
1368 pool_reachable || margin.tiers.is_empty(),
1369 "margin pool {margin_pool_id} is not on chain, so its {} configured \
1370 tier(s) cannot be reconciled",
1371 margin.tiers.len()
1372 );
1373 if pool_reachable {
1374 reconcile_margin_tiers(
1375 &pool,
1376 price_feed_id,
1377 pairs,
1378 &margin.tiers,
1379 prop_config.collateral_decimals,
1380 )
1381 .await?;
1382 }
1383
1384 Ok(MarginIds {
1385 price_feed_id,
1386 margin_pool_id,
1387 margin_oracle_id,
1388 })
1389}
1390
1391async fn reconcile_margin_tiers<W>(
1400 pool: &o2_tools::prop::PropMarginPoolContract<W>,
1401 price_feed_id: ContractId,
1402 pairs: &[OrderBookConfig],
1403 tiers: &[MarginTierConfig],
1404 collateral_decimals: u8,
1405) -> anyhow::Result<()>
1406where
1407 W: Account + ViewOnlyAccount + Clone + 'static,
1408{
1409 for tier in tiers {
1410 let books = tier
1411 .markets
1412 .iter()
1413 .map(|market| resolve_tier_market(market, pairs, tier.sidecar))
1414 .collect::<anyhow::Result<Vec<_>>>()?;
1415 let params = tier_params(tier, collateral_decimals)?;
1416 let terms = tier.turbo.as_ref().map(o2_tools::prop::TurboTerms::from);
1417 validate_turbo_tier_config(tier, ¶ms, terms.as_ref())?;
1421 let current_version = pool
1422 .methods()
1423 .current_tier_version(tier.tier_id)
1424 .simulate(Execution::state_read_only())
1425 .await?
1426 .value;
1427
1428 let mut contract_ids = vec![price_feed_id];
1429 contract_ids.extend(books.iter().copied());
1430
1431 let Some(version) = current_version else {
1432 let version = publish_margin_tier(
1433 pool,
1434 tier.tier_id,
1435 params,
1436 terms.clone(),
1437 books,
1438 &contract_ids,
1439 )
1440 .await?;
1441 tracing::info!(
1442 "Margin: tier {} published version {version}{}",
1443 tier.tier_id,
1444 if terms.is_some() { " (prepaid)" } else { "" }
1445 );
1446 reconcile_margin_tier_enabled(pool, tier).await?;
1447 continue;
1448 };
1449
1450 let live = pool
1451 .methods()
1452 .get_tier(tier.tier_id, version)
1453 .simulate(Execution::state_read_only())
1454 .await?
1455 .value
1456 .with_context(|| {
1457 format!("tier {} version {version} vanished mid-read", tier.tier_id)
1458 })?;
1459 let live_terms = pool
1466 .methods()
1467 .get_turbo_terms(tier.tier_id, version)
1468 .simulate(Execution::state_read_only())
1469 .await?
1470 .value;
1471 let live_books = pool
1472 .methods()
1473 .tier_books(tier.tier_id, version)
1474 .simulate(Execution::state_read_only())
1475 .await?
1476 .value;
1477 let venue_changed = tier_book_route_changed(tier.sidecar, pairs, &live_books);
1480 if live != params || live_terms != terms || venue_changed {
1481 let new_version = publish_margin_tier(
1482 pool,
1483 tier.tier_id,
1484 params,
1485 terms.clone(),
1486 books,
1487 &contract_ids,
1488 )
1489 .await?;
1490 tracing::info!(
1491 "Margin: tier {} republished as version {new_version} ({})",
1492 tier.tier_id,
1493 if venue_changed {
1494 "book venue changed"
1495 } else if live_terms != terms {
1496 "terms changed"
1497 } else {
1498 "economics changed"
1499 }
1500 );
1501 reconcile_margin_tier_enabled(pool, tier).await?;
1502 continue;
1503 }
1504 reconcile_margin_tier_enabled(pool, tier).await?;
1505
1506 let missing: Vec<ContractId> = books
1507 .iter()
1508 .copied()
1509 .filter(|book| !live_books.contains(book))
1510 .collect();
1511 let shrunk = live_books
1512 .iter()
1513 .filter(|book| !books.contains(book))
1514 .count();
1515 if shrunk > 0 {
1516 tracing::warn!(
1517 "Margin: tier {} declares {shrunk} fewer book(s) than live version \
1518 {version}; books are append-only — publish a new version to drop \
1519 markets",
1520 tier.tier_id
1521 );
1522 }
1523 if missing.is_empty() {
1524 tracing::info!(
1525 "Margin: tier {} version {version} matches the declared catalogue — \
1526 unchanged",
1527 tier.tier_id
1528 );
1529 continue;
1530 }
1531 let mut add_contract_ids = vec![price_feed_id];
1532 add_contract_ids.extend(missing.iter().copied());
1533 pool.methods()
1534 .add_books(tier.tier_id, missing.clone())
1535 .with_contract_ids(&add_contract_ids)
1536 .call()
1537 .await?;
1538 tracing::info!(
1539 "Margin: tier {} version {version} gained {} book(s)",
1540 tier.tier_id,
1541 missing.len()
1542 );
1543 }
1544 Ok(())
1545}
1546
1547async fn publish_margin_tier<W>(
1553 pool: &o2_tools::prop::PropMarginPoolContract<W>,
1554 tier_id: u64,
1555 params: o2_tools::prop::TierParams,
1556 terms: Option<o2_tools::prop::TurboTerms>,
1557 books: Vec<ContractId>,
1558 contract_ids: &[ContractId],
1559) -> anyhow::Result<u32>
1560where
1561 W: Account + ViewOnlyAccount + Clone + 'static,
1562{
1563 let version = match terms {
1564 Some(terms) => {
1565 pool.methods()
1566 .publish_tier_v2(tier_id, params, terms, books)
1567 .with_contract_ids(contract_ids)
1568 .call()
1569 .await?
1570 .value
1571 }
1572 None => {
1573 pool.methods()
1574 .publish_tier_version(tier_id, params, books)
1575 .with_contract_ids(contract_ids)
1576 .call()
1577 .await?
1578 .value
1579 }
1580 };
1581 Ok(version)
1582}
1583
1584async fn reconcile_margin_tier_enabled<W>(
1592 pool: &o2_tools::prop::PropMarginPoolContract<W>,
1593 tier: &MarginTierConfig,
1594) -> anyhow::Result<()>
1595where
1596 W: Account + ViewOnlyAccount + Clone + 'static,
1597{
1598 let live = pool
1599 .methods()
1600 .is_tier_enabled(tier.tier_id)
1601 .simulate(Execution::state_read_only())
1602 .await?
1603 .value;
1604 if live == tier.enabled {
1605 return Ok(());
1606 }
1607 pool.methods()
1608 .set_tier_enabled(tier.tier_id, tier.enabled)
1609 .call()
1610 .await?;
1611 tracing::info!(
1612 "Margin: tier {} now {} for new sessions",
1613 tier.tier_id,
1614 if tier.enabled { "OPEN" } else { "CLOSED" }
1615 );
1616 Ok(())
1617}
1618
1619fn validate_turbo_tier_config(
1626 tier: &MarginTierConfig,
1627 params: &o2_tools::prop::TierParams,
1628 terms: Option<&o2_tools::prop::TurboTerms>,
1629) -> anyhow::Result<()> {
1630 let Some(terms) = terms else {
1631 return Ok(());
1632 };
1633 let id = tier.tier_id;
1634 anyhow::ensure!(
1635 params.duration == 0 || params.duration == u64::MAX,
1636 "tier {id} is prepaid, so `duration` must be 0 for a weekly term \
1637 or u64::MAX for a perpetual term"
1638 );
1639 anyhow::ensure!(
1640 params.prolong_fee.iter().all(|fee| *fee == 0),
1641 "tier {id} is prepaid, so every `prolong_fee` must be \"0\" - a \
1642 prepaid term is extended by the FREE rollover and by nothing else"
1643 );
1644 anyhow::ensure!(
1645 params.profit_share_bps == 0,
1646 "tier {id} is prepaid, so `profit_share_bps` must be 0 - the user \
1647 keeps 100% of profits and the platform's take is the forfeited entry"
1648 );
1649 anyhow::ensure!(
1652 params.max_credit_line_bps > 10_000,
1653 "tier {id} is prepaid, so `max_credit_line_bps` must be > 10000 - \
1654 the cap must not truncate the line the session opens with"
1655 );
1656 anyhow::ensure!(
1657 terms.rollover_profit_bps <= 10_000,
1658 "tier {id} `rollover_profit_bps` must be <= 10000"
1659 );
1660 anyhow::ensure!(
1661 terms.max_loss_bps.iter().all(|bps| *bps <= 10_000),
1662 "tier {id} every `max_loss_bps` entry must be <= 10000 - a rate past \
1663 BPS would put the floor below zero for any line"
1664 );
1665 if terms.rollover_profit_bps != 0 {
1666 anyhow::ensure!(
1667 terms.max_rollovers != 0,
1668 "tier {id} offers a rollover, so `max_rollovers` must not be 0 - \
1669 an enabled feature must be a real one"
1670 );
1671 }
1672 let last_step = terms.max_rollovers.min(3) as usize;
1676 for step in 0..=last_step {
1677 anyhow::ensure!(
1678 terms.max_loss_bps[step] != 0,
1679 "tier {id} sells a drawdown escalator whose step {step} is 0, \
1680 which floors that term at the session's line - a \
1681 session reaching it is liquidatable immediately"
1682 );
1683 anyhow::ensure!(
1684 step == 0 || terms.max_loss_bps[step] >= terms.max_loss_bps[step - 1],
1685 "tier {id} drawdown step {step} is NARROWER than the step \
1686 before it - the schedule is sold as an escalator and must \
1687 never take back room a previous term already granted"
1688 );
1689 }
1690 let line = u128::from(params.line);
1693 let bps_up = |bps: u64| (line * u128::from(bps)).div_ceil(10_000);
1694 let allowance = line * u128::from(terms.max_loss_bps[0]) / 10_000;
1695 let gap =
1696 bps_up(params.open_buffer_bps).saturating_sub(bps_up(params.maintenance_bps));
1697 anyhow::ensure!(
1698 gap <= allowance,
1699 "tier {id} first drawdown step ({} bps) is narrower than the \
1700 tier's own `open_buffer_bps - maintenance_bps` gap, so every \
1701 session it sells opens already FROZEN and can never trade",
1702 terms.max_loss_bps[0]
1703 );
1704 Ok(())
1705}
1706
1707fn collateral_amount(value: &str, decimals: u8, field: &str) -> anyhow::Result<u64> {
1716 let raw = value.trim();
1717 anyhow::ensure!(!raw.is_empty(), "{field}: empty amount");
1718 anyhow::ensure!(
1719 !raw.starts_with('-'),
1720 "{field}: `{raw}` is negative; fees are unsigned"
1721 );
1722 let (whole, fraction) = match raw.split_once('.') {
1723 Some((whole, fraction)) => (whole, fraction),
1724 None => (raw, ""),
1725 };
1726 anyhow::ensure!(
1727 !fraction.contains('.'),
1728 "{field}: `{raw}` has more than one decimal point"
1729 );
1730 anyhow::ensure!(
1731 !whole.is_empty() && whole.bytes().all(|b| b.is_ascii_digit()),
1732 "{field}: `{raw}` is not a decimal amount"
1733 );
1734 anyhow::ensure!(
1735 fraction.bytes().all(|b| b.is_ascii_digit()),
1736 "{field}: `{raw}` is not a decimal amount"
1737 );
1738 let decimals = usize::from(decimals);
1739 anyhow::ensure!(
1740 fraction.len() <= decimals,
1741 "{field}: `{raw}` has {} decimal places, but the collateral asset has \
1742 only {decimals}; the chain cannot represent it",
1743 fraction.len()
1744 );
1745 let digits = format!("{whole}{fraction}{}", "0".repeat(decimals - fraction.len()));
1746 digits
1747 .parse::<u64>()
1748 .map_err(|e| anyhow::anyhow!("{field}: `{raw}` does not fit a u64: {e}"))
1749}
1750
1751fn tier_params(
1752 tier: &MarginTierConfig,
1753 collateral_decimals: u8,
1754) -> anyhow::Result<o2_tools::prop::TierParams> {
1755 let fee = |value: &str, what: &str| {
1756 collateral_amount(
1757 value,
1758 collateral_decimals,
1759 &format!("tier {} {what}", tier.tier_id),
1760 )
1761 };
1762 Ok(o2_tools::prop::TierParams {
1763 line: tier.line,
1764 leverage: tier.leverage,
1765 duration: tier.duration,
1766 maintenance_bps: tier.maintenance_bps,
1767 open_buffer_bps: tier.open_buffer_bps,
1768 liq_price_factor: tier.liq_price_factor,
1769 prolong_fee: [
1770 fee(&tier.prolong_fee[0], "prolong_fee[0]")?,
1771 fee(&tier.prolong_fee[1], "prolong_fee[1]")?,
1772 fee(&tier.prolong_fee[2], "prolong_fee[2]")?,
1773 fee(&tier.prolong_fee[3], "prolong_fee[3]")?,
1774 ],
1775 max_credit_line_bps: tier.max_credit_line_bps,
1776 max_price_age: tier.max_price_age,
1777 open_fee: fee(&tier.open_fee, "open_fee")?,
1778 profit_share_bps: tier.profit_share_bps,
1779 price_band_bps: tier.price_band_bps,
1780 })
1781}
1782
1783fn tier_book_route_changed(
1785 sidecar: bool,
1786 pairs: &[OrderBookConfig],
1787 live_books: &[ContractId],
1788) -> bool {
1789 pairs.iter().any(|pair| {
1790 let previous_book = if sidecar {
1791 pair.contract_id
1792 } else {
1793 pair.sidecar
1794 .as_ref()
1795 .and_then(|settings| settings.contract_id)
1796 };
1797 previous_book.is_some_and(|book| live_books.contains(&book))
1798 })
1799}
1800
1801fn resolve_tier_market(
1803 market: &str,
1804 pairs: &[OrderBookConfig],
1805 sidecar: bool,
1806) -> anyhow::Result<ContractId> {
1807 let wanted = market.trim();
1808 let wanted_id = wanted
1809 .strip_prefix("0x")
1810 .unwrap_or(wanted)
1811 .to_ascii_lowercase();
1812 let pair = pairs
1813 .iter()
1814 .find(|pair| {
1815 let symbol = format!("{}/{}", pair.base.symbol, pair.quote.symbol);
1816 symbol.eq_ignore_ascii_case(wanted)
1817 || hex::encode(*pair.market_id) == wanted_id
1818 })
1819 .with_context(|| format!("margin tier references unknown market `{market}`"))?;
1820 if sidecar {
1821 pair.sidecar
1822 .as_ref()
1823 .and_then(|settings| settings.contract_id)
1824 .with_context(|| {
1825 format!("margin tier market `{market}` has no deployed sidecar")
1826 })
1827 } else {
1828 pair.contract_id.with_context(|| {
1829 format!("margin tier market `{market}` has no deployed order book")
1830 })
1831 }
1832}
1833
1834fn parse_margin_identity(s: &str) -> anyhow::Result<Identity> {
1836 if let Some(hex_part) = s.strip_prefix("address:") {
1837 Ok(Identity::Address(fuels::types::Address::new(
1838 parse_margin_bytes32(hex_part)?,
1839 )))
1840 } else if let Some(hex_part) = s.strip_prefix("contract:") {
1841 Ok(Identity::ContractId(ContractId::new(parse_margin_bytes32(
1842 hex_part,
1843 )?)))
1844 } else {
1845 anyhow::bail!("expected `address:0x..` or `contract:0x..`, got `{s}`")
1846 }
1847}
1848
1849fn parse_margin_bytes32(s: &str) -> anyhow::Result<[u8; 32]> {
1850 let raw = s.trim().strip_prefix("0x").unwrap_or(s.trim());
1851 let bytes = hex::decode(raw)
1852 .map_err(|e| anyhow::anyhow!("expected 32 hex bytes, got `{s}`: {e}"))?;
1853 bytes
1854 .try_into()
1855 .map_err(|_| anyhow::anyhow!("expected 32 hex bytes, got `{s}`"))
1856}
1857
1858#[allow(clippy::too_many_arguments)]
1863async fn transfer_ownership<W>(
1864 wallet: &W,
1865 params: &DeployParams,
1866 order_book_registry: &OrderBookRegistryManager<W>,
1867 trade_account_registry: &TradeAccountRegistryManager<W>,
1868 trade_account_oracle_deploy: &TradeAccountDeploy<W>,
1869 trial_trade_account_oracle_id: ContractId,
1870 order_book_blacklist_id: Option<ContractId>,
1871 order_book_whitelist_id: Option<ContractId>,
1872) -> anyhow::Result<()>
1873where
1874 W: Account + ViewOnlyAccount + Clone + 'static,
1875{
1876 if let Some(new_proxy_owner) = params.new_proxy_owner {
1877 let new_identity = Identity::Address(new_proxy_owner);
1878 tracing::info!(
1879 "Transferring OrderBookRegistry proxy ownership to {}",
1880 new_proxy_owner
1881 );
1882 order_book_registry
1883 .registry_proxy
1884 .methods()
1885 .set_owner(new_identity)
1886 .call()
1887 .await?;
1888 tracing::info!(
1889 "Transferring TradeAccountRegistry proxy ownership to {}",
1890 new_proxy_owner
1891 );
1892 trade_account_registry
1893 .registry_proxy
1894 .methods()
1895 .set_owner(new_identity)
1896 .call()
1897 .await?;
1898 }
1899
1900 if let Some(new_contract_owner) = params.new_contract_owner {
1901 let new_identity = Identity::Address(new_contract_owner);
1902 tracing::info!(
1903 "Transferring TradeAccountOracle ownership to {}",
1904 new_contract_owner
1905 );
1906 trade_account_oracle_deploy
1907 .oracle
1908 .methods()
1909 .transfer_ownership(new_identity)
1910 .call()
1911 .await?;
1912 tracing::info!(
1913 "Transferring TrialTradeAccountOracle ownership to {}",
1914 new_contract_owner
1915 );
1916 TrialTradingAccountOracle::new(trial_trade_account_oracle_id, wallet.clone())
1917 .methods()
1918 .transfer_ownership(new_identity)
1919 .call()
1920 .await?;
1921 tracing::info!(
1922 "Transferring TradeAccountRegistry ownership to {}",
1923 new_contract_owner
1924 );
1925 trade_account_registry
1926 .registry
1927 .methods()
1928 .transfer_ownership(new_identity)
1929 .call()
1930 .await?;
1931 tracing::info!(
1932 "Transferring OrderBookRegistry ownership to {}",
1933 new_contract_owner
1934 );
1935 order_book_registry
1936 .registry
1937 .methods()
1938 .transfer_ownership(new_identity)
1939 .call()
1940 .await?;
1941 if let Some(blacklist_id) = order_book_blacklist_id {
1942 tracing::info!(
1943 "Transferring OrderBookBlacklist ownership to {}",
1944 new_contract_owner
1945 );
1946 OrderBookBlacklist::new(blacklist_id, wallet.clone())
1947 .methods()
1948 .transfer_ownership(new_identity)
1949 .call()
1950 .await?;
1951 }
1952 if let Some(whitelist_id) = order_book_whitelist_id {
1953 tracing::info!(
1954 "Transferring OrderBookWhitelist ownership to {}",
1955 new_contract_owner
1956 );
1957 OrderBookWhitelist::new(whitelist_id, wallet.clone())
1958 .methods()
1959 .transfer_ownership(new_identity)
1960 .call()
1961 .await?;
1962 }
1963 }
1964
1965 Ok(())
1966}
1967
1968async fn deploy_order_book_blacklist<W>(
1973 deployer_wallet: W,
1974 deploy_blacklist: bool,
1975 order_book_blacklist_id: Option<ContractId>,
1976 salt: Salt,
1977) -> anyhow::Result<Option<ContractId>>
1978where
1979 W: Account + ViewOnlyAccount + Clone + 'static,
1980{
1981 match order_book_blacklist_id {
1982 Some(order_book_blacklist_id) => {
1983 tracing::info!(
1984 "Using existing OrderBookBlacklist: {}",
1985 order_book_blacklist_id
1986 );
1987 Ok(Some(order_book_blacklist_id))
1988 }
1989 None => {
1990 if !deploy_blacklist {
1991 return Ok(None);
1992 }
1993 tracing::info!("Deploying OrderBookBlacklist");
1994 let order_book_blacklist = OrderBookDeploy::deploy_order_book_blacklist(
1995 &deployer_wallet,
1996 &Identity::Address(ViewOnlyAccount::address(&deployer_wallet)),
1997 &OrderBookDeployConfig {
1998 salt,
1999 ..Default::default()
2000 },
2001 )
2002 .await?;
2003 tracing::info!("OrderBookBlacklist: {}", order_book_blacklist.contract_id());
2004 Ok(Some(order_book_blacklist.contract_id()))
2005 }
2006 }
2007}
2008
2009async fn deploy_order_book_whitelist<W>(
2010 deployer_wallet: W,
2011 deploy_whitelist: bool,
2012 order_book_whitelist_id: Option<ContractId>,
2013 salt: Salt,
2014) -> anyhow::Result<Option<ContractId>>
2015where
2016 W: Account + ViewOnlyAccount + Clone + 'static,
2017{
2018 match (order_book_whitelist_id, deploy_whitelist) {
2019 (Some(order_book_whitelist_id), false)
2020 | (Some(order_book_whitelist_id), true) => {
2021 tracing::info!(
2022 "Using existing OrderBookWhitelist: {}",
2023 order_book_whitelist_id
2024 );
2025 Ok(Some(order_book_whitelist_id))
2026 }
2027 (None, false) => Ok(None),
2028 (None, true) => {
2029 tracing::info!("Deploying OrderBookWhitelist");
2030 let trade_account_whitelist = OrderBookDeploy::deploy_order_book_whitelist(
2031 &deployer_wallet,
2032 &Identity::Address(ViewOnlyAccount::address(&deployer_wallet)),
2033 &OrderBookDeployConfig {
2034 salt,
2035 ..Default::default()
2036 },
2037 )
2038 .await?;
2039 tracing::info!(
2040 "OrderBookWhitelist: {}",
2041 trade_account_whitelist.contract_id()
2042 );
2043 Ok(Some(trade_account_whitelist.contract_id()))
2044 }
2045 }
2046}
2047
2048async fn load_or_recover_trade_account_oracle<W>(
2052 deployer_wallet: &W,
2053 oracle_id: ContractId,
2054) -> anyhow::Result<(TradeAccountDeploy<W>, ContractId)>
2055where
2056 W: Account + ViewOnlyAccount + Clone + 'static,
2057{
2058 let oracle = TradingAccountOracle::new(oracle_id, deployer_wallet.clone());
2059 let impl_id = oracle
2060 .methods()
2061 .get_trade_account_impl()
2062 .simulate(Execution::state_read_only())
2063 .await?
2064 .value;
2065
2066 let blob_id = match impl_id {
2067 Some(id) => id,
2068 None => {
2069 tracing::info!(
2070 "Trade account implementation not set on oracle {}, deploying...",
2071 oracle_id
2072 );
2073 let blob = TradeAccountDeploy::trade_account_blob(
2074 deployer_wallet,
2075 &Default::default(),
2076 )
2077 .await?;
2078 TradeAccountDeploy::deploy_trade_account_blob(
2079 deployer_wallet,
2080 &DeployConfig::Latest(Default::default()),
2081 )
2082 .await?;
2083 oracle
2084 .methods()
2085 .set_trade_account_impl(ContractId::from(blob.id))
2086 .call()
2087 .await?;
2088 ContractId::from(blob.id)
2089 }
2090 };
2091
2092 let deploy = TradeAccountDeploy {
2093 oracle,
2094 oracle_id,
2095 trade_account_blob_id: blob_id.into(),
2096 deployer_wallet: deployer_wallet.clone(),
2097 proxy: None,
2098 proxy_id: None,
2099 };
2100 Ok((deploy, blob_id))
2101}
2102
2103async fn deploy_trade_account_oracle<W>(
2104 deployer_wallet: W,
2105 should_upgrade_bytecode: bool,
2106 trade_account_oracle_id: Option<ContractId>,
2107 salt: Salt,
2108) -> anyhow::Result<(TradeAccountDeploy<W>, ContractId)>
2109where
2110 W: Account + ViewOnlyAccount + Clone + 'static,
2111{
2112 let (trade_account_oracle_deploy, mut trade_account_blob_id) =
2113 match trade_account_oracle_id {
2114 Some(oracle_id) => {
2115 load_or_recover_trade_account_oracle(&deployer_wallet, oracle_id).await?
2116 }
2117 None => {
2118 let deploy = TradeAccountDeploy::deploy(
2119 &deployer_wallet,
2120 &DeployConfig::Latest(TradeAccountDeployConfig {
2121 salt,
2122 ..Default::default()
2123 }),
2124 )
2125 .await?;
2126 let blob_id = deploy
2127 .oracle
2128 .methods()
2129 .get_trade_account_impl()
2130 .simulate(Execution::state_read_only())
2131 .await?
2132 .value
2133 .context("Trade account impl should exist after fresh deploy")?;
2134 (deploy, blob_id)
2135 }
2136 };
2137 tracing::info!(
2138 "TradeAccountOracle: {}",
2139 trade_account_oracle_deploy.oracle_id
2140 );
2141
2142 if should_upgrade_bytecode {
2143 let trade_account_blob =
2144 TradeAccountDeploy::trade_account_blob(&deployer_wallet, &Default::default())
2145 .await?;
2146 if ContractId::from(trade_account_blob.id) != trade_account_blob_id {
2147 tracing::info!(
2148 "Update TradeAccountImpl on Oracle from {:?} to new blob {:?}",
2149 trade_account_blob_id,
2150 ContractId::from(trade_account_blob.id)
2151 );
2152 TradeAccountDeploy::deploy_trade_account_blob(
2153 &deployer_wallet,
2154 &DeployConfig::Latest(Default::default()),
2155 )
2156 .await?;
2157 trade_account_oracle_deploy
2158 .oracle
2159 .methods()
2160 .set_trade_account_impl(ContractId::from(trade_account_blob.id))
2161 .call()
2162 .await?;
2163 trade_account_blob_id = ContractId::from(trade_account_blob.id);
2164 }
2165 }
2166
2167 Ok((trade_account_oracle_deploy, trade_account_blob_id))
2168}
2169
2170async fn deploy_trial_trade_account_oracle<W>(
2178 deployer_wallet: W,
2179 should_upgrade_bytecode: bool,
2180 trial_trade_account_oracle_id: Option<ContractId>,
2181 trial_cosigner: Option<fuels::types::Address>,
2182 salt: Salt,
2183) -> anyhow::Result<ContractId>
2184where
2185 W: Account + ViewOnlyAccount + Clone + 'static,
2186{
2187 let mut trial_deploy_config = TrialTradeAccountDeployConfig {
2188 salt,
2189 ..Default::default()
2190 };
2191 if let Some(cosigner) = trial_cosigner {
2192 trial_deploy_config = trial_deploy_config.with_cosigner(cosigner);
2193 }
2194 let deploy_config = TrialDeployConfig::Latest(trial_deploy_config);
2195
2196 let oracle_id = match trial_trade_account_oracle_id {
2197 None => {
2198 let trial_deploy =
2199 TrialTradeAccountDeploy::deploy(&deployer_wallet, &deploy_config).await?;
2200 tracing::info!(
2201 "TrialTradeAccountOracle: {} (implementation {:?}, cosigner {:?})",
2202 trial_deploy.oracle_id,
2203 trial_deploy.trial_trade_account_blob_id,
2204 trial_cosigner,
2205 );
2206 trial_deploy.oracle_id
2207 }
2208 Some(oracle_id) => {
2209 let current_trial_impl =
2210 TrialTradingAccountOracle::new(oracle_id, deployer_wallet.clone())
2211 .methods()
2212 .get_trial_account_impl()
2213 .simulate(Execution::state_read_only())
2214 .await?
2215 .value;
2216 if current_trial_impl.is_none()
2217 || should_upgrade_bytecode
2218 || trial_cosigner.is_some()
2219 {
2220 let trial_deploy = TrialTradeAccountDeploy::deploy_to_oracle(
2221 &deployer_wallet,
2222 oracle_id,
2223 &deploy_config,
2224 )
2225 .await?;
2226 tracing::info!(
2227 "Trial implementation {:?} deployed to oracle {} (cosigner {:?})",
2228 trial_deploy.trial_trade_account_blob_id,
2229 oracle_id,
2230 trial_cosigner,
2231 );
2232 }
2233 oracle_id
2234 }
2235 };
2236
2237 Ok(oracle_id)
2238}
2239
2240async fn deploy_trade_account_registry<W>(
2241 deployer_wallet: W,
2242 should_upgrade_bytecode: bool,
2243 trade_account_deploy: TradeAccountDeploy<W>,
2244 trial_trade_account_oracle_id: ContractId,
2245 trade_account_registry_id: Option<ContractId>,
2246 salt: Salt,
2247) -> anyhow::Result<(TradeAccountRegistryManager<W>, ContractId)>
2248where
2249 W: Account + ViewOnlyAccount + Clone + 'static,
2250{
2251 let trade_account_oracle_id = trade_account_deploy.oracle_id;
2252 let trade_account_registry = match trade_account_registry_id {
2253 Some(trade_account_registry_contract_id) => TradeAccountRegistryManager::new(
2254 deployer_wallet.clone(),
2255 trade_account_registry_contract_id,
2256 ),
2257 None => {
2258 let trade_account_registry_deploy_config = TradeAccountRegistryDeployConfig {
2259 salt,
2260 ..Default::default()
2261 };
2262 TradeAccountRegistryManager::deploy(
2263 &deployer_wallet,
2264 trade_account_oracle_id,
2265 trial_trade_account_oracle_id,
2266 &trade_account_registry_deploy_config,
2267 )
2268 .await?
2269 }
2270 };
2271 tracing::info!(
2272 "TradeAccountRegistry: {}",
2273 trade_account_registry.contract_id
2274 );
2275 let mut trade_account_registry_blob_id = match trade_account_registry
2276 .registry_proxy
2277 .methods()
2278 .proxy_target()
2279 .simulate(Execution::state_read_only())
2280 .await?
2281 .value
2282 {
2283 Some(blob_id) => blob_id,
2284 None => {
2285 tracing::info!("TradeAccountRegistry proxy target not set, initializing...");
2286 trade_account_registry
2290 .registry_proxy
2291 .methods()
2292 .initialize_proxy()
2293 .call()
2294 .await?;
2295 trade_account_registry
2296 .registry
2297 .methods()
2298 .initialize()
2299 .call()
2300 .await?;
2301 trade_account_registry
2302 .registry_proxy
2303 .methods()
2304 .proxy_target()
2305 .simulate(Execution::state_read_only())
2306 .await?
2307 .value
2308 .context("TradeAccountRegistry proxy target should be set after initialization")?
2309 }
2310 };
2311
2312 if should_upgrade_bytecode {
2313 let trade_account_registry_deploy_config = TradeAccountRegistryDeployConfig {
2317 registry_config: trade_account_registry
2318 .live_prop_config(TradeAccountRegistryConfigurables::default())
2319 .await?,
2320 ..Default::default()
2321 };
2322 let trade_account_proxy_blob = TradeAccountRegistryManager::register_proxy_blob(
2323 &deployer_wallet,
2324 &trade_account_registry_deploy_config,
2325 )
2326 .await?;
2327 let trial_trade_account_proxy_blob =
2328 TradeAccountRegistryManager::register_trial_proxy_blob(
2329 &deployer_wallet,
2330 &trade_account_registry_deploy_config,
2331 )
2332 .await?;
2333
2334 let trade_account_register_blob = TradeAccountRegistryManager::register_blob(
2335 &deployer_wallet,
2336 trade_account_oracle_id,
2337 trial_trade_account_oracle_id,
2338 trade_account_proxy_blob.id,
2339 trial_trade_account_proxy_blob.id,
2340 &trade_account_registry_deploy_config,
2341 )
2342 .await?;
2343
2344 if trade_account_registry_blob_id
2345 != ContractId::from(trade_account_register_blob.id)
2346 {
2347 tracing::info!(
2348 "Upgrade TradeAccountRegistry blob from {:?} to {:?}",
2349 trade_account_registry.contract_id,
2350 ContractId::from(trade_account_register_blob.id)
2351 );
2352 trade_account_registry
2356 .upgrade(
2357 trade_account_oracle_id,
2358 trial_trade_account_oracle_id,
2359 &trade_account_registry_deploy_config,
2360 )
2361 .await?;
2362 trade_account_registry_blob_id = trade_account_register_blob.id.into();
2363 }
2364 }
2365 Ok((trade_account_registry, trade_account_registry_blob_id))
2366}
2367
2368async fn deploy_order_book_registry<W>(
2369 deployer_wallet: W,
2370 should_upgrade_bytecode: bool,
2371 order_book_registry_id: Option<ContractId>,
2372 salt: Salt,
2373) -> anyhow::Result<(OrderBookRegistryManager<W>, ContractId)>
2374where
2375 W: Account + ViewOnlyAccount + Clone + 'static,
2376{
2377 let order_book_registry = match order_book_registry_id {
2378 Some(registry_contract_id) => {
2379 OrderBookRegistryManager::new(deployer_wallet.clone(), registry_contract_id)
2380 }
2381 None => {
2382 OrderBookRegistryManager::deploy(
2383 &deployer_wallet,
2384 &OrderBookRegistryDeployConfig {
2385 salt,
2386 ..Default::default()
2387 },
2388 )
2389 .await?
2390 }
2391 };
2392 tracing::info!("OrderBookRegistry: {}", order_book_registry.contract_id);
2393 let mut order_book_registry_blob_id = match order_book_registry
2394 .registry_proxy
2395 .methods()
2396 .proxy_target()
2397 .simulate(Execution::state_read_only())
2398 .await?
2399 .value
2400 {
2401 Some(blob_id) => blob_id,
2402 None => {
2403 tracing::info!("OrderBookRegistry proxy target not set, initializing...");
2404 order_book_registry
2408 .registry_proxy
2409 .methods()
2410 .initialize_proxy()
2411 .call()
2412 .await?;
2413 order_book_registry
2414 .registry
2415 .methods()
2416 .initialize()
2417 .call()
2418 .await?;
2419 order_book_registry
2420 .registry_proxy
2421 .methods()
2422 .proxy_target()
2423 .simulate(Execution::state_read_only())
2424 .await?
2425 .value
2426 .context(
2427 "OrderBookRegistry proxy target should be set after initialization",
2428 )?
2429 }
2430 };
2431
2432 if should_upgrade_bytecode {
2433 let order_book_register_deploy_config = OrderBookRegistryDeployConfig::default();
2434 let order_book_register_blob = OrderBookRegistryManager::register_blob(
2435 &deployer_wallet,
2436 &order_book_register_deploy_config,
2437 )
2438 .await?;
2439 if order_book_registry_blob_id != order_book_register_blob.id.into() {
2440 tracing::info!(
2441 "Upgrade OrderBookRegistry blob from {:?} to {:?}",
2442 order_book_registry.contract_id,
2443 ContractId::from(order_book_register_blob.id)
2444 );
2445 order_book_registry
2446 .upgrade(&order_book_register_deploy_config)
2447 .await?;
2448 order_book_registry_blob_id = order_book_register_blob.id.into();
2449 }
2450 }
2451
2452 Ok((order_book_registry, order_book_registry_blob_id))
2453}
2454
2455async fn deploy_order_books<W>(
2456 deployer_wallet: W,
2457 should_upgrade_bytecode: bool,
2458 order_book_blacklist_id: Option<ContractId>,
2459 order_book_whitelist_id: Option<ContractId>,
2460 order_book_registry: OrderBookRegistryManager<W>,
2461 order_book_configs: &mut [OrderBookConfig],
2462 ownership_options: OwnershipTransferOptions,
2463) -> anyhow::Result<Vec<OrderBookConfig>>
2464where
2465 W: Account + ViewOnlyAccount + Clone + 'static,
2466{
2467 audit_order_book_precision(order_book_configs)?;
2472
2473 let mut pairs: Vec<OrderBookConfig> = Vec::with_capacity(order_book_configs.len());
2474
2475 for order_book_config in order_book_configs.iter_mut() {
2476 let pair = deploy_single_order_book(
2477 &deployer_wallet,
2478 should_upgrade_bytecode,
2479 order_book_blacklist_id,
2480 order_book_whitelist_id,
2481 &order_book_registry,
2482 order_book_config,
2483 &ownership_options,
2484 )
2485 .await?;
2486 pairs.push(pair);
2487 }
2488
2489 Ok(pairs)
2490}
2491
2492async fn deploy_single_order_book<W>(
2493 deployer_wallet: &W,
2494 should_upgrade_bytecode: bool,
2495 order_book_blacklist_id: Option<ContractId>,
2496 order_book_whitelist_id: Option<ContractId>,
2497 order_book_registry: &OrderBookRegistryManager<W>,
2498 order_book_config: &mut OrderBookConfig,
2499 ownership_options: &OwnershipTransferOptions,
2500) -> anyhow::Result<OrderBookConfig>
2501where
2502 W: Account + ViewOnlyAccount + Clone + 'static,
2503{
2504 let market_symbol = format!(
2505 "{}/{}",
2506 order_book_config.base.symbol, order_book_config.quote.symbol
2507 );
2508 let market_id = MarketIdAssets {
2509 base_asset: order_book_config.base.asset,
2510 quote_asset: order_book_config.quote.asset,
2511 };
2512 let order_book_configurables = build_order_book_configurables(
2513 order_book_config,
2514 order_book_blacklist_id,
2515 order_book_whitelist_id,
2516 deployer_wallet,
2517 )?;
2518
2519 let order_book = load_or_deploy_order_book(
2520 deployer_wallet,
2521 order_book_registry,
2522 &market_id,
2523 &market_symbol,
2524 &order_book_configurables,
2525 order_book_config,
2526 )
2527 .await?;
2528
2529 tracing::info!(
2530 "[{}] OrderBook: {}",
2531 market_symbol,
2532 order_book.contract.contract_id()
2533 );
2534
2535 let order_book_blob_id = maybe_upgrade_order_book(
2536 deployer_wallet,
2537 should_upgrade_bytecode,
2538 &order_book,
2539 order_book_config,
2540 order_book_configurables,
2541 &market_symbol,
2542 )
2543 .await?;
2544
2545 set_order_book_maintainer(&order_book, ownership_options, &market_symbol).await?;
2549
2550 sidecar::deploy_sidecar(
2551 deployer_wallet,
2552 &order_book,
2553 order_book_config,
2554 should_upgrade_bytecode,
2555 ownership_options,
2556 )
2557 .await?;
2558 sidecar::grant_house_roles(&order_book, &order_book_config.house_identities).await?;
2559
2560 transfer_order_book_ownership(&order_book, ownership_options, &market_symbol).await?;
2561
2562 order_book_config.contract_id = Some(order_book.contract.contract_id());
2563 order_book_config.blob_id = order_book_blob_id.into();
2564
2565 Ok(order_book_config.clone())
2566}
2567
2568pub fn audit_order_book_precision(configs: &[OrderBookConfig]) -> anyhow::Result<()> {
2576 for config in configs {
2577 order_book_precision_exponents(config)?;
2578 }
2579 Ok(())
2580}
2581
2582fn order_book_precision_exponents(config: &OrderBookConfig) -> anyhow::Result<(u8, u8)> {
2602 let price_precision = config
2603 .quote
2604 .decimals
2605 .checked_sub(config.quote.max_precision)
2606 .ok_or_else(|| {
2607 anyhow::anyhow!(
2608 "quote max_precision ({}) exceeds decimals ({})",
2609 config.quote.max_precision,
2610 config.quote.decimals
2611 )
2612 })?;
2613 let quantity_precision = config
2614 .base
2615 .decimals
2616 .checked_sub(config.base.max_precision)
2617 .ok_or_else(|| {
2618 anyhow::anyhow!(
2619 "base max_precision ({}) exceeds decimals ({})",
2620 config.base.max_precision,
2621 config.base.decimals
2622 )
2623 })?;
2624
2625 let spent = config.base.max_precision as u16 + config.quote.max_precision as u16;
2628 let budget = config.quote.decimals as u16;
2629
2630 if config.allow_fractional_price {
2631 if spent > budget {
2632 tracing::warn!(
2633 "{}/{}: base max_precision ({}) + quote max_precision ({}) = {spent} \
2634 exceeds the quote's {budget} decimals; ALLOW_FRACTIONAL_PRICE is set, \
2635 so notionals will silently truncate rather than revert",
2636 config.base.symbol,
2637 config.quote.symbol,
2638 config.base.max_precision,
2639 config.quote.max_precision,
2640 );
2641 }
2642 } else {
2643 anyhow::ensure!(
2644 spent <= budget,
2645 "{}/{}: base max_precision ({}) + quote max_precision ({}) = {spent} \
2646 exceeds the quote's {budget} decimals, so every order would revert \
2647 with FractionalPrice. Lower one of the two until they sum to {budget} \
2648 or less.",
2649 config.base.symbol,
2650 config.quote.symbol,
2651 config.base.max_precision,
2652 config.quote.max_precision,
2653 );
2654 }
2655
2656 Ok((price_precision, quantity_precision))
2657}
2658
2659fn build_order_book_configurables<W: ViewOnlyAccount>(
2660 config: &OrderBookConfig,
2661 order_book_blacklist_id: Option<ContractId>,
2662 order_book_whitelist_id: Option<ContractId>,
2663 deployer_wallet: &W,
2664) -> anyhow::Result<OrderBookConfigurables> {
2665 let (price_precision, quantity_precision) = order_book_precision_exponents(config)?;
2666
2667 Ok(OrderBookConfigurables::default()
2668 .with_MIN_ORDER(config.min_order)?
2669 .with_ALLOW_FRACTIONAL_PRICE(config.allow_fractional_price)?
2670 .with_TAKER_FEE(config.taker_fee.into())?
2671 .with_MAKER_FEE(config.maker_fee.into())?
2672 .with_DUST(config.dust)?
2673 .with_PRICE_WINDOW(config.price_window as u64)?
2674 .with_BASE_DECIMALS(10u64.pow(config.base.decimals as u32))?
2675 .with_QUOTE_DECIMALS(10u64.pow(config.quote.decimals as u32))?
2676 .with_BASE_SYMBOL(SizedAsciiString::new_with_right_whitespace_padding(
2677 config.base.symbol.clone(),
2678 )?)?
2679 .with_QUOTE_SYMBOL(SizedAsciiString::new_with_right_whitespace_padding(
2680 config.quote.symbol.clone(),
2681 )?)?
2682 .with_PRICE_PRECISION(10u64.pow(price_precision as u32))?
2683 .with_QUANTITY_PRECISION(10u64.pow(quantity_precision as u32))?
2684 .with_INITIAL_OWNER(o2_tools::order_book_deploy::State::Initialized(
2685 Identity::Address(ViewOnlyAccount::address(deployer_wallet)),
2686 ))?
2687 .with_WHITE_LIST_CONTRACT(order_book_whitelist_id)?
2688 .with_BLACK_LIST_CONTRACT(order_book_blacklist_id)?)
2689}
2690
2691async fn load_or_deploy_order_book<W>(
2692 deployer_wallet: &W,
2693 order_book_registry: &OrderBookRegistryManager<W>,
2694 market_id: &MarketIdAssets,
2695 market_symbol: &str,
2696 order_book_configurables: &OrderBookConfigurables,
2697 order_book_config: &OrderBookConfig,
2698) -> anyhow::Result<OrderBookManager<W>>
2699where
2700 W: Account + ViewOnlyAccount + Clone + 'static,
2701{
2702 let register_contract_id = order_book_registry
2703 .registry
2704 .methods()
2705 .get_order_book(to_registry_market_id(market_id))
2706 .simulate(Execution::state_read_only())
2707 .await?
2708 .value;
2709
2710 match register_contract_id {
2711 Some(contract_id) => {
2712 let order_book_deploy = OrderBookDeploy::new(
2713 deployer_wallet.clone(),
2714 contract_id,
2715 market_id.base_asset,
2716 market_id.quote_asset,
2717 );
2718 let proxy_target = order_book_deploy
2721 .order_book_proxy
2722 .methods()
2723 .proxy_target()
2724 .simulate(Execution::state_read_only())
2725 .await?
2726 .value;
2727 if proxy_target.is_none() {
2728 tracing::info!(
2729 "[{}] Proxy target not set, initializing...",
2730 market_symbol
2731 );
2732 order_book_deploy.initialize().await?;
2733 }
2734 Ok(OrderBookManager::new(
2735 deployer_wallet,
2736 10u64.pow(order_book_config.base.decimals as u32),
2737 10u64.pow(order_book_config.quote.decimals as u32),
2738 &order_book_deploy,
2739 ))
2740 }
2741 None => {
2742 let (order_book_deployment, initialization_required) =
2743 OrderBookDeploy::deploy_without_initialization(
2744 deployer_wallet,
2745 market_id.base_asset,
2746 market_id.quote_asset,
2747 &OrderBookDeployConfig {
2748 order_book_configurables: order_book_configurables.clone(),
2749 salt: Salt::from(*order_book_registry.contract_id),
2750 ..Default::default()
2751 },
2752 )
2753 .await?;
2754
2755 order_book_registry
2756 .register_order_book(
2757 to_registry_market_id(market_id),
2758 order_book_deployment.contract_id,
2759 )
2760 .await?;
2761
2762 if initialization_required {
2763 order_book_deployment.initialize().await?;
2764 }
2765 Ok(OrderBookManager::new(
2766 deployer_wallet,
2767 10u64.pow(order_book_config.base.decimals as u32),
2768 10u64.pow(order_book_config.quote.decimals as u32),
2769 &order_book_deployment,
2770 ))
2771 }
2772 }
2773}
2774
2775async fn maybe_upgrade_order_book<W>(
2776 deployer_wallet: &W,
2777 should_upgrade_bytecode: bool,
2778 order_book: &OrderBookManager<W>,
2779 order_book_config: &OrderBookConfig,
2780 order_book_configurables: OrderBookConfigurables,
2781 market_symbol: &str,
2782) -> anyhow::Result<ContractId>
2783where
2784 W: Account + ViewOnlyAccount + Clone + 'static,
2785{
2786 let mut order_book_blob_id = order_book
2787 .proxy
2788 .methods()
2789 .proxy_target()
2790 .simulate(Execution::state_read_only())
2791 .await?
2792 .value
2793 .context("Order book proxy target should be set after initialization")?;
2794
2795 if should_upgrade_bytecode {
2796 let order_book_deploy_config = OrderBookDeployConfig {
2797 order_book_configurables,
2798 ..Default::default()
2799 };
2800 let order_book_deploy = OrderBookDeploy::new(
2801 deployer_wallet.clone(),
2802 order_book.contract.contract_id(),
2803 order_book_config.base.asset,
2804 order_book_config.quote.asset,
2805 );
2806 let order_book_manager = OrderBookManager::new(
2807 deployer_wallet,
2808 10u64.pow(order_book_config.base.decimals as u32),
2809 10u64.pow(order_book_config.quote.decimals as u32),
2810 &order_book_deploy,
2811 );
2812 let order_book_blob = OrderBookDeploy::order_book_blob(
2813 deployer_wallet,
2814 order_book_config.base.asset,
2815 order_book_config.quote.asset,
2816 &order_book_deploy_config,
2817 )
2818 .await?;
2819
2820 if order_book_blob_id != order_book_blob.id.into() {
2821 tracing::info!(
2822 "[{}] Upgrade OrderBook blob from {:?} to {:?}",
2823 market_symbol,
2824 order_book_blob_id,
2825 ContractId::from(order_book_blob.id)
2826 );
2827 order_book_manager
2828 .upgrade(&order_book_deploy_config)
2829 .await?;
2830 tracing::info!(
2831 "[{}] Emit new configuration event for {}",
2832 market_symbol,
2833 order_book.contract.contract_id()
2834 );
2835 order_book_manager.emit_config().await?;
2836 order_book_blob_id = order_book_blob.id.into();
2837 }
2838 }
2839
2840 Ok(order_book_blob_id)
2841}
2842
2843async fn set_order_book_maintainer<W>(
2844 order_book: &OrderBookManager<W>,
2845 ownership_options: &OwnershipTransferOptions,
2846 market_symbol: &str,
2847) -> anyhow::Result<()>
2848where
2849 W: Account + ViewOnlyAccount + Clone + 'static,
2850{
2851 for account in &ownership_options.revoke_orderbook_maintainers {
2855 tracing::info!(
2856 "[{}] Revoking ORDERBOOK_MAINTAINER_ROLE from {}",
2857 market_symbol,
2858 account
2859 );
2860 order_book
2861 .contract
2862 .methods()
2863 .owner_revoke_role(ORDERBOOK_MAINTAINER_ROLE, Identity::Address(*account))
2864 .call()
2865 .await?;
2866 }
2867
2868 for account in &ownership_options.new_orderbook_maintainers {
2869 tracing::info!(
2870 "[{}] Granting ORDERBOOK_MAINTAINER_ROLE to {}",
2871 market_symbol,
2872 account
2873 );
2874 order_book
2875 .contract
2876 .methods()
2877 .owner_grant_role(ORDERBOOK_MAINTAINER_ROLE, Identity::Address(*account))
2878 .call()
2879 .await?;
2880 }
2881
2882 Ok(())
2883}
2884
2885async fn transfer_order_book_ownership<W>(
2886 order_book: &OrderBookManager<W>,
2887 ownership_options: &OwnershipTransferOptions,
2888 market_symbol: &str,
2889) -> anyhow::Result<()>
2890where
2891 W: Account + ViewOnlyAccount + Clone + 'static,
2892{
2893 if let Some(new_owner) = ownership_options.new_proxy_owner {
2894 let new_identity = Identity::Address(new_owner);
2895 tracing::info!(
2896 "[{}] Transferring OrderBook proxy ownership to {}",
2897 market_symbol,
2898 new_owner
2899 );
2900 order_book
2901 .proxy
2902 .methods()
2903 .set_owner(new_identity)
2904 .call()
2905 .await?;
2906 }
2907
2908 if let Some(new_owner) = ownership_options.new_contract_owner {
2909 let new_identity = Identity::Address(new_owner);
2910 tracing::info!(
2911 "[{}] Transferring OrderBook contract ownership to {}",
2912 market_symbol,
2913 new_owner
2914 );
2915 order_book
2916 .contract
2917 .methods()
2918 .transfer_ownership(new_identity)
2919 .call()
2920 .await?;
2921 }
2922
2923 Ok(())
2924}
2925
2926#[cfg(test)]
2927mod tests {
2928 use super::*;
2929
2930 #[test]
2931 fn load_config_empty_path_returns_default() {
2932 let result: MarketsConfigPartial = load_config_from_file("").unwrap();
2933 assert!(result.pairs.is_empty());
2934 }
2935
2936 #[test]
2937 fn load_config_missing_file_errors() {
2938 let result: Result<MarketsConfigPartial, _> =
2939 load_config_from_file("nonexistent_file_12345.json");
2940 assert!(result.is_err());
2941 }
2942
2943 #[test]
2944 fn checked_sub_catches_overflow() {
2945 let decimals: u32 = 6;
2947 let max_precision: u32 = 8; let result = decimals.checked_sub(max_precision);
2950 assert!(
2951 result.is_none(),
2952 "should return None when max_precision > decimals"
2953 );
2954
2955 let result = 9u32.checked_sub(6);
2957 assert_eq!(result, Some(3));
2958 }
2959
2960 #[test]
2961 fn markets_config_partial_default_has_empty_pairs() {
2962 let config = MarketsConfigPartial::default();
2963 assert!(config.pairs.is_empty());
2964 }
2965
2966 pub(super) const LIVE_LEGACY_TIER: &str = r#"{
2969 "tier_id": 11,
2970 "line": "2500000000000",
2971 "leverage": 40,
2972 "duration": 0,
2973 "maintenance_bps": 150,
2974 "open_buffer_bps": 151,
2975 "liq_price_factor": 9900,
2976 "prolong_fee": ["10", "18", "60", "200"],
2977 "max_credit_line_bps": 20000,
2978 "max_price_age": 300,
2979 "open_fee": "0",
2980 "profit_share_bps": 0,
2981 "price_band_bps": 85,
2982 "markets": ["fwBTC/fUSDC", "fETH/fUSDC", "fXAUT/fUSDC"]
2983 }"#;
2984
2985 fn turbo_tier(turbo: &str) -> String {
2986 format!(
2987 r#"{{
2988 "tier_id": 33,
2989 "line": "10000000000000",
2990 "leverage": 40,
2991 "duration": 0,
2992 "maintenance_bps": 100,
2993 "open_buffer_bps": 101,
2994 "liq_price_factor": 9900,
2995 "prolong_fee": ["0", "0", "0", "0"],
2996 "max_credit_line_bps": 20000,
2997 "max_price_age": 300,
2998 "open_fee": "0",
2999 "profit_share_bps": 0,
3000 "price_band_bps": 100,
3001 "markets": ["fwBTC/fUSDC"],
3002 "turbo": {turbo}
3003 }}"#
3004 )
3005 }
3006
3007 const SHIPPED_TERMS: &str = r#"{
3008 "max_loss_bps": [150, 200, 250, 300],
3009 "rollover_profit_bps": 900,
3010 "max_rollovers": 3
3011 }"#;
3012
3013 #[test]
3017 fn a_live_legacy_tier_still_parses_and_defaults_to_selling() {
3018 let tier: MarginTierConfig = serde_json::from_str(LIVE_LEGACY_TIER).unwrap();
3019 assert_eq!(tier.tier_id, 11);
3020 assert!(tier.turbo.is_none(), "no terms means the legacy cohort");
3021 assert!(tier.enabled, "a tier with no opinion sells");
3022 assert!(!tier.sidecar, "omitted selection keeps canonical books");
3023 }
3024
3025 #[test]
3029 fn a_misspelled_tier_key_is_refused_rather_than_ignored() {
3030 let typo = LIVE_LEGACY_TIER.replace("\"leverage\"", "\"leverag\"");
3031 let err = serde_json::from_str::<MarginTierConfig>(&typo).unwrap_err();
3032 assert!(err.to_string().contains("leverag"), "{err}");
3033
3034 let typo = turbo_tier(&SHIPPED_TERMS.replace("max_loss_bps", "max_los_bps"));
3035 let err = serde_json::from_str::<MarginTierConfig>(&typo).unwrap_err();
3036 assert!(err.to_string().contains("max_los_bps"), "{err}");
3037 }
3038
3039 #[test]
3040 fn a_prepaid_tier_carries_its_terms_through_to_the_pools_shape() {
3041 let tier: MarginTierConfig =
3042 serde_json::from_str(&turbo_tier(SHIPPED_TERMS)).unwrap();
3043 assert!(!tier.sidecar, "prepaid terms do not select the sidecar");
3044 let turbo = tier.turbo.as_ref().expect("the tier declares terms");
3045 let terms = o2_tools::prop::TurboTerms::from(turbo);
3046 assert_eq!(terms.max_loss_bps, [150, 200, 250, 300]);
3047 assert_eq!(terms.rollover_profit_bps, 900);
3048 assert_eq!(terms.max_rollovers, 3);
3049
3050 let params = tier_params(&tier, 9).unwrap();
3051 validate_turbo_tier_config(&tier, ¶ms, Some(&terms))
3052 .expect("the shipped shape is publishable");
3053 }
3054
3055 #[test]
3056 fn prepaid_durations_parse_exactly_and_allow_valid_rollover_terms() {
3057 for (duration, json_duration) in [(0, "0"), (u64::MAX, "18446744073709551615")] {
3058 let json = turbo_tier(SHIPPED_TERMS)
3059 .replace("\"duration\": 0", &format!("\"duration\": {json_duration}"));
3060 let tier: MarginTierConfig = serde_json::from_str(&json).unwrap();
3061 assert_eq!(tier.duration, duration);
3062 let params = tier_params(&tier, 9).unwrap();
3063 assert_eq!(params.duration, duration);
3064 let terms = o2_tools::prop::TurboTerms::from(tier.turbo.as_ref().unwrap());
3065 validate_turbo_tier_config(&tier, ¶ms, Some(&terms))
3066 .expect("valid rollover terms are allowed even when expiry is perpetual");
3067 }
3068 }
3069
3070 #[test]
3071 fn a_three_percent_drawdown_needs_no_rollover() {
3072 let json = turbo_tier(
3073 r#"{
3074 "max_loss_bps": [300, 0, 0, 0],
3075 "rollover_profit_bps": 0,
3076 "max_rollovers": 0
3077 }"#,
3078 );
3079 for duration in [0, u64::MAX] {
3080 let mut tier: MarginTierConfig = serde_json::from_str(&json).unwrap();
3081 tier.duration = duration;
3082 let params = tier_params(&tier, 9).unwrap();
3083 let terms = o2_tools::prop::TurboTerms::from(tier.turbo.as_ref().unwrap());
3084 validate_turbo_tier_config(&tier, ¶ms, Some(&terms))
3085 .expect("the line-based floor needs no rollover state");
3086 }
3087 }
3088
3089 #[test]
3093 fn a_config_the_pool_would_refuse_is_refused_before_it_costs_a_transaction() {
3094 let check = |mutate: &dyn Fn(&mut MarginTierConfig), expect: &str| {
3095 let mut tier: MarginTierConfig =
3096 serde_json::from_str(&turbo_tier(SHIPPED_TERMS)).unwrap();
3097 mutate(&mut tier);
3098 let params = tier_params(&tier, 9).unwrap();
3099 let terms = tier.turbo.as_ref().map(o2_tools::prop::TurboTerms::from);
3100 let err = validate_turbo_tier_config(&tier, ¶ms, terms.as_ref())
3101 .expect_err("must be refused");
3102 assert!(
3103 err.to_string().contains(expect),
3104 "expected {expect:?} in: {err}"
3105 );
3106 };
3107
3108 for duration in [1, 604_800, u64::MAX - 1] {
3109 check(&|t| t.duration = duration, "duration");
3110 }
3111 check(&|t| t.prolong_fee[2] = "60".to_owned(), "prolong_fee");
3112 check(&|t| t.profit_share_bps = 500, "profit_share_bps");
3113 for cap in [9_999, 10_000] {
3114 check(&|t| t.max_credit_line_bps = cap, "max_credit_line_bps");
3115 }
3116 check(&|t| t.open_buffer_bps = 251, "FROZEN");
3117 check(
3120 &|t| t.turbo.as_mut().unwrap().max_loss_bps = [0, 200, 250, 300],
3121 "step 0 is 0",
3122 );
3123 check(
3124 &|t| t.turbo.as_mut().unwrap().max_loss_bps = [0; 4],
3125 "step 0 is 0",
3126 );
3127 check(
3131 &|t| t.turbo.as_mut().unwrap().max_loss_bps = [150, 0, 250, 300],
3132 "step 1 is 0",
3133 );
3134 check(
3135 &|t| t.turbo.as_mut().unwrap().max_loss_bps = [150, 200, 250, 0],
3136 "step 3 is 0",
3137 );
3138 check(
3140 &|t| t.turbo.as_mut().unwrap().max_loss_bps = [150, 100, 250, 300],
3141 "NARROWER",
3142 );
3143 {
3145 let mut tier: MarginTierConfig =
3146 serde_json::from_str(&turbo_tier(SHIPPED_TERMS)).unwrap();
3147 let turbo = tier.turbo.as_mut().unwrap();
3148 turbo.max_rollovers = 1;
3149 turbo.max_loss_bps = [150, 200, 0, 0];
3150 let params = tier_params(&tier, 9).unwrap();
3151 let terms = tier.turbo.as_ref().map(o2_tools::prop::TurboTerms::from);
3152 validate_turbo_tier_config(&tier, ¶ms, terms.as_ref())
3153 .expect("only reachable steps are policed");
3154 }
3155 check(
3157 &|t| {
3158 let turbo = t.turbo.as_mut().unwrap();
3159 turbo.max_rollovers = 0;
3160 },
3161 "max_rollovers",
3162 );
3163 check(
3164 &|t| t.turbo.as_mut().unwrap().max_loss_bps = [150, 200, 250, 10_001],
3165 "<= 10000",
3166 );
3167 check(
3168 &|t| t.turbo.as_mut().unwrap().rollover_profit_bps = 10_001,
3169 "rollover_profit_bps",
3170 );
3171 }
3172
3173 #[test]
3176 fn a_legacy_tier_is_not_held_to_the_prepaid_invariants() {
3177 let tier: MarginTierConfig = serde_json::from_str(LIVE_LEGACY_TIER).unwrap();
3178 let params = tier_params(&tier, 9).unwrap();
3179 validate_turbo_tier_config(&tier, ¶ms, None).unwrap();
3180 }
3181}
3182
3183#[cfg(test)]
3184mod precision_budget_tests {
3185 use super::*;
3186
3187 fn pair(base_max: u8, quote_max: u8) -> OrderBookConfig {
3190 let asset = |symbol: &str, max_precision: u8| AssetConfig {
3191 symbol: symbol.to_string(),
3192 asset: Default::default(),
3193 decimals: 9,
3194 min_precision: 0,
3195 max_precision,
3196 };
3197 OrderBookConfig {
3198 contract_id: None,
3199 blob_id: None,
3200 house_identities: vec![],
3201 sidecar: None,
3202 market_id: Default::default(),
3203 taker_fee: 100,
3204 maker_fee: 0,
3205 min_order: 1_000_000_000,
3206 dust: 1_000,
3207 price_window: 0,
3208 allow_fractional_price: false,
3209 base: asset("TKN", base_max),
3210 quote: asset("USDC", quote_max),
3211 }
3212 }
3213
3214 #[test]
3215 fn a_pair_that_spends_its_whole_budget_yields_the_contract_exponents() {
3216 let (price, quantity) = order_book_precision_exponents(&pair(2, 7)).unwrap();
3218 assert_eq!(price, 2, "PRICE_PRECISION = 10^(9-7)");
3219 assert_eq!(quantity, 7, "QUANTITY_PRECISION = 10^(9-2)");
3220 }
3221
3222 #[test]
3223 fn one_digit_over_budget_is_refused() {
3224 let err = order_book_precision_exponents(&pair(4, 6))
3226 .unwrap_err()
3227 .to_string();
3228 assert!(err.contains("FractionalPrice"), "{err}");
3229 assert!(err.contains("TKN/USDC"), "{err}");
3230 assert!(err.contains("= 10"), "{err}");
3231 }
3232
3233 #[test]
3234 fn fractional_prices_downgrade_the_breach_to_a_warning() {
3235 let mut config = pair(4, 6);
3238 config.allow_fractional_price = true;
3239 assert!(order_book_precision_exponents(&config).is_ok());
3240 }
3241
3242 #[test]
3243 fn max_precision_beyond_the_asset_decimals_is_still_refused() {
3244 let err = order_book_precision_exponents(&pair(0, 10))
3245 .unwrap_err()
3246 .to_string();
3247 assert!(err.contains("exceeds decimals"), "{err}");
3248 }
3249
3250 #[test]
3251 fn the_budget_tracks_the_quote_decimals_not_a_hardcoded_nine() {
3252 let mut config = pair(4, 2);
3253 config.quote.decimals = 6;
3254 assert!(
3255 order_book_precision_exponents(&config).is_ok(),
3256 "4 + 2 fits a 6-decimal quote"
3257 );
3258
3259 config.quote.max_precision = 3;
3260 assert!(
3261 order_book_precision_exponents(&config).is_err(),
3262 "4 + 3 does not"
3263 );
3264 }
3265}
3266
3267#[cfg(test)]
3268mod fee_amount_tests {
3269 use super::collateral_amount;
3270
3271 fn at9(value: &str) -> anyhow::Result<u64> {
3272 collateral_amount(value, 9, "tier 1 open_fee")
3273 }
3274
3275 #[test]
3276 fn whole_and_fractional_units_scale_by_the_asset_decimals() {
3277 assert_eq!(at9("7.5").unwrap(), 7_500_000_000);
3278 assert_eq!(at9("7").unwrap(), 7_000_000_000);
3279 assert_eq!(at9("0").unwrap(), 0);
3280 assert_eq!(at9("0.000000001").unwrap(), 1);
3281 assert_eq!(at9("13.5").unwrap(), 13_500_000_000);
3282 assert_eq!(at9("7.1").unwrap(), 7_100_000_000);
3284 }
3285
3286 #[test]
3287 fn a_figure_the_asset_cannot_represent_is_refused_not_rounded() {
3288 let err = at9("7.0000000001").unwrap_err().to_string();
3289 assert!(err.contains("10 decimal places"), "{err}");
3290 assert!(err.contains("tier 1 open_fee"), "{err}");
3291 }
3292
3293 #[test]
3294 fn junk_is_refused() {
3295 for bad in ["", " ", "-1", "1.2.3", "abc", "1e9", ".5", "1_000"] {
3296 assert!(at9(bad).is_err(), "`{bad}` should not parse");
3297 }
3298 }
3299
3300 #[test]
3301 fn an_amount_past_u64_is_refused_rather_than_wrapping() {
3302 assert!(at9("18446744074").is_err());
3303 assert_eq!(at9("18446744073.709551615").unwrap(), u64::MAX);
3304 }
3305
3306 #[test]
3307 fn other_decimal_scales_are_honoured() {
3308 assert_eq!(collateral_amount("7.5", 6, "f").unwrap(), 7_500_000);
3309 assert_eq!(collateral_amount("7.5", 1, "f").unwrap(), 75);
3310 assert!(collateral_amount("7.5", 0, "f").is_err());
3311 assert_eq!(collateral_amount("7", 0, "f").unwrap(), 7);
3312 }
3313}