o2-deploy 0.3.22-rc

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

/// 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,

    /// How many times to attempt each RPC call before giving up.
    ///
    /// `fuels` defaults this to 1 — NO retries — so one transport hiccup
    /// anywhere in a deploy aborts the whole run, leaving the estate
    /// half-upgraded and the config write-back unwritten. A deploy makes
    /// hundreds of calls, so at that default the question is not whether
    /// a run trips but when.
    ///
    /// The endpoint is normally a load balancer over several nodes that
    /// are NOT height-consistent, while the SDK stamps every post-submit
    /// request with `required_fuel_block_height` so it cannot be served
    /// state older than what it just wrote. A request routed to a replica
    /// that has not caught up fails with "The required block height was
    /// not met" — the error that aborted three consecutive mainnet
    /// deploys on 2026-08-11, when two sentries stalled ~170 blocks
    /// behind the tip.
    ///
    /// A retry helps two ways: the backoff gives the replica time to
    /// catch up, and a fresh connection may be balanced onto a different
    /// one. Neither is guaranteed — connection reuse can pin a retry to
    /// the same node — which is why this is a mitigation and not a
    /// substitute for pointing a deploy at a single node.
    ///
    /// Set to 1 to restore the old fail-fast behaviour.
    #[arg(long, env = "FUEL_RPC_RETRIES", default_value = "5")]
    rpc_retries: u32,

    /// 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}"))?;
    // Exponential from 250ms: waits of 0.25s, 0.5s, 1s and 2s between the
    // five attempts, so a call spends at most ~3.75s retrying before it
    // gives up. Cheap on a one-off blip, and short enough that a genuinely
    // broken endpoint still fails the run in minutes rather than hours —
    // raise --rpc-retries for a known-lagging node instead of making the
    // default slow for everyone.
    //
    // The retry sits in the SDK's own `RetryableClient`, which wraps every
    // provider call and retries on any error, so this one line covers the
    // whole deploy — blob uploads, contract calls and read-only
    // simulations alike — rather than each call site growing its own loop.
    // Contract reverts arrive as a successful response carrying a failure
    // status, not as an error, so they still fail immediately.
    let retry_config = RetryConfig::new(
        cli.rpc_retries,
        Backoff::Exponential(Duration::from_millis(250)),
    )
    .map_err(|e| anyhow::anyhow!("invalid --rpc-retries: {e}"))?;
    let provider = Provider::connect(cli.fuel_rpc.as_str())
        .await?
        .with_retry_config(retry_config);
    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"));
    }
}