o2-deploy 0.4.7-rc1

Contract deployment logic for Fuel O2 exchange
Documentation
use crate::{
    MarketsConfigPartial,
    OwnershipTransferOptions,
    parse_margin_identity,
};
use anyhow::{
    Context,
    Result,
    ensure,
};
use fuels::{
    accounts::{
        Account,
        ViewOnlyAccount,
    },
    prelude::Execution,
    types::{
        ContractId,
        Identity,
        Salt,
    },
};
use o2_api_types::domain::book::OrderBookConfig;
use o2_tools::{
    house_order_book::{
        HOUSE_ROLE,
        HouseOrderBookDeploy,
        HouseOrderBookDeployConfig,
    },
    order_book::OrderBookManager,
    order_book_deploy::{
        HouseOrderBookConfigurables,
        OrderBook,
        State,
    },
};
use std::collections::BTreeSet;

pub(crate) fn validate_config(pairs: &[OrderBookConfig]) -> Result<()> {
    for pair in pairs {
        for identity in &pair.house_identities {
            parse_margin_identity(identity).with_context(|| {
                format!(
                    "{}/{}: invalid House identity",
                    pair.base.symbol, pair.quote.symbol
                )
            })?;
        }
        if let Some(sidecar) = &pair.sidecar {
            ensure!(
                sidecar.maker_fee <= 1_000_000 && sidecar.taker_fee <= 1_000_000,
                "{}/{}: sidecar fees must be at most 1000000 millionths",
                pair.base.symbol,
                pair.quote.symbol
            );
        }
    }
    Ok(())
}

pub(crate) async fn grant_house_roles<W>(
    canonical: &OrderBookManager<W>,
    identities: &[String],
) -> Result<()>
where
    W: Account + ViewOnlyAccount + Clone + 'static,
{
    for identity in identities {
        let account = parse_margin_identity(identity)?;
        if !canonical
            .contract
            .methods()
            .has_role(HOUSE_ROLE, account)
            .simulate(Execution::state_read_only())
            .await?
            .value
        {
            tracing::info!(canonical = %canonical.contract.contract_id(), ?account,
                "Granting HOUSE_ROLE");
            canonical
                .contract
                .methods()
                .owner_grant_role(HOUSE_ROLE, account)
                .call()
                .await?;
        }
    }
    Ok(())
}

pub(crate) async fn deploy_sidecar<W>(
    wallet: &W,
    canonical: &OrderBookManager<W>,
    pair: &mut OrderBookConfig,
    upgrade: bool,
    ownership: &OwnershipTransferOptions,
) -> Result<()>
where
    W: Account + ViewOnlyAccount + Clone + 'static,
{
    let Some(settings) = pair.sidecar.as_mut() else {
        return Ok(())
    };
    let canonical_id = canonical.contract.contract_id();
    let attached = canonical.contract.methods().get_sidecar_state()
        .simulate(Execution::state_read_only()).await
        .context("Reading sidecar configuration; upgrade canonical to a sidecar-capable implementation first")?.value;
    if let (Some(recorded), Some(active)) = (settings.contract_id, attached) {
        ensure!(
            recorded == active,
            "Canonical {canonical_id} is attached to {active}, but the config records {recorded}"
        );
    }

    let canonical_owner = canonical
        .contract
        .methods()
        .owner()
        .simulate(Execution::state_read_only())
        .await?
        .value;
    let owner = ownership
        .new_contract_owner
        .map(Identity::Address)
        .or_else(|| initialized_owner(canonical_owner))
        .context("Canonical contract owner is not initialized")?;
    let proxy_owner = canonical
        .proxy
        .methods()
        .proxy_owner()
        .simulate(Execution::state_read_only())
        .await?
        .value;
    let proxy_owner = ownership
        .new_proxy_owner
        .map(Identity::Address)
        .or_else(|| initialized_owner(proxy_owner))
        .context("Canonical proxy owner is not initialized")?;
    let mut config = HouseOrderBookDeployConfig {
        owner: Some(owner),
        proxy_owner: Some(proxy_owner),
        salt: Salt::from(*canonical_id),
        configurables: HouseOrderBookConfigurables::default()
            .with_MAKER_FEE(settings.maker_fee.into())?
            .with_TAKER_FEE(settings.taker_fee.into())?,
        ..Default::default()
    };

    let house = match settings.contract_id.or(attached) {
        Some(id) => HouseOrderBookDeploy::load(wallet, &canonical.contract, id).await?,
        None => {
            HouseOrderBookDeploy::deploy(wallet, &canonical.contract, &config).await?
        }
    };
    let id = house.book.contract_id;
    let blob = if upgrade {
        // INITIAL_OWNER only seeds first attachment. An initialized House keeps
        // its own governance, even if canonical's ownership is being transferred.
        if let Some(current_owner) = initialized_owner(
            house
                .house
                .methods()
                .owner()
                .simulate(Execution::state_read_only())
                .await?
                .value,
        ) {
            config.owner = Some(current_owner);
        }
        house.upgrade(&config).await?
    } else {
        let actual_maker = house
            .house
            .methods()
            .get_maker_fee()
            .simulate(Execution::state_read_only())
            .await?
            .value;
        let actual_taker = house
            .house
            .methods()
            .get_taker_fee()
            .simulate(Execution::state_read_only())
            .await?
            .value;
        ensure!(
            actual_maker == settings.maker_fee && actual_taker == settings.taker_fee,
            "House book {id} has different fees; use --upgrade-bytecode to apply the configured fees"
        );
        house
            .book
            .order_book_proxy
            .methods()
            .proxy_target()
            .simulate(Execution::state_read_only())
            .await?
            .value
            .context("House proxy target is not initialized")?
    };
    settings.contract_id = Some(id);
    settings.blob_id = Some(blob);
    tracing::info!(canonical = %canonical_id, sidecar = %id, blob = %blob,
        "Sidecar deployment ready; attachment is a separate operation");
    Ok(())
}

fn initialized_owner(state: State) -> Option<Identity> {
    match state {
        State::Initialized(owner) => Some(owner),
        State::Uninitialized | State::Revoked => None,
    }
}

fn selected_pairs<'a>(
    pairs: &'a [OrderBookConfig],
    markets: &[String],
) -> Result<Vec<&'a OrderBookConfig>> {
    ensure!(
        !markets.is_empty(),
        "Select at least one market for sidecar activation"
    );
    let mut selected = BTreeSet::new();
    for market in markets {
        let wanted = market.trim();
        ensure!(
            !wanted.is_empty(),
            "Sidecar activation market cannot be empty"
        );
        let id = wanted.strip_prefix("0x").unwrap_or(wanted);
        let matches = pairs
            .iter()
            .enumerate()
            .filter(|(_, pair)| {
                let symbol = format!("{}/{}", pair.base.symbol, pair.quote.symbol);
                symbol.eq_ignore_ascii_case(wanted)
                    || hex::encode(*pair.market_id).eq_ignore_ascii_case(id)
            })
            .map(|(index, _)| index)
            .collect::<Vec<_>>();
        ensure!(
            matches.len() == 1,
            "Sidecar activation market `{market}` must identify exactly one configured pair"
        );
        selected.insert(matches[0]);
    }
    Ok(selected.into_iter().map(|index| &pairs[index]).collect())
}

/// Attach only recorded sidecars, independently of deployment and config write-back.
/// All selected calls are simulated before any activation is submitted.
pub async fn activate_sidecars<W>(
    wallet: &W,
    config: &MarketsConfigPartial,
    markets: &[String],
) -> Result<Vec<ContractId>>
where
    W: Account + ViewOnlyAccount + Clone + 'static,
{
    let selected = selected_pairs(&config.pairs, markets)?;
    let mut calls = Vec::with_capacity(selected.len());
    for pair in selected {
        let market = format!("{}/{}", pair.base.symbol, pair.quote.symbol);
        let canonical_id = pair
            .contract_id
            .with_context(|| format!("{market}: no recorded canonical deployment"))?;
        let sidecar_id = pair
            .sidecar
            .as_ref()
            .and_then(|s| s.contract_id)
            .with_context(|| {
                format!("{market}: deploy and record the sidecar before activation")
            })?;
        let canonical = OrderBook::new(canonical_id, wallet.clone());
        let house = HouseOrderBookDeploy::load(wallet, &canonical, sidecar_id).await?;
        ensure!(
            house.book.base_asset == pair.base.asset
                && house.book.quote_asset == pair.quote.asset,
            "{market}: recorded canonical book has different assets"
        );
        let active = canonical
            .methods()
            .get_sidecar_state()
            .simulate(Execution::state_read_only())
            .await?
            .value;
        ensure!(
            active.is_none() || active == Some(sidecar_id),
            "{market}: canonical is already attached to another sidecar"
        );
        if active != Some(sidecar_id) {
            // This checks ownership, the permanent binding, and initialization.
            canonical
                .methods()
                .set_sidecar(Some(sidecar_id))
                .with_contracts(&[&house.house])
                .simulate(Execution::realistic())
                .await
                .with_context(|| format!("{market}: activation preflight failed"))?;
        }
        calls.push((market, canonical, house, active == Some(sidecar_id)));
    }

    let mut activated = Vec::with_capacity(calls.len());
    for (market, canonical, house, already_active) in calls {
        let id = house.book.contract_id;
        if !already_active {
            canonical
                .methods()
                .set_sidecar(Some(id))
                .with_contracts(&[&house.house])
                .call()
                .await
                .with_context(|| format!("{market}: sidecar activation failed"))?;
        }
        tracing::info!(%market, canonical = %canonical.contract_id(), sidecar = %id,
            already_active, "Sidecar attached");
        activated.push(id);
    }
    Ok(activated)
}