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