o2-deploy 0.3.21-rc

Contract deployment logic for Fuel O2 exchange
Documentation
use clap::Parser;
use fuels::{
    accounts::{
        provider::Provider,
        signers::private_key::PrivateKeySigner,
        wallet::Wallet,
    },
    crypto::SecretKey,
};
use o2_deploy::{
    DeployParams,
    MarketsConfigPartial,
    load_config_from_file,
};
use std::str::FromStr;

/// CLI for deploying O2 exchange contracts to Fuel.
#[derive(Parser)]
#[command(name = "o2-deploy", about = "Deploy O2 exchange contracts")]
struct Cli {
    /// Hex-encoded private key for signing deploy transactions.
    #[arg(long, env = "DEPLOY_KEY")]
    deploy_key: String,

    /// Fuel RPC URL for sending transactions.
    #[arg(
        long = "fuel-rpc",
        env = "FUEL_RPC",
        default_value = "http://127.0.0.1:4000"
    )]
    fuel_rpc: url::Url,

    /// Path to the deploy config JSON file.
    #[arg(long, env = "DEPLOY_CONFIG", default_value = "./deploy_config.json")]
    deploy_config: String,

    /// Output file path for the deploy result JSON.
    #[arg(long, env = "OUTPUT_FILE")]
    output: Option<String>,

    /// Deploy a whitelist contract.
    #[arg(long, env = "DEPLOY_WHITELIST", default_value = "false")]
    deploy_whitelist: bool,

    /// Deploy a blacklist contract.
    #[arg(long, env = "DEPLOY_BLACKLIST", default_value = "true")]
    deploy_blacklist: bool,

    /// If set, will attempt to upgrade bytecode of deployed contracts.
    #[arg(long, env, default_value = "false")]
    upgrade_bytecode: bool,

    /// Transfer proxy ownership to this address after deploy/upgrade.
    #[arg(long, env = "DEPLOY_NEW_PROXY_OWNER")]
    new_proxy_owner: Option<String>,

    /// Transfer non-proxy contract ownership to this address after deploy/upgrade.
    #[arg(long, env = "DEPLOY_NEW_CONTRACT_OWNER")]
    new_contract_owner: Option<String>,

    /// Cosigner address for trial trade accounts. When set, the trial trade
    /// account implementation is (re)deployed on the trial oracle and this
    /// cosigner is configured on it; when absent, the configured cosigner is
    /// left untouched.
    #[arg(long, env = "DEPLOY_TRIAL_COSIGNER")]
    trial_cosigner: Option<String>,

    /// Reconcile the margin TIERS only, leaving the margin system — pool,
    /// oracle, price feed and the registry's prop wiring — untouched.
    ///
    /// The mode used to be inferred from `margin.margin_pool_id` being
    /// present in the markets config, which conflated WHICH pool with
    /// WHETHER to touch the system: the ordinary steady state, a pool id
    /// on file, silently disabled the system phase — so a registry
    /// upgrade that dropped the prop wiring was never repaired.
    #[arg(long, env = "DEPLOY_MARGIN_TIER_ONLY")]
    margin_tier_only: bool,

    /// Cosigner address (`0x…`) for prop/margin accounts. When set, it is
    /// written to the prop account oracle if it differs from the live one;
    /// when absent, the configured cosigner is left untouched. Must be the
    /// address whose key the backend runs as `MARGIN_COSIGNER_KEY` - a
    /// mismatch leaves margin inert.
    #[arg(long, env = "DEPLOY_MARGIN_COSIGNER")]
    margin_cosigner: Option<String>,

    /// Recipient of the residue from forced margin exits, prefixed with its
    /// kind: `address:0x…` or `contract:0x…`. When set, it is written to the
    /// pool if it differs from the live one; when absent, the live value is
    /// left untouched.
    #[arg(long, env = "DEPLOY_MARGIN_LIQUIDATOR")]
    margin_liquidator: Option<String>,

    /// Identity allowed to register (activate) trial trade accounts on the
    /// registry, prefixed with its kind: `address:0x…` for a wallet that
    /// signs the registration transaction itself, or `contract:0x…` for a
    /// trade account contract the backend routes the call through. When set,
    /// it is written via set_trial_trade_account_creator if it differs; when
    /// absent, the creator is left untouched.
    #[arg(long, env = "DEPLOY_TRIAL_CREATOR")]
    trial_creator: Option<String>,

    /// Revoke the order-book maintainer role from these addresses before granting
    /// the new maintainer. Repeatable, or comma-separated via the env var.
    #[arg(
        long,
        env = "DEPLOY_REVOKE_ORDERBOOK_MAINTAINERS",
        value_delimiter = ','
    )]
    revoke_orderbook_maintainer: Vec<String>,

    /// Grant the order-book maintainer role to these addresses after deploy/upgrade.
    /// Repeatable, or comma-separated via the env var.
    #[arg(long, env = "DEPLOY_NEW_ORDERBOOK_MAINTAINERS", value_delimiter = ',')]
    new_orderbook_maintainer: Vec<String>,
}

fn parse_address(s: &str) -> anyhow::Result<fuels::types::Address> {
    let trimmed = s.strip_prefix("0x").unwrap_or(s);
    let bytes = hex::decode(trimmed)?;
    Ok(fuels::types::Address::new(
        bytes
            .try_into()
            .map_err(|_| anyhow::anyhow!("Invalid address length"))?,
    ))
}

/// The registry only compares `msg_sender()` against the stored creator, so
/// an `Address` creator can never register through a trade account contract
/// and vice versa — the caller has to say which shape they mean.
fn parse_identity(s: &str) -> anyhow::Result<fuels::types::Identity> {
    if let Some(address) = s.strip_prefix("address:") {
        Ok(fuels::types::Identity::Address(parse_address(address)?))
    } else if let Some(contract_id) = s.strip_prefix("contract:") {
        Ok(fuels::types::Identity::ContractId(
            fuels::types::ContractId::new(*parse_address(contract_id)?),
        ))
    } else {
        anyhow::bail!(
            "identity must specify its kind: `address:0x…` for a wallet that \
             signs directly, or `contract:0x…` for a trade account contract \
             the call is routed through"
        )
    }
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    tracing_subscriber::fmt()
        .with_env_filter(
            tracing_subscriber::EnvFilter::try_from_default_env()
                .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
        )
        .init();

    let cli = Cli::parse();

    let secret_key = SecretKey::from_str(&cli.deploy_key)
        .map_err(|e| anyhow::anyhow!("Failed to parse deploy key: {e}"))?;
    let provider = Provider::connect(cli.fuel_rpc.as_str()).await?;
    let wallet = Wallet::new(PrivateKeySigner::new(secret_key), provider);

    let deploy_config: MarketsConfigPartial = load_config_from_file(&cli.deploy_config)?;

    let new_proxy_owner = cli
        .new_proxy_owner
        .as_deref()
        .map(parse_address)
        .transpose()?;
    let new_contract_owner = cli
        .new_contract_owner
        .as_deref()
        .map(parse_address)
        .transpose()?;
    let trial_cosigner = cli
        .trial_cosigner
        .as_deref()
        .map(parse_address)
        .transpose()?;
    let trial_creator = cli
        .trial_creator
        .as_deref()
        .map(parse_identity)
        .transpose()?;
    let margin_cosigner = cli
        .margin_cosigner
        .as_deref()
        .map(parse_address)
        .transpose()?;
    let margin_liquidator = cli
        .margin_liquidator
        .as_deref()
        .map(parse_identity)
        .transpose()?;
    let new_orderbook_maintainers = cli
        .new_orderbook_maintainer
        .iter()
        .map(|s| parse_address(s))
        .collect::<anyhow::Result<Vec<_>>>()?;
    let revoke_orderbook_maintainers = cli
        .revoke_orderbook_maintainer
        .iter()
        .map(|s| parse_address(s))
        .collect::<anyhow::Result<Vec<_>>>()?;

    let params = DeployParams {
        deploy_config,
        output: cli.output,
        deploy_whitelist: cli.deploy_whitelist,
        deploy_blacklist: cli.deploy_blacklist,
        upgrade_bytecode: cli.upgrade_bytecode,
        new_proxy_owner,
        new_contract_owner,
        trial_cosigner,
        trial_creator,
        margin_tier_only: cli.margin_tier_only,
        margin_cosigner,
        margin_liquidator,
        revoke_orderbook_maintainers,
        new_orderbook_maintainers,
    };

    let result = o2_deploy::deploy(wallet, params).await?;
    println!("{}", serde_json::to_string_pretty(&result)?);

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::parse_identity;
    use fuels::types::Identity;

    const HEX: &str = "6468f728c3c42d7805a998d7be4be93a382b3d66049e736ceb78ab4645b8a0f4";

    #[test]
    fn parses_address_identity() {
        let identity = parse_identity(&format!("address:0x{HEX}")).unwrap();
        assert!(matches!(identity, Identity::Address(_)));
    }

    #[test]
    fn parses_contract_identity() {
        let identity = parse_identity(&format!("contract:0x{HEX}")).unwrap();
        assert!(matches!(identity, Identity::ContractId(_)));
    }

    #[test]
    fn rejects_bare_value_without_kind() {
        let err = parse_identity(&format!("0x{HEX}")).unwrap_err();
        assert!(err.to_string().contains("must specify its kind"));
    }
}