use anyhow::Context;
use fuel_core_client::client::types::primitives::{
ContractId,
Salt,
};
use fuel_core_types::fuel_types::BlockHeight;
use fuels::{
accounts::{
Account,
ViewOnlyAccount,
},
prelude::Execution,
types::{
Identity,
SizedAsciiString,
},
};
use o2_api_types::{
domain::book::{
AssetConfig,
MarketIdAssets,
OrderBookConfig,
},
parse::HexDisplayFromStr,
};
use o2_tools::{
order_book::OrderBookManager,
order_book_deploy::{
OrderBookBlacklist,
OrderBookConfigurables,
OrderBookDeploy,
OrderBookDeployConfig,
OrderBookWhitelist,
},
order_book_registry::{
OrderBookRegistryDeployConfig,
OrderBookRegistryManager,
},
trade_account_deploy::{
DeployConfig,
TradeAccountDeploy,
TradeAccountDeployConfig,
TradingAccountOracle,
},
trade_account_registry::{
TradeAccountRegistryConfigurables,
TradeAccountRegistryDeployConfig,
TradeAccountRegistryManager,
},
trial_trade_account_deploy::{
DeployConfig as TrialDeployConfig,
TrialTradeAccountDeploy,
TrialTradeAccountDeployConfig,
TrialTradingAccountOracle,
},
};
use serde_with::serde_as;
use std::ops::{
Deref,
DerefMut,
};
pub use o2_tools::prop_deploy::{
ExistingRegistry,
PropDeployConfig,
PropDeployment,
};
const ORDERBOOK_MAINTAINER_ROLE: u64 = 1;
const DISCOUNT_MANAGER_ROLE: u64 = 1;
pub async fn deploy_prop_system<W>(
wallet: &W,
config: &PropDeployConfig,
) -> anyhow::Result<PropDeployment<W>>
where
W: Account + Clone,
{
PropDeployment::deploy(wallet, config).await
}
fn to_registry_market_id(m: &MarketIdAssets) -> o2_tools::order_book_registry::MarketId {
o2_tools::order_book_registry::MarketId {
base_asset: m.base_asset,
quote_asset: m.quote_asset,
}
}
#[serde_as]
#[derive(Debug, serde::Serialize, Clone, Default)]
pub struct MarketsConfigOutput {
pub starting_height: u32,
#[serde_as(as = "HexDisplayFromStr")]
pub trade_account_registry_id: ContractId,
#[serde_as(as = "HexDisplayFromStr")]
pub trade_account_registry_blob_id: ContractId,
#[serde_as(as = "HexDisplayFromStr")]
pub trade_account_oracle_id: ContractId,
#[serde_as(as = "HexDisplayFromStr")]
pub trial_trade_account_oracle_id: ContractId,
#[serde_as(as = "HexDisplayFromStr")]
pub trade_account_root: ContractId,
#[serde_as(as = "HexDisplayFromStr")]
pub trade_account_proxy: ContractId,
#[serde_as(as = "HexDisplayFromStr")]
pub trade_account_blob_id: ContractId,
#[serde_as(as = "Option<HexDisplayFromStr>")]
pub order_book_whitelist_id: Option<ContractId>,
#[serde_as(as = "Option<HexDisplayFromStr>")]
pub order_book_blacklist_id: Option<ContractId>,
#[serde_as(as = "HexDisplayFromStr")]
pub order_book_registry_id: ContractId,
#[serde_as(as = "HexDisplayFromStr")]
pub order_book_registry_blob_id: ContractId,
#[serde_as(as = "Option<HexDisplayFromStr>")]
pub fast_bridge_asset_registry_proxy_id: Option<ContractId>,
#[serde_as(as = "Option<HexDisplayFromStr>")]
pub price_feed_id: Option<ContractId>,
#[serde_as(as = "Option<HexDisplayFromStr>")]
pub margin_pool_id: Option<ContractId>,
#[serde_as(as = "Option<HexDisplayFromStr>")]
pub margin_oracle_id: Option<ContractId>,
#[serde(skip_serializing_if = "Option::is_none")]
pub margin: Option<MarginConfig>,
pub pairs: Vec<OrderBookConfig>,
}
#[serde_as]
#[derive(Debug, Clone, serde::Deserialize)]
struct OrderBookConfigDeHelper {
#[serde_as(as = "Option<serde_with::DisplayFromStr>")]
blob_id: Option<ContractId>,
#[serde_as(as = "Option<serde_with::DisplayFromStr>")]
contract_id: Option<ContractId>,
#[serde_as(as = "serde_with::DisplayFromStr")]
taker_fee: u64,
#[serde_as(as = "serde_with::DisplayFromStr")]
maker_fee: u64,
#[serde_as(as = "serde_with::DisplayFromStr")]
min_order: u64,
#[serde_as(as = "serde_with::DisplayFromStr")]
dust: u64,
price_window: u8,
#[serde(default)]
allow_fractional_price: bool,
base: AssetConfig,
quote: AssetConfig,
}
impl From<OrderBookConfigDeHelper> for OrderBookConfig {
fn from(h: OrderBookConfigDeHelper) -> Self {
let ids = MarketIdAssets {
base_asset: h.base.asset,
quote_asset: h.quote.asset,
};
let market_id = ids.market_id();
OrderBookConfig {
contract_id: h.contract_id,
blob_id: h.blob_id,
market_id,
taker_fee: h.taker_fee,
maker_fee: h.maker_fee,
min_order: h.min_order,
dust: h.dust,
price_window: h.price_window,
allow_fractional_price: h.allow_fractional_price,
base: h.base,
quote: h.quote,
}
}
}
#[derive(Debug, Clone, Default, serde::Serialize)]
pub struct MarketsConfigPartial {
pub starting_height: u32,
pub trade_account_registry_id: Option<ContractId>,
pub order_book_registry_id: Option<ContractId>,
pub trade_account_oracle_id: Option<ContractId>,
pub trial_trade_account_oracle_id: Option<ContractId>,
pub order_book_whitelist_id: Option<ContractId>,
pub order_book_blacklist_id: Option<ContractId>,
pub fast_bridge_asset_registry_proxy_id: Option<ContractId>,
pub pairs: Vec<OrderBookConfig>,
pub margin: Option<MarginConfig>,
}
impl<'de> serde::Deserialize<'de> for MarketsConfigPartial {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(serde::Deserialize, Default)]
struct Helper {
#[serde(default)]
starting_height: u32,
trade_account_registry_id: Option<ContractId>,
order_book_registry_id: Option<ContractId>,
trade_account_oracle_id: Option<ContractId>,
trial_trade_account_oracle_id: Option<ContractId>,
order_book_whitelist_id: Option<ContractId>,
order_book_blacklist_id: Option<ContractId>,
fast_bridge_asset_registry_proxy_id: Option<ContractId>,
#[serde(default)]
pairs: Vec<OrderBookConfigDeHelper>,
#[serde(default)]
margin: Option<MarginConfig>,
}
let h = Helper::deserialize(deserializer)?;
if let Some(margin) = &h.margin
&& margin.price_feed_id.is_some()
{
return Err(serde::de::Error::custom(
"`margin.price_feed_id` moved into `margin.price_feed.id`. \
Refusing to load a config that uses the retired spelling: it \
would be ignored, a fresh feed would be derived, and the pool \
would be repointed at an oracle with no publisher. Move the id \
(and `publishers` / `supported_assets`) under \
`margin.price_feed`.",
));
}
Ok(MarketsConfigPartial {
starting_height: h.starting_height,
trade_account_registry_id: h.trade_account_registry_id,
order_book_registry_id: h.order_book_registry_id,
trade_account_oracle_id: h.trade_account_oracle_id,
trial_trade_account_oracle_id: h.trial_trade_account_oracle_id,
order_book_whitelist_id: h.order_book_whitelist_id,
order_book_blacklist_id: h.order_book_blacklist_id,
fast_bridge_asset_registry_proxy_id: h.fast_bridge_asset_registry_proxy_id,
pairs: h.pairs.into_iter().map(Into::into).collect(),
margin: h.margin,
})
}
}
#[serde_as]
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct MarginTierConfig {
pub tier_id: u64,
#[serde_as(as = "serde_with::DisplayFromStr")]
pub line: u64,
pub leverage: u64,
pub duration: u64,
pub maintenance_bps: u64,
pub open_buffer_bps: u64,
pub liq_price_factor: u64,
pub prolong_fee: [String; 4],
pub max_credit_line_bps: u64,
pub max_price_age: u64,
pub open_fee: String,
pub profit_share_bps: u64,
pub price_band_bps: u64,
pub markets: Vec<String>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct MarginSupportedAsset {
pub asset: fuels::types::AssetId,
pub decimals: u8,
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct MarginPriceFeedConfig {
pub id: Option<ContractId>,
pub max_offchain_age_seconds: Option<u64>,
#[serde(default)]
pub publishers: Vec<String>,
#[serde(default)]
pub remove_publishers: Vec<String>,
#[serde(default)]
pub supported_assets: Vec<MarginSupportedAsset>,
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct MarginConfig {
#[serde(default)]
pub price_feed: MarginPriceFeedConfig,
#[serde(default, skip_serializing)]
pub price_feed_id: Option<ContractId>,
pub margin_pool_id: Option<ContractId>,
pub platform_payout: Option<String>,
#[serde(default)]
pub discount_managers: Vec<String>,
#[serde(default)]
pub remove_discount_managers: Vec<String>,
pub collateral_asset: Option<fuels::types::AssetId>,
pub collateral_decimals: Option<u8>,
pub max_tier_books: Option<u64>,
pub base_repay_fee_ppm: Option<u64>,
#[serde(default)]
pub tiers: Vec<MarginTierConfig>,
}
#[derive(Debug, Clone, Default)]
pub struct OwnershipTransferOptions {
pub new_proxy_owner: Option<fuels::types::Address>,
pub new_contract_owner: Option<fuels::types::Address>,
pub revoke_orderbook_maintainers: Vec<fuels::types::Address>,
pub new_orderbook_maintainers: Vec<fuels::types::Address>,
}
#[derive(Debug, Clone)]
pub struct DeployParams {
pub deploy_config: MarketsConfigPartial,
pub output: Option<String>,
pub deploy_whitelist: bool,
pub deploy_blacklist: bool,
pub upgrade_bytecode: bool,
pub new_proxy_owner: Option<fuels::types::Address>,
pub new_contract_owner: Option<fuels::types::Address>,
pub trial_cosigner: Option<fuels::types::Address>,
pub trial_creator: Option<Identity>,
pub margin_tier_only: bool,
pub margin_cosigner: Option<fuels::types::Address>,
pub margin_liquidator: Option<Identity>,
pub revoke_orderbook_maintainers: Vec<fuels::types::Address>,
pub new_orderbook_maintainers: Vec<fuels::types::Address>,
}
pub fn load_config_from_file<T>(config_path: &str) -> anyhow::Result<T>
where
T: Default + serde::de::DeserializeOwned,
{
if config_path.is_empty() {
return Ok(T::default());
}
let current_dir = std::env::current_dir()?;
let path = current_dir.join(config_path);
tracing::info!("Loading config from {}", path.display());
let file = std::fs::File::open(&path)?;
let config: T = serde_json::from_reader(file)?;
Ok(config)
}
pub async fn deploy<W>(
wallet: W,
params: DeployParams,
) -> anyhow::Result<MarketsConfigOutput>
where
W: Account + ViewOnlyAccount + Clone + 'static,
{
tracing::info!("Starting Fuel o2 Registries and Markets");
let mut markets_config_partial = params.deploy_config.clone();
let starting_height: BlockHeight = markets_config_partial.starting_height.into();
let trade_account_oracle_id = markets_config_partial.trade_account_oracle_id;
let trial_trade_account_oracle_id =
markets_config_partial.trial_trade_account_oracle_id;
let order_book_registry_id = markets_config_partial.order_book_registry_id;
let trade_account_registry_id = markets_config_partial.trade_account_registry_id;
let fast_bridge_asset_registry_proxy_id =
markets_config_partial.fast_bridge_asset_registry_proxy_id;
let mut salt = Salt::zeroed();
salt.deref_mut()[..4].copy_from_slice(&starting_height.deref().to_be_bytes());
let (trade_account_oracle_deploy, trade_account_blob_id) =
deploy_trade_account_oracle(
wallet.clone(),
params.upgrade_bytecode,
trade_account_oracle_id,
salt,
)
.await?;
let trial_trade_account_oracle_id = deploy_trial_trade_account_oracle(
wallet.clone(),
params.upgrade_bytecode,
trial_trade_account_oracle_id,
params.trial_cosigner,
salt,
)
.await?;
let (trade_account_registry, trade_account_registry_blob_id) =
deploy_trade_account_registry(
wallet.clone(),
params.upgrade_bytecode,
trade_account_oracle_deploy.clone(),
trial_trade_account_oracle_id,
trade_account_registry_id,
salt,
)
.await?;
let order_book_blacklist_id = deploy_order_book_blacklist(
wallet.clone(),
params.deploy_blacklist,
markets_config_partial.order_book_blacklist_id,
salt,
)
.await?;
let order_book_whitelist_id = deploy_order_book_whitelist(
wallet.clone(),
params.deploy_whitelist,
markets_config_partial.order_book_whitelist_id,
salt,
)
.await?;
let (order_book_registry, order_book_registry_blob_id) = deploy_order_book_registry(
wallet.clone(),
params.upgrade_bytecode,
order_book_registry_id,
salt,
)
.await?;
let pairs = deploy_order_books(
wallet.clone(),
params.upgrade_bytecode,
order_book_blacklist_id,
order_book_whitelist_id,
order_book_registry.clone(),
&mut markets_config_partial.pairs,
OwnershipTransferOptions {
new_proxy_owner: params.new_proxy_owner,
new_contract_owner: params.new_contract_owner,
revoke_orderbook_maintainers: params.revoke_orderbook_maintainers.clone(),
new_orderbook_maintainers: params.new_orderbook_maintainers.clone(),
},
)
.await?;
let order_book_registry_id = order_book_registry.contract_id;
let trade_account_registry_id = trade_account_registry.contract_id;
let trade_account_oracle_id = trade_account_oracle_deploy.oracle_id;
let trade_account_proxy = trade_account_registry
.registry
.methods()
.default_bytecode()
.simulate(Execution::state_read_only())
.await?
.value
.context(
"Trade account registry default bytecode should exist after initialization",
)?;
let trade_account_root = trade_account_registry
.registry
.methods()
.factory_bytecode_root()
.simulate(Execution::state_read_only())
.await?
.value
.context("Trade account registry factory bytecode root should exist after initialization")?;
let margin_ids = match &markets_config_partial.margin {
Some(margin) => Some(
deploy_margin(
&wallet,
margin,
salt,
params.margin_tier_only,
trade_account_registry_id,
trade_account_oracle_id,
trial_trade_account_oracle_id,
&pairs,
params.margin_cosigner,
params.margin_liquidator,
params.upgrade_bytecode,
)
.await?,
),
None => None,
};
if let Some(trial_creator) = params.trial_creator {
let current_creator = trade_account_registry
.get_trial_trade_account_creator()
.await?;
if current_creator != trial_creator {
tracing::info!("Setting trial trade account creator to {trial_creator:?}");
trade_account_registry
.set_trial_trade_account_creator(trial_creator)
.await?;
}
}
transfer_ownership(
&wallet,
¶ms,
&order_book_registry,
&trade_account_registry,
&trade_account_oracle_deploy,
trial_trade_account_oracle_id,
order_book_blacklist_id,
order_book_whitelist_id,
)
.await?;
let deploy_result = MarketsConfigOutput {
starting_height: starting_height.into(),
trade_account_registry_id,
trade_account_registry_blob_id,
trade_account_proxy,
trade_account_blob_id,
trade_account_root: ContractId::from(trade_account_root.0),
trade_account_oracle_id,
trial_trade_account_oracle_id,
order_book_whitelist_id,
order_book_blacklist_id,
order_book_registry_id,
order_book_registry_blob_id,
pairs,
fast_bridge_asset_registry_proxy_id,
price_feed_id: margin_ids.map(|ids| ids.price_feed_id),
margin_pool_id: margin_ids.map(|ids| ids.margin_pool_id),
margin_oracle_id: margin_ids.and_then(|ids| ids.margin_oracle_id),
margin: markets_config_partial.margin.clone().map(|mut margin| {
if let Some(ids) = margin_ids {
margin.price_feed.id = Some(ids.price_feed_id);
margin.margin_pool_id = Some(ids.margin_pool_id);
}
margin
}),
};
if let Some(output_path) = params.output {
let json = serde_json::to_string_pretty(&deploy_result)?;
tracing::info!("Deploy result saved to {}", output_path);
std::fs::write(output_path, json)?;
}
Ok(deploy_result)
}
#[derive(Debug, Clone, Copy)]
pub struct MarginIds {
pub price_feed_id: ContractId,
pub margin_pool_id: ContractId,
pub margin_oracle_id: Option<ContractId>,
}
#[allow(clippy::too_many_arguments)]
async fn deploy_margin<W>(
wallet: &W,
margin: &MarginConfig,
salt: fuels::types::Salt,
tier_only: bool,
trade_account_registry_id: ContractId,
trade_account_oracle_id: ContractId,
trial_trade_account_oracle_id: ContractId,
pairs: &[OrderBookConfig],
margin_cosigner: Option<fuels::types::Address>,
margin_liquidator: Option<Identity>,
upgrade_bytecode: bool,
) -> anyhow::Result<MarginIds>
where
W: Account + ViewOnlyAccount + Clone + 'static,
{
use o2_tools::prop::{
PriceFeedContract,
PropAccountOracleContract,
PropMarginPoolContract,
};
let collateral_asset = match margin.collateral_asset {
Some(collateral_asset) => collateral_asset,
None => {
pairs
.first()
.context(
"margin.collateral_asset is not set and there are no pairs to \
default it from",
)?
.quote
.asset
}
};
let cosigner = margin_cosigner;
let platform_payout = margin
.platform_payout
.as_deref()
.map(parse_margin_identity)
.transpose()?;
let liquidator = margin_liquidator;
let mut prop_config = PropDeployConfig::new(collateral_asset);
prop_config.collateral_decimals = margin.collateral_decimals.context(
"margin.collateral_decimals is required - set it to the collateral \
asset's decimals in the deploy config",
)?;
prop_config.max_tier_books = margin.max_tier_books.unwrap_or(80);
prop_config.base_repay_fee_ppm = margin.base_repay_fee_ppm.unwrap_or(100);
prop_config.cosigner = cosigner;
prop_config.platform_payout = platform_payout;
prop_config.liquidator = liquidator;
prop_config.salt = salt;
prop_config.existing_price_feed = margin.price_feed.id;
prop_config.max_offchain_age_seconds = margin.price_feed.max_offchain_age_seconds;
prop_config.existing_pool = margin.margin_pool_id;
prop_config.existing_registry = Some(ExistingRegistry {
registry_id: trade_account_registry_id,
trade_account_oracle_id,
trial_trade_account_oracle_id,
});
anyhow::ensure!(
!(tier_only && margin.margin_pool_id.is_none()),
"--margin-tier-only was requested but the markets config names no \
`margin.margin_pool_id`: there is no pool to reconcile tiers against"
);
let (price_feed_id, margin_pool_id, margin_oracle_id) = if let (
true,
Some(margin_pool_id),
) =
(tier_only, margin.margin_pool_id)
{
tracing::info!("Margin: tier-only mode against pool {margin_pool_id}");
let price_feed_id = match margin.price_feed.id {
Some(price_feed_id) => price_feed_id,
None => {
PropMarginPoolContract::new(margin_pool_id, wallet.clone())
.methods()
.price_feed()
.simulate(Execution::state_read_only())
.await
.context("read the pool's price feed")?
.value
}
};
let margin_oracle_id =
TradeAccountRegistryManager::new(wallet.clone(), trade_account_registry_id)
.registry
.methods()
.get_prop_oracle_id()
.simulate(Execution::state_read_only())
.await
.map(|result| result.value)
.ok()
.filter(|oracle_id| *oracle_id != ContractId::zeroed());
(price_feed_id, margin_pool_id, margin_oracle_id)
} else {
let deployment = deploy_prop_system(wallet, &prop_config).await?;
tracing::info!(
"Margin: oracle {}, pool {}, feed {} (shared registry {} upgraded in place)",
deployment.oracle_id,
deployment.pool_id,
deployment.price_feed_id,
deployment.registry_id,
);
(
deployment.price_feed_id,
deployment.pool_id,
Some(deployment.oracle_id),
)
};
let pool_deployed = wallet
.try_provider()?
.contract_exists(&margin_pool_id)
.await?;
if pool_deployed {
let pool = PropMarginPoolContract::new(margin_pool_id, wallet.clone());
if let Some(platform_payout) = platform_payout {
let current = pool
.methods()
.platform_payout()
.simulate(Execution::state_read_only())
.await
.context("read the pool's platform payout")?
.value;
if current != platform_payout {
tracing::info!(
"Margin: platform payout {current:?} -> {platform_payout:?}"
);
pool.methods()
.set_platform_payout(platform_payout)
.call()
.await?;
}
}
if let Some(liquidator) = liquidator {
let current = pool
.methods()
.liquidator()
.simulate(Execution::state_read_only())
.await
.context("read the pool's liquidator")?
.value;
if current != liquidator {
tracing::info!("Margin: liquidator {current:?} -> {liquidator:?}");
pool.methods().set_liquidator(liquidator).call().await?;
}
}
for entry in &margin.discount_managers {
anyhow::ensure!(
!margin.remove_discount_managers.contains(entry),
"discount manager {entry} is listed in both \
`discount_managers` and `remove_discount_managers`"
);
}
for (entry, should_hold) in margin
.discount_managers
.iter()
.map(|entry| (entry, true))
.chain(
margin
.remove_discount_managers
.iter()
.map(|entry| (entry, false)),
)
{
let manager = parse_margin_identity(entry)?;
let holds = pool
.methods()
.has_role(DISCOUNT_MANAGER_ROLE, manager)
.simulate(Execution::state_read_only())
.await
.context("read a discount manager's role")?
.value;
if holds == should_hold {
continue;
}
if should_hold {
tracing::info!("Margin: adding discount manager {manager:?}");
pool.methods()
.grant_role(DISCOUNT_MANAGER_ROLE, manager)
.call()
.await
.context("grant a discount manager its role")?;
} else {
tracing::info!("Margin: removing discount manager {manager:?}");
pool.methods()
.revoke_role(DISCOUNT_MANAGER_ROLE, manager)
.call()
.await
.context("revoke a discount manager's role")?;
}
}
}
let margin_oracle_id = match margin_oracle_id {
Some(oracle_id) => Some(oracle_id),
None if cosigner.is_some() => {
let registry = o2_tools::trade_account_registry::TradeAccountRegistry::new(
trade_account_registry_id,
wallet.clone(),
);
let oracle_id = registry
.methods()
.get_prop_oracle_id()
.simulate(Execution::state_read_only())
.await
.context("read the registry's prop account oracle")?
.value;
(oracle_id != ContractId::zeroed()).then_some(oracle_id)
}
None => None,
};
if let (Some(cosigner), Some(oracle_id)) = (cosigner, margin_oracle_id)
&& wallet.try_provider()?.contract_exists(&oracle_id).await?
{
let oracle = PropAccountOracleContract::new(oracle_id, wallet.clone());
let current = oracle
.methods()
.get_cosigner()
.simulate(Execution::state_read_only())
.await
.context("read the prop account oracle's cosigner")?
.value;
if current != Some(cosigner) {
tracing::info!("Margin: cosigner {current:?} -> {cosigner:?}");
oracle.methods().set_cosigner(cosigner).call().await?;
}
}
for entry in &margin.price_feed.publishers {
anyhow::ensure!(
!margin.price_feed.remove_publishers.contains(entry),
"price feed publisher {entry} is listed in both `publishers` and \
`remove_publishers`"
);
}
let price_feed = PriceFeedContract::new(price_feed_id, wallet.clone());
for (entry, should_hold) in margin
.price_feed
.publishers
.iter()
.map(|entry| (entry, true))
.chain(
margin
.price_feed
.remove_publishers
.iter()
.map(|entry| (entry, false)),
)
{
let publisher = parse_margin_identity(entry)?;
let holds = price_feed
.methods()
.has_role(o2_tools::prop_deploy::PRICE_SUBMITTER_ROLE, publisher)
.simulate(Execution::state_read_only())
.await
.context("read a price feed publisher's submitter role")?
.value;
if holds == should_hold {
continue;
}
if should_hold {
tracing::info!("Margin: adding price feed publisher {publisher:?}");
price_feed
.methods()
.add_publisher(publisher)
.call()
.await
.context("grant a price feed publisher the submitter role")?;
} else {
tracing::info!("Margin: removing price feed publisher {publisher:?}");
price_feed
.methods()
.remove_publisher(publisher)
.call()
.await
.context("revoke a price feed publisher's submitter role")?;
}
}
if upgrade_bytecode && !tier_only {
o2_tools::prop_deploy::upgrade_price_feed_implementation(
wallet,
price_feed_id,
prop_config
.owner
.unwrap_or(Identity::Address(wallet.address())),
&prop_config,
)
.await
.context("upgrade the price feed implementation")?;
}
if let Some(seconds) = margin.price_feed.max_offchain_age_seconds {
let current = price_feed
.methods()
.get_max_offchain_age()
.simulate(Execution::state_read_only())
.await
.context("read the price feed's submission-age bound")?
.value;
if current != seconds {
tracing::info!("Margin: submission-age bound {current} -> {seconds}");
price_feed
.methods()
.set_max_offchain_age(seconds)
.call()
.await
.context("set the price feed's submission-age bound")?;
}
}
for supported in &margin.price_feed.supported_assets {
let current = price_feed
.methods()
.get_asset_decimals(supported.asset)
.simulate(Execution::state_read_only())
.await
.context("read the price feed's asset decimals")?
.value;
match current {
Some(decimals) if decimals == supported.decimals => continue,
Some(decimals) => anyhow::bail!(
"price feed already knows {} at {decimals} decimals, config says \
{} - refusing to move it, since the pool pins decimals on first \
use and rejects a change",
supported.asset,
supported.decimals,
),
None => {
tracing::info!(
"Margin: registering {} at {} decimals on the feed",
supported.asset,
supported.decimals,
);
price_feed
.methods()
.set_asset_decimals(supported.asset, supported.decimals)
.call()
.await
.context("register a supported asset on the price feed")?;
}
}
}
let pool = PropMarginPoolContract::new(margin_pool_id, wallet.clone());
let pool_reachable = wallet
.try_provider()?
.contract_exists(&margin_pool_id)
.await?;
if pool_reachable {
let live_feed = pool
.methods()
.price_feed()
.simulate(Execution::state_read_only())
.await
.context("read the pool's price feed")?
.value;
if live_feed != price_feed_id {
tracing::info!("Margin: pool price feed {live_feed} -> {price_feed_id}");
pool.methods()
.set_price_feed(price_feed_id)
.with_contract_ids(&[price_feed_id])
.call()
.await
.context(
"point the pool at the configured price feed - the \
replacement must already know every admitted asset at the \
decimals the pool pinned",
)?;
}
}
anyhow::ensure!(
pool_reachable || margin.tiers.is_empty(),
"margin pool {margin_pool_id} is not on chain, so its {} configured \
tier(s) cannot be reconciled",
margin.tiers.len()
);
if pool_reachable {
reconcile_margin_tiers(
&pool,
price_feed_id,
pairs,
&margin.tiers,
prop_config.collateral_decimals,
)
.await?;
}
Ok(MarginIds {
price_feed_id,
margin_pool_id,
margin_oracle_id,
})
}
async fn reconcile_margin_tiers<W>(
pool: &o2_tools::prop::PropMarginPoolContract<W>,
price_feed_id: ContractId,
pairs: &[OrderBookConfig],
tiers: &[MarginTierConfig],
collateral_decimals: u8,
) -> anyhow::Result<()>
where
W: Account + ViewOnlyAccount + Clone + 'static,
{
for tier in tiers {
let books = tier
.markets
.iter()
.map(|market| resolve_tier_market(market, pairs))
.collect::<anyhow::Result<Vec<_>>>()?;
let params = tier_params(tier, collateral_decimals)?;
let current_version = pool
.methods()
.current_tier_version(tier.tier_id)
.simulate(Execution::state_read_only())
.await?
.value;
let mut contract_ids = vec![price_feed_id];
contract_ids.extend(books.iter().copied());
let Some(version) = current_version else {
let version = pool
.methods()
.publish_tier_version(tier.tier_id, params, books)
.with_contract_ids(&contract_ids)
.call()
.await?
.value;
tracing::info!("Margin: tier {} published version {version}", tier.tier_id);
continue;
};
let live = pool
.methods()
.get_tier(tier.tier_id, version)
.simulate(Execution::state_read_only())
.await?
.value
.with_context(|| {
format!("tier {} version {version} vanished mid-read", tier.tier_id)
})?;
if live != params {
let new_version = pool
.methods()
.publish_tier_version(tier.tier_id, params, books)
.with_contract_ids(&contract_ids)
.call()
.await?
.value;
tracing::info!(
"Margin: tier {} republished as version {new_version}",
tier.tier_id
);
continue;
}
let live_books = pool
.methods()
.tier_books(tier.tier_id, version)
.simulate(Execution::state_read_only())
.await?
.value;
let missing: Vec<ContractId> = books
.iter()
.copied()
.filter(|book| !live_books.contains(book))
.collect();
let shrunk = live_books
.iter()
.filter(|book| !books.contains(book))
.count();
if shrunk > 0 {
tracing::warn!(
"Margin: tier {} declares {shrunk} fewer book(s) than live version \
{version}; books are append-only — publish a new version to drop \
markets",
tier.tier_id
);
}
if missing.is_empty() {
tracing::info!(
"Margin: tier {} version {version} matches the declared catalogue — \
unchanged",
tier.tier_id
);
continue;
}
let mut add_contract_ids = vec![price_feed_id];
add_contract_ids.extend(missing.iter().copied());
pool.methods()
.add_books(tier.tier_id, missing.clone())
.with_contract_ids(&add_contract_ids)
.call()
.await?;
tracing::info!(
"Margin: tier {} version {version} gained {} book(s)",
tier.tier_id,
missing.len()
);
}
Ok(())
}
fn collateral_amount(value: &str, decimals: u8, field: &str) -> anyhow::Result<u64> {
let raw = value.trim();
anyhow::ensure!(!raw.is_empty(), "{field}: empty amount");
anyhow::ensure!(
!raw.starts_with('-'),
"{field}: `{raw}` is negative; fees are unsigned"
);
let (whole, fraction) = match raw.split_once('.') {
Some((whole, fraction)) => (whole, fraction),
None => (raw, ""),
};
anyhow::ensure!(
!fraction.contains('.'),
"{field}: `{raw}` has more than one decimal point"
);
anyhow::ensure!(
!whole.is_empty() && whole.bytes().all(|b| b.is_ascii_digit()),
"{field}: `{raw}` is not a decimal amount"
);
anyhow::ensure!(
fraction.bytes().all(|b| b.is_ascii_digit()),
"{field}: `{raw}` is not a decimal amount"
);
let decimals = usize::from(decimals);
anyhow::ensure!(
fraction.len() <= decimals,
"{field}: `{raw}` has {} decimal places, but the collateral asset has \
only {decimals}; the chain cannot represent it",
fraction.len()
);
let digits = format!("{whole}{fraction}{}", "0".repeat(decimals - fraction.len()));
digits
.parse::<u64>()
.map_err(|e| anyhow::anyhow!("{field}: `{raw}` does not fit a u64: {e}"))
}
fn tier_params(
tier: &MarginTierConfig,
collateral_decimals: u8,
) -> anyhow::Result<o2_tools::prop::TierParams> {
let fee = |value: &str, what: &str| {
collateral_amount(
value,
collateral_decimals,
&format!("tier {} {what}", tier.tier_id),
)
};
Ok(o2_tools::prop::TierParams {
line: tier.line,
leverage: tier.leverage,
duration: tier.duration,
maintenance_bps: tier.maintenance_bps,
open_buffer_bps: tier.open_buffer_bps,
liq_price_factor: tier.liq_price_factor,
prolong_fee: [
fee(&tier.prolong_fee[0], "prolong_fee[0]")?,
fee(&tier.prolong_fee[1], "prolong_fee[1]")?,
fee(&tier.prolong_fee[2], "prolong_fee[2]")?,
fee(&tier.prolong_fee[3], "prolong_fee[3]")?,
],
max_credit_line_bps: tier.max_credit_line_bps,
max_price_age: tier.max_price_age,
open_fee: fee(&tier.open_fee, "open_fee")?,
profit_share_bps: tier.profit_share_bps,
price_band_bps: tier.price_band_bps,
})
}
fn resolve_tier_market(
market: &str,
pairs: &[OrderBookConfig],
) -> anyhow::Result<ContractId> {
let wanted = market.trim();
let wanted_id = wanted
.strip_prefix("0x")
.unwrap_or(wanted)
.to_ascii_lowercase();
let pair = pairs
.iter()
.find(|pair| {
let symbol = format!("{}/{}", pair.base.symbol, pair.quote.symbol);
symbol.eq_ignore_ascii_case(wanted)
|| hex::encode(*pair.market_id) == wanted_id
})
.with_context(|| format!("margin tier references unknown market `{market}`"))?;
pair.contract_id.with_context(|| {
format!("margin tier market `{market}` has no deployed order book")
})
}
fn parse_margin_identity(s: &str) -> anyhow::Result<Identity> {
if let Some(hex_part) = s.strip_prefix("address:") {
Ok(Identity::Address(fuels::types::Address::new(
parse_margin_bytes32(hex_part)?,
)))
} else if let Some(hex_part) = s.strip_prefix("contract:") {
Ok(Identity::ContractId(ContractId::new(parse_margin_bytes32(
hex_part,
)?)))
} else {
anyhow::bail!("expected `address:0x..` or `contract:0x..`, got `{s}`")
}
}
fn parse_margin_bytes32(s: &str) -> anyhow::Result<[u8; 32]> {
let raw = s.trim().strip_prefix("0x").unwrap_or(s.trim());
let bytes = hex::decode(raw)
.map_err(|e| anyhow::anyhow!("expected 32 hex bytes, got `{s}`: {e}"))?;
bytes
.try_into()
.map_err(|_| anyhow::anyhow!("expected 32 hex bytes, got `{s}`"))
}
#[allow(clippy::too_many_arguments)]
async fn transfer_ownership<W>(
wallet: &W,
params: &DeployParams,
order_book_registry: &OrderBookRegistryManager<W>,
trade_account_registry: &TradeAccountRegistryManager<W>,
trade_account_oracle_deploy: &TradeAccountDeploy<W>,
trial_trade_account_oracle_id: ContractId,
order_book_blacklist_id: Option<ContractId>,
order_book_whitelist_id: Option<ContractId>,
) -> anyhow::Result<()>
where
W: Account + ViewOnlyAccount + Clone + 'static,
{
if let Some(new_proxy_owner) = params.new_proxy_owner {
let new_identity = Identity::Address(new_proxy_owner);
tracing::info!(
"Transferring OrderBookRegistry proxy ownership to {}",
new_proxy_owner
);
order_book_registry
.registry_proxy
.methods()
.set_owner(new_identity)
.call()
.await?;
tracing::info!(
"Transferring TradeAccountRegistry proxy ownership to {}",
new_proxy_owner
);
trade_account_registry
.registry_proxy
.methods()
.set_owner(new_identity)
.call()
.await?;
}
if let Some(new_contract_owner) = params.new_contract_owner {
let new_identity = Identity::Address(new_contract_owner);
tracing::info!(
"Transferring TradeAccountOracle ownership to {}",
new_contract_owner
);
trade_account_oracle_deploy
.oracle
.methods()
.transfer_ownership(new_identity)
.call()
.await?;
tracing::info!(
"Transferring TrialTradeAccountOracle ownership to {}",
new_contract_owner
);
TrialTradingAccountOracle::new(trial_trade_account_oracle_id, wallet.clone())
.methods()
.transfer_ownership(new_identity)
.call()
.await?;
tracing::info!(
"Transferring TradeAccountRegistry ownership to {}",
new_contract_owner
);
trade_account_registry
.registry
.methods()
.transfer_ownership(new_identity)
.call()
.await?;
tracing::info!(
"Transferring OrderBookRegistry ownership to {}",
new_contract_owner
);
order_book_registry
.registry
.methods()
.transfer_ownership(new_identity)
.call()
.await?;
if let Some(blacklist_id) = order_book_blacklist_id {
tracing::info!(
"Transferring OrderBookBlacklist ownership to {}",
new_contract_owner
);
OrderBookBlacklist::new(blacklist_id, wallet.clone())
.methods()
.transfer_ownership(new_identity)
.call()
.await?;
}
if let Some(whitelist_id) = order_book_whitelist_id {
tracing::info!(
"Transferring OrderBookWhitelist ownership to {}",
new_contract_owner
);
OrderBookWhitelist::new(whitelist_id, wallet.clone())
.methods()
.transfer_ownership(new_identity)
.call()
.await?;
}
}
Ok(())
}
async fn deploy_order_book_blacklist<W>(
deployer_wallet: W,
deploy_blacklist: bool,
order_book_blacklist_id: Option<ContractId>,
salt: Salt,
) -> anyhow::Result<Option<ContractId>>
where
W: Account + ViewOnlyAccount + Clone + 'static,
{
match order_book_blacklist_id {
Some(order_book_blacklist_id) => {
tracing::info!(
"Using existing OrderBookBlacklist: {}",
order_book_blacklist_id
);
Ok(Some(order_book_blacklist_id))
}
None => {
if !deploy_blacklist {
return Ok(None);
}
tracing::info!("Deploying OrderBookBlacklist");
let order_book_blacklist = OrderBookDeploy::deploy_order_book_blacklist(
&deployer_wallet,
&Identity::Address(ViewOnlyAccount::address(&deployer_wallet)),
&OrderBookDeployConfig {
salt,
..Default::default()
},
)
.await?;
tracing::info!("OrderBookBlacklist: {}", order_book_blacklist.contract_id());
Ok(Some(order_book_blacklist.contract_id()))
}
}
}
async fn deploy_order_book_whitelist<W>(
deployer_wallet: W,
deploy_whitelist: bool,
order_book_whitelist_id: Option<ContractId>,
salt: Salt,
) -> anyhow::Result<Option<ContractId>>
where
W: Account + ViewOnlyAccount + Clone + 'static,
{
match (order_book_whitelist_id, deploy_whitelist) {
(Some(order_book_whitelist_id), false)
| (Some(order_book_whitelist_id), true) => {
tracing::info!(
"Using existing OrderBookWhitelist: {}",
order_book_whitelist_id
);
Ok(Some(order_book_whitelist_id))
}
(None, false) => Ok(None),
(None, true) => {
tracing::info!("Deploying OrderBookWhitelist");
let trade_account_whitelist = OrderBookDeploy::deploy_order_book_whitelist(
&deployer_wallet,
&Identity::Address(ViewOnlyAccount::address(&deployer_wallet)),
&OrderBookDeployConfig {
salt,
..Default::default()
},
)
.await?;
tracing::info!(
"OrderBookWhitelist: {}",
trade_account_whitelist.contract_id()
);
Ok(Some(trade_account_whitelist.contract_id()))
}
}
}
async fn load_or_recover_trade_account_oracle<W>(
deployer_wallet: &W,
oracle_id: ContractId,
) -> anyhow::Result<(TradeAccountDeploy<W>, ContractId)>
where
W: Account + ViewOnlyAccount + Clone + 'static,
{
let oracle = TradingAccountOracle::new(oracle_id, deployer_wallet.clone());
let impl_id = oracle
.methods()
.get_trade_account_impl()
.simulate(Execution::state_read_only())
.await?
.value;
let blob_id = match impl_id {
Some(id) => id,
None => {
tracing::info!(
"Trade account implementation not set on oracle {}, deploying...",
oracle_id
);
let blob = TradeAccountDeploy::trade_account_blob(
deployer_wallet,
&Default::default(),
)
.await?;
TradeAccountDeploy::deploy_trade_account_blob(
deployer_wallet,
&DeployConfig::Latest(Default::default()),
)
.await?;
oracle
.methods()
.set_trade_account_impl(ContractId::from(blob.id))
.call()
.await?;
ContractId::from(blob.id)
}
};
let deploy = TradeAccountDeploy {
oracle,
oracle_id,
trade_account_blob_id: blob_id.into(),
deployer_wallet: deployer_wallet.clone(),
proxy: None,
proxy_id: None,
};
Ok((deploy, blob_id))
}
async fn deploy_trade_account_oracle<W>(
deployer_wallet: W,
should_upgrade_bytecode: bool,
trade_account_oracle_id: Option<ContractId>,
salt: Salt,
) -> anyhow::Result<(TradeAccountDeploy<W>, ContractId)>
where
W: Account + ViewOnlyAccount + Clone + 'static,
{
let (trade_account_oracle_deploy, mut trade_account_blob_id) =
match trade_account_oracle_id {
Some(oracle_id) => {
load_or_recover_trade_account_oracle(&deployer_wallet, oracle_id).await?
}
None => {
let deploy = TradeAccountDeploy::deploy(
&deployer_wallet,
&DeployConfig::Latest(TradeAccountDeployConfig {
salt,
..Default::default()
}),
)
.await?;
let blob_id = deploy
.oracle
.methods()
.get_trade_account_impl()
.simulate(Execution::state_read_only())
.await?
.value
.context("Trade account impl should exist after fresh deploy")?;
(deploy, blob_id)
}
};
tracing::info!(
"TradeAccountOracle: {}",
trade_account_oracle_deploy.oracle_id
);
if should_upgrade_bytecode {
let trade_account_blob =
TradeAccountDeploy::trade_account_blob(&deployer_wallet, &Default::default())
.await?;
if ContractId::from(trade_account_blob.id) != trade_account_blob_id {
tracing::info!(
"Update TradeAccountImpl on Oracle from {:?} to new blob {:?}",
trade_account_blob_id,
ContractId::from(trade_account_blob.id)
);
TradeAccountDeploy::deploy_trade_account_blob(
&deployer_wallet,
&DeployConfig::Latest(Default::default()),
)
.await?;
trade_account_oracle_deploy
.oracle
.methods()
.set_trade_account_impl(ContractId::from(trade_account_blob.id))
.call()
.await?;
trade_account_blob_id = ContractId::from(trade_account_blob.id);
}
}
Ok((trade_account_oracle_deploy, trade_account_blob_id))
}
async fn deploy_trial_trade_account_oracle<W>(
deployer_wallet: W,
should_upgrade_bytecode: bool,
trial_trade_account_oracle_id: Option<ContractId>,
trial_cosigner: Option<fuels::types::Address>,
salt: Salt,
) -> anyhow::Result<ContractId>
where
W: Account + ViewOnlyAccount + Clone + 'static,
{
let mut trial_deploy_config = TrialTradeAccountDeployConfig {
salt,
..Default::default()
};
if let Some(cosigner) = trial_cosigner {
trial_deploy_config = trial_deploy_config.with_cosigner(cosigner);
}
let deploy_config = TrialDeployConfig::Latest(trial_deploy_config);
let oracle_id = match trial_trade_account_oracle_id {
None => {
let trial_deploy =
TrialTradeAccountDeploy::deploy(&deployer_wallet, &deploy_config).await?;
tracing::info!(
"TrialTradeAccountOracle: {} (implementation {:?}, cosigner {:?})",
trial_deploy.oracle_id,
trial_deploy.trial_trade_account_blob_id,
trial_cosigner,
);
trial_deploy.oracle_id
}
Some(oracle_id) => {
let current_trial_impl =
TrialTradingAccountOracle::new(oracle_id, deployer_wallet.clone())
.methods()
.get_trial_account_impl()
.simulate(Execution::state_read_only())
.await?
.value;
if current_trial_impl.is_none()
|| should_upgrade_bytecode
|| trial_cosigner.is_some()
{
let trial_deploy = TrialTradeAccountDeploy::deploy_to_oracle(
&deployer_wallet,
oracle_id,
&deploy_config,
)
.await?;
tracing::info!(
"Trial implementation {:?} deployed to oracle {} (cosigner {:?})",
trial_deploy.trial_trade_account_blob_id,
oracle_id,
trial_cosigner,
);
}
oracle_id
}
};
Ok(oracle_id)
}
async fn deploy_trade_account_registry<W>(
deployer_wallet: W,
should_upgrade_bytecode: bool,
trade_account_deploy: TradeAccountDeploy<W>,
trial_trade_account_oracle_id: ContractId,
trade_account_registry_id: Option<ContractId>,
salt: Salt,
) -> anyhow::Result<(TradeAccountRegistryManager<W>, ContractId)>
where
W: Account + ViewOnlyAccount + Clone + 'static,
{
let trade_account_oracle_id = trade_account_deploy.oracle_id;
let trade_account_registry = match trade_account_registry_id {
Some(trade_account_registry_contract_id) => TradeAccountRegistryManager::new(
deployer_wallet.clone(),
trade_account_registry_contract_id,
),
None => {
let trade_account_registry_deploy_config = TradeAccountRegistryDeployConfig {
salt,
..Default::default()
};
TradeAccountRegistryManager::deploy(
&deployer_wallet,
trade_account_oracle_id,
trial_trade_account_oracle_id,
&trade_account_registry_deploy_config,
)
.await?
}
};
tracing::info!(
"TradeAccountRegistry: {}",
trade_account_registry.contract_id
);
let mut trade_account_registry_blob_id = match trade_account_registry
.registry_proxy
.methods()
.proxy_target()
.simulate(Execution::state_read_only())
.await?
.value
{
Some(blob_id) => blob_id,
None => {
tracing::info!("TradeAccountRegistry proxy target not set, initializing...");
trade_account_registry
.registry_proxy
.methods()
.initialize_proxy()
.call()
.await?;
trade_account_registry
.registry
.methods()
.initialize()
.call()
.await?;
trade_account_registry
.registry_proxy
.methods()
.proxy_target()
.simulate(Execution::state_read_only())
.await?
.value
.context("TradeAccountRegistry proxy target should be set after initialization")?
}
};
if should_upgrade_bytecode {
let trade_account_registry_deploy_config = TradeAccountRegistryDeployConfig {
registry_config: trade_account_registry
.live_prop_config(TradeAccountRegistryConfigurables::default())
.await?,
..Default::default()
};
let trade_account_proxy_blob = TradeAccountRegistryManager::register_proxy_blob(
&deployer_wallet,
&trade_account_registry_deploy_config,
)
.await?;
let trial_trade_account_proxy_blob =
TradeAccountRegistryManager::register_trial_proxy_blob(
&deployer_wallet,
&trade_account_registry_deploy_config,
)
.await?;
let trade_account_register_blob = TradeAccountRegistryManager::register_blob(
&deployer_wallet,
trade_account_oracle_id,
trial_trade_account_oracle_id,
trade_account_proxy_blob.id,
trial_trade_account_proxy_blob.id,
&trade_account_registry_deploy_config,
)
.await?;
if trade_account_registry_blob_id
!= ContractId::from(trade_account_register_blob.id)
{
tracing::info!(
"Upgrade TradeAccountRegistry blob from {:?} to {:?}",
trade_account_registry.contract_id,
ContractId::from(trade_account_register_blob.id)
);
trade_account_registry
.upgrade(
trade_account_oracle_id,
trial_trade_account_oracle_id,
&trade_account_registry_deploy_config,
)
.await?;
trade_account_registry_blob_id = trade_account_register_blob.id.into();
}
}
Ok((trade_account_registry, trade_account_registry_blob_id))
}
async fn deploy_order_book_registry<W>(
deployer_wallet: W,
should_upgrade_bytecode: bool,
order_book_registry_id: Option<ContractId>,
salt: Salt,
) -> anyhow::Result<(OrderBookRegistryManager<W>, ContractId)>
where
W: Account + ViewOnlyAccount + Clone + 'static,
{
let order_book_registry = match order_book_registry_id {
Some(registry_contract_id) => {
OrderBookRegistryManager::new(deployer_wallet.clone(), registry_contract_id)
}
None => {
OrderBookRegistryManager::deploy(
&deployer_wallet,
&OrderBookRegistryDeployConfig {
salt,
..Default::default()
},
)
.await?
}
};
tracing::info!("OrderBookRegistry: {}", order_book_registry.contract_id);
let mut order_book_registry_blob_id = match order_book_registry
.registry_proxy
.methods()
.proxy_target()
.simulate(Execution::state_read_only())
.await?
.value
{
Some(blob_id) => blob_id,
None => {
tracing::info!("OrderBookRegistry proxy target not set, initializing...");
order_book_registry
.registry_proxy
.methods()
.initialize_proxy()
.call()
.await?;
order_book_registry
.registry
.methods()
.initialize()
.call()
.await?;
order_book_registry
.registry_proxy
.methods()
.proxy_target()
.simulate(Execution::state_read_only())
.await?
.value
.context(
"OrderBookRegistry proxy target should be set after initialization",
)?
}
};
if should_upgrade_bytecode {
let order_book_register_deploy_config = OrderBookRegistryDeployConfig::default();
let order_book_register_blob = OrderBookRegistryManager::register_blob(
&deployer_wallet,
&order_book_register_deploy_config,
)
.await?;
if order_book_registry_blob_id != order_book_register_blob.id.into() {
tracing::info!(
"Upgrade OrderBookRegistry blob from {:?} to {:?}",
order_book_registry.contract_id,
ContractId::from(order_book_register_blob.id)
);
order_book_registry
.upgrade(&order_book_register_deploy_config)
.await?;
order_book_registry_blob_id = order_book_register_blob.id.into();
}
}
Ok((order_book_registry, order_book_registry_blob_id))
}
async fn deploy_order_books<W>(
deployer_wallet: W,
should_upgrade_bytecode: bool,
order_book_blacklist_id: Option<ContractId>,
order_book_whitelist_id: Option<ContractId>,
order_book_registry: OrderBookRegistryManager<W>,
order_book_configs: &mut [OrderBookConfig],
ownership_options: OwnershipTransferOptions,
) -> anyhow::Result<Vec<OrderBookConfig>>
where
W: Account + ViewOnlyAccount + Clone + 'static,
{
audit_order_book_precision(order_book_configs)?;
let mut pairs: Vec<OrderBookConfig> = Vec::with_capacity(order_book_configs.len());
for order_book_config in order_book_configs.iter_mut() {
let pair = deploy_single_order_book(
&deployer_wallet,
should_upgrade_bytecode,
order_book_blacklist_id,
order_book_whitelist_id,
&order_book_registry,
order_book_config,
&ownership_options,
)
.await?;
pairs.push(pair);
}
Ok(pairs)
}
async fn deploy_single_order_book<W>(
deployer_wallet: &W,
should_upgrade_bytecode: bool,
order_book_blacklist_id: Option<ContractId>,
order_book_whitelist_id: Option<ContractId>,
order_book_registry: &OrderBookRegistryManager<W>,
order_book_config: &mut OrderBookConfig,
ownership_options: &OwnershipTransferOptions,
) -> anyhow::Result<OrderBookConfig>
where
W: Account + ViewOnlyAccount + Clone + 'static,
{
let market_symbol = format!(
"{}/{}",
order_book_config.base.symbol, order_book_config.quote.symbol
);
let market_id = MarketIdAssets {
base_asset: order_book_config.base.asset,
quote_asset: order_book_config.quote.asset,
};
let order_book_configurables = build_order_book_configurables(
order_book_config,
order_book_blacklist_id,
order_book_whitelist_id,
deployer_wallet,
)?;
let order_book = load_or_deploy_order_book(
deployer_wallet,
order_book_registry,
&market_id,
&market_symbol,
&order_book_configurables,
order_book_config,
)
.await?;
tracing::info!(
"[{}] OrderBook: {}",
market_symbol,
order_book.contract.contract_id()
);
let order_book_blob_id = maybe_upgrade_order_book(
deployer_wallet,
should_upgrade_bytecode,
&order_book,
order_book_config,
order_book_configurables,
&market_symbol,
)
.await?;
set_order_book_maintainer(&order_book, ownership_options, &market_symbol).await?;
transfer_order_book_ownership(&order_book, ownership_options, &market_symbol).await?;
order_book_config.contract_id = Some(order_book.contract.contract_id());
order_book_config.blob_id = order_book_blob_id.into();
Ok(order_book_config.clone())
}
pub fn audit_order_book_precision(configs: &[OrderBookConfig]) -> anyhow::Result<()> {
for config in configs {
order_book_precision_exponents(config)?;
}
Ok(())
}
fn order_book_precision_exponents(config: &OrderBookConfig) -> anyhow::Result<(u8, u8)> {
let price_precision = config
.quote
.decimals
.checked_sub(config.quote.max_precision)
.ok_or_else(|| {
anyhow::anyhow!(
"quote max_precision ({}) exceeds decimals ({})",
config.quote.max_precision,
config.quote.decimals
)
})?;
let quantity_precision = config
.base
.decimals
.checked_sub(config.base.max_precision)
.ok_or_else(|| {
anyhow::anyhow!(
"base max_precision ({}) exceeds decimals ({})",
config.base.max_precision,
config.base.decimals
)
})?;
let spent = config.base.max_precision as u16 + config.quote.max_precision as u16;
let budget = config.quote.decimals as u16;
if config.allow_fractional_price {
if spent > budget {
tracing::warn!(
"{}/{}: base max_precision ({}) + quote max_precision ({}) = {spent} \
exceeds the quote's {budget} decimals; ALLOW_FRACTIONAL_PRICE is set, \
so notionals will silently truncate rather than revert",
config.base.symbol,
config.quote.symbol,
config.base.max_precision,
config.quote.max_precision,
);
}
} else {
anyhow::ensure!(
spent <= budget,
"{}/{}: base max_precision ({}) + quote max_precision ({}) = {spent} \
exceeds the quote's {budget} decimals, so every order would revert \
with FractionalPrice. Lower one of the two until they sum to {budget} \
or less.",
config.base.symbol,
config.quote.symbol,
config.base.max_precision,
config.quote.max_precision,
);
}
Ok((price_precision, quantity_precision))
}
fn build_order_book_configurables<W: ViewOnlyAccount>(
config: &OrderBookConfig,
order_book_blacklist_id: Option<ContractId>,
order_book_whitelist_id: Option<ContractId>,
deployer_wallet: &W,
) -> anyhow::Result<OrderBookConfigurables> {
let (price_precision, quantity_precision) = order_book_precision_exponents(config)?;
Ok(OrderBookConfigurables::default()
.with_MIN_ORDER(config.min_order)?
.with_ALLOW_FRACTIONAL_PRICE(config.allow_fractional_price)?
.with_TAKER_FEE(config.taker_fee.into())?
.with_MAKER_FEE(config.maker_fee.into())?
.with_DUST(config.dust)?
.with_PRICE_WINDOW(config.price_window as u64)?
.with_BASE_DECIMALS(10u64.pow(config.base.decimals as u32))?
.with_QUOTE_DECIMALS(10u64.pow(config.quote.decimals as u32))?
.with_BASE_SYMBOL(SizedAsciiString::new_with_right_whitespace_padding(
config.base.symbol.clone(),
)?)?
.with_QUOTE_SYMBOL(SizedAsciiString::new_with_right_whitespace_padding(
config.quote.symbol.clone(),
)?)?
.with_PRICE_PRECISION(10u64.pow(price_precision as u32))?
.with_QUANTITY_PRECISION(10u64.pow(quantity_precision as u32))?
.with_INITIAL_OWNER(o2_tools::order_book_deploy::State::Initialized(
Identity::Address(ViewOnlyAccount::address(deployer_wallet)),
))?
.with_WHITE_LIST_CONTRACT(order_book_whitelist_id)?
.with_BLACK_LIST_CONTRACT(order_book_blacklist_id)?)
}
async fn load_or_deploy_order_book<W>(
deployer_wallet: &W,
order_book_registry: &OrderBookRegistryManager<W>,
market_id: &MarketIdAssets,
market_symbol: &str,
order_book_configurables: &OrderBookConfigurables,
order_book_config: &OrderBookConfig,
) -> anyhow::Result<OrderBookManager<W>>
where
W: Account + ViewOnlyAccount + Clone + 'static,
{
let register_contract_id = order_book_registry
.registry
.methods()
.get_order_book(to_registry_market_id(market_id))
.simulate(Execution::state_read_only())
.await?
.value;
match register_contract_id {
Some(contract_id) => {
let order_book_deploy = OrderBookDeploy::new(
deployer_wallet.clone(),
contract_id,
market_id.base_asset,
market_id.quote_asset,
);
let proxy_target = order_book_deploy
.order_book_proxy
.methods()
.proxy_target()
.simulate(Execution::state_read_only())
.await?
.value;
if proxy_target.is_none() {
tracing::info!(
"[{}] Proxy target not set, initializing...",
market_symbol
);
order_book_deploy.initialize().await?;
}
Ok(OrderBookManager::new(
deployer_wallet,
10u64.pow(order_book_config.base.decimals as u32),
10u64.pow(order_book_config.quote.decimals as u32),
&order_book_deploy,
))
}
None => {
let (order_book_deployment, initialization_required) =
OrderBookDeploy::deploy_without_initialization(
deployer_wallet,
market_id.base_asset,
market_id.quote_asset,
&OrderBookDeployConfig {
order_book_configurables: order_book_configurables.clone(),
salt: Salt::from(*order_book_registry.contract_id),
..Default::default()
},
)
.await?;
order_book_registry
.register_order_book(
to_registry_market_id(market_id),
order_book_deployment.contract_id,
)
.await?;
if initialization_required {
order_book_deployment.initialize().await?;
}
Ok(OrderBookManager::new(
deployer_wallet,
10u64.pow(order_book_config.base.decimals as u32),
10u64.pow(order_book_config.quote.decimals as u32),
&order_book_deployment,
))
}
}
}
async fn maybe_upgrade_order_book<W>(
deployer_wallet: &W,
should_upgrade_bytecode: bool,
order_book: &OrderBookManager<W>,
order_book_config: &OrderBookConfig,
order_book_configurables: OrderBookConfigurables,
market_symbol: &str,
) -> anyhow::Result<ContractId>
where
W: Account + ViewOnlyAccount + Clone + 'static,
{
let mut order_book_blob_id = order_book
.proxy
.methods()
.proxy_target()
.simulate(Execution::state_read_only())
.await?
.value
.context("Order book proxy target should be set after initialization")?;
if should_upgrade_bytecode {
let order_book_deploy_config = OrderBookDeployConfig {
order_book_configurables,
..Default::default()
};
let order_book_deploy = OrderBookDeploy::new(
deployer_wallet.clone(),
order_book.contract.contract_id(),
order_book_config.base.asset,
order_book_config.quote.asset,
);
let order_book_manager = OrderBookManager::new(
deployer_wallet,
10u64.pow(order_book_config.base.decimals as u32),
10u64.pow(order_book_config.quote.decimals as u32),
&order_book_deploy,
);
let order_book_blob = OrderBookDeploy::order_book_blob(
deployer_wallet,
order_book_config.base.asset,
order_book_config.quote.asset,
&order_book_deploy_config,
)
.await?;
if order_book_blob_id != order_book_blob.id.into() {
tracing::info!(
"[{}] Upgrade OrderBook blob from {:?} to {:?}",
market_symbol,
order_book_blob_id,
ContractId::from(order_book_blob.id)
);
order_book_manager
.upgrade(&order_book_deploy_config)
.await?;
tracing::info!(
"[{}] Emit new configuration event for {}",
market_symbol,
order_book.contract.contract_id()
);
order_book_manager.emit_config().await?;
order_book_blob_id = order_book_blob.id.into();
}
}
Ok(order_book_blob_id)
}
async fn set_order_book_maintainer<W>(
order_book: &OrderBookManager<W>,
ownership_options: &OwnershipTransferOptions,
market_symbol: &str,
) -> anyhow::Result<()>
where
W: Account + ViewOnlyAccount + Clone + 'static,
{
for account in &ownership_options.revoke_orderbook_maintainers {
tracing::info!(
"[{}] Revoking ORDERBOOK_MAINTAINER_ROLE from {}",
market_symbol,
account
);
order_book
.contract
.methods()
.owner_revoke_role(ORDERBOOK_MAINTAINER_ROLE, Identity::Address(*account))
.call()
.await?;
}
for account in &ownership_options.new_orderbook_maintainers {
tracing::info!(
"[{}] Granting ORDERBOOK_MAINTAINER_ROLE to {}",
market_symbol,
account
);
order_book
.contract
.methods()
.owner_grant_role(ORDERBOOK_MAINTAINER_ROLE, Identity::Address(*account))
.call()
.await?;
}
Ok(())
}
async fn transfer_order_book_ownership<W>(
order_book: &OrderBookManager<W>,
ownership_options: &OwnershipTransferOptions,
market_symbol: &str,
) -> anyhow::Result<()>
where
W: Account + ViewOnlyAccount + Clone + 'static,
{
if let Some(new_owner) = ownership_options.new_proxy_owner {
let new_identity = Identity::Address(new_owner);
tracing::info!(
"[{}] Transferring OrderBook proxy ownership to {}",
market_symbol,
new_owner
);
order_book
.proxy
.methods()
.set_owner(new_identity)
.call()
.await?;
}
if let Some(new_owner) = ownership_options.new_contract_owner {
let new_identity = Identity::Address(new_owner);
tracing::info!(
"[{}] Transferring OrderBook contract ownership to {}",
market_symbol,
new_owner
);
order_book
.contract
.methods()
.transfer_ownership(new_identity)
.call()
.await?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn load_config_empty_path_returns_default() {
let result: MarketsConfigPartial = load_config_from_file("").unwrap();
assert!(result.pairs.is_empty());
}
#[test]
fn load_config_missing_file_errors() {
let result: Result<MarketsConfigPartial, _> =
load_config_from_file("nonexistent_file_12345.json");
assert!(result.is_err());
}
#[test]
fn checked_sub_catches_overflow() {
let decimals: u32 = 6;
let max_precision: u32 = 8;
let result = decimals.checked_sub(max_precision);
assert!(
result.is_none(),
"should return None when max_precision > decimals"
);
let result = 9u32.checked_sub(6);
assert_eq!(result, Some(3));
}
#[test]
fn markets_config_partial_default_has_empty_pairs() {
let config = MarketsConfigPartial::default();
assert!(config.pairs.is_empty());
}
}
#[cfg(test)]
mod precision_budget_tests {
use super::*;
fn pair(base_max: u8, quote_max: u8) -> OrderBookConfig {
let asset = |symbol: &str, max_precision: u8| AssetConfig {
symbol: symbol.to_string(),
asset: Default::default(),
decimals: 9,
min_precision: 0,
max_precision,
};
OrderBookConfig {
contract_id: None,
blob_id: None,
market_id: Default::default(),
taker_fee: 100,
maker_fee: 0,
min_order: 1_000_000_000,
dust: 1_000,
price_window: 0,
allow_fractional_price: false,
base: asset("TKN", base_max),
quote: asset("USDC", quote_max),
}
}
#[test]
fn a_pair_that_spends_its_whole_budget_yields_the_contract_exponents() {
let (price, quantity) = order_book_precision_exponents(&pair(2, 7)).unwrap();
assert_eq!(price, 2, "PRICE_PRECISION = 10^(9-7)");
assert_eq!(quantity, 7, "QUANTITY_PRECISION = 10^(9-2)");
}
#[test]
fn one_digit_over_budget_is_refused() {
let err = order_book_precision_exponents(&pair(4, 6))
.unwrap_err()
.to_string();
assert!(err.contains("FractionalPrice"), "{err}");
assert!(err.contains("TKN/USDC"), "{err}");
assert!(err.contains("= 10"), "{err}");
}
#[test]
fn fractional_prices_downgrade_the_breach_to_a_warning() {
let mut config = pair(4, 6);
config.allow_fractional_price = true;
assert!(order_book_precision_exponents(&config).is_ok());
}
#[test]
fn max_precision_beyond_the_asset_decimals_is_still_refused() {
let err = order_book_precision_exponents(&pair(0, 10))
.unwrap_err()
.to_string();
assert!(err.contains("exceeds decimals"), "{err}");
}
#[test]
fn the_budget_tracks_the_quote_decimals_not_a_hardcoded_nine() {
let mut config = pair(4, 2);
config.quote.decimals = 6;
assert!(
order_book_precision_exponents(&config).is_ok(),
"4 + 2 fits a 6-decimal quote"
);
config.quote.max_precision = 3;
assert!(
order_book_precision_exponents(&config).is_err(),
"4 + 3 does not"
);
}
}
#[cfg(test)]
mod fee_amount_tests {
use super::collateral_amount;
fn at9(value: &str) -> anyhow::Result<u64> {
collateral_amount(value, 9, "tier 1 open_fee")
}
#[test]
fn whole_and_fractional_units_scale_by_the_asset_decimals() {
assert_eq!(at9("7.5").unwrap(), 7_500_000_000);
assert_eq!(at9("7").unwrap(), 7_000_000_000);
assert_eq!(at9("0").unwrap(), 0);
assert_eq!(at9("0.000000001").unwrap(), 1);
assert_eq!(at9("13.5").unwrap(), 13_500_000_000);
assert_eq!(at9("7.1").unwrap(), 7_100_000_000);
}
#[test]
fn a_figure_the_asset_cannot_represent_is_refused_not_rounded() {
let err = at9("7.0000000001").unwrap_err().to_string();
assert!(err.contains("10 decimal places"), "{err}");
assert!(err.contains("tier 1 open_fee"), "{err}");
}
#[test]
fn junk_is_refused() {
for bad in ["", " ", "-1", "1.2.3", "abc", "1e9", ".5", "1_000"] {
assert!(at9(bad).is_err(), "`{bad}` should not parse");
}
}
#[test]
fn an_amount_past_u64_is_refused_rather_than_wrapping() {
assert!(at9("18446744074").is_err());
assert_eq!(at9("18446744073.709551615").unwrap(), u64::MAX);
}
#[test]
fn other_decimal_scales_are_honoured() {
assert_eq!(collateral_amount("7.5", 6, "f").unwrap(), 7_500_000);
assert_eq!(collateral_amount("7.5", 1, "f").unwrap(), 75);
assert!(collateral_amount("7.5", 0, "f").is_err());
assert_eq!(collateral_amount("7", 0, "f").unwrap(), 7);
}
}