o2-deploy 0.4.8-rc3

Contract deployment logic for Fuel O2 exchange
Documentation
//! Optional EvalShop deployment and explicit, compare-first configuration.

use anyhow::{
    Context,
    Result,
    ensure,
};
use fuels::{
    prelude::*,
    types::Identity,
};
use o2_tools::eval_shop::{
    DEFAULT_ADMIN_ROLE,
    EvalShopDeployConfig,
    EvalShopDeployment,
    FEE_WITHDRAWER_ROLE,
    PAUSER_ROLE,
    PRICE_MANAGER_ROLE,
};
use serde::{
    Deserialize,
    Serialize,
};
use std::collections::HashSet;

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EvalShopPriceConfig {
    pub tier_id: u64,
    pub asset_id: AssetId,
    /// Base units as a decimal string; explicit null removes the listing.
    /// Required even when null, to prevent a missing field from disabling sales.
    #[serde(deserialize_with = "read_price", serialize_with = "write_price")]
    pub price: Option<u64>,
}

fn read_price<'de, D: serde::Deserializer<'de>>(
    deserializer: D,
) -> Result<Option<u64>, D::Error> {
    Option::<String>::deserialize(deserializer)?
        .map(|value| value.parse().map_err(serde::de::Error::custom))
        .transpose()
}

fn write_price<S: serde::Serializer>(
    price: &Option<u64>,
    serializer: S,
) -> Result<S::Ok, S::Error> {
    price.map(|value| value.to_string()).serialize(serializer)
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EvalShopConfig {
    pub id: Option<ContractId>,
    /// Required address:0x... or contract:0x... identity. Only this payee receives withdrawals.
    pub treasury: String,
    #[serde(default)]
    pub prices: Vec<EvalShopPriceConfig>,
    #[serde(default)]
    pub price_managers: Vec<String>,
    #[serde(default)]
    pub remove_price_managers: Vec<String>,
    #[serde(default)]
    pub fee_withdrawers: Vec<String>,
    #[serde(default)]
    pub remove_fee_withdrawers: Vec<String>,
    #[serde(default)]
    pub pausers: Vec<String>,
    #[serde(default)]
    pub remove_pausers: Vec<String>,
}

fn identity(value: &str) -> Result<Identity> {
    let id = super::parse_margin_identity(value)?;
    let nonzero = match id {
        Identity::Address(address) => address != Address::zeroed(),
        Identity::ContractId(contract) => contract != ContractId::zeroed(),
    };
    ensure!(nonzero, "EvalShop identity cannot be zero");
    Ok(id)
}

pub(crate) fn validate_config(config: &EvalShopConfig) -> Result<()> {
    identity(&config.treasury).context("invalid EvalShop treasury")?;
    ensure!(
        config.id != Some(ContractId::zeroed()),
        "EvalShop id cannot be zero"
    );
    let mut prices = HashSet::new();
    for price in &config.prices {
        ensure!(
            prices.insert((price.tier_id, price.asset_id)),
            "duplicate EvalShop tier/asset price: {} / {}",
            price.tier_id,
            price.asset_id
        );
    }
    for (grants, revokes) in [
        (&config.price_managers, &config.remove_price_managers),
        (&config.fee_withdrawers, &config.remove_fee_withdrawers),
        (&config.pausers, &config.remove_pausers),
    ] {
        let grants = grants
            .iter()
            .map(|s| identity(s))
            .collect::<Result<Vec<_>>>()?;
        for revoke in revokes {
            ensure!(
                !grants.contains(&identity(revoke)?),
                "conflicting EvalShop role grant/revoke for {revoke}"
            );
        }
    }
    Ok(())
}

pub(crate) async fn deploy_and_reconcile<W: Account + Clone>(
    wallet: &W,
    config: &EvalShopConfig,
    registry: ContractId,
    salt: Salt,
    params: &super::DeployParams,
) -> Result<EvalShopConfig> {
    validate_config(config)?;
    let deployment = EvalShopDeployment::deploy(
        wallet,
        &EvalShopDeployConfig {
            contract_id: config.id,
            salt,
            upgrade_bytecode: params.upgrade_bytecode,
            ..Default::default()
        },
    )
    .await?;
    let shop = &deployment.shop;
    let treasury = identity(&config.treasury)?;
    if shop
        .methods()
        .registry()
        .simulate(Execution::state_read_only())
        .await?
        .value
        != Some(registry)
    {
        shop.methods()
            .set_registry(registry)
            .call()
            .await
            .context("set EvalShop registry")?;
    }
    if shop
        .methods()
        .treasury()
        .simulate(Execution::state_read_only())
        .await?
        .value
        != Some(treasury)
    {
        shop.methods()
            .set_treasury(treasury)
            .call()
            .await
            .context("set EvalShop treasury")?;
    }
    for (role, grants) in [
        (PRICE_MANAGER_ROLE, &config.price_managers),
        (FEE_WITHDRAWER_ROLE, &config.fee_withdrawers),
        (PAUSER_ROLE, &config.pausers),
    ] {
        for value in grants {
            let who = identity(value)?;
            if !shop
                .methods()
                .has_role(role, who)
                .simulate(Execution::state_read_only())
                .await?
                .value
            {
                shop.methods().grant_role(role, who).call().await?;
            }
        }
    }
    for entry in &config.prices {
        let current = shop
            .methods()
            .price(entry.tier_id, entry.asset_id)
            .simulate(Execution::state_read_only())
            .await?
            .value;
        if current != entry.price {
            ensure!(
                shop.methods()
                    .has_role(PRICE_MANAGER_ROLE, Identity::Address(wallet.address()))
                    .simulate(Execution::state_read_only())
                    .await?
                    .value,
                "EvalShop price reconciliation requires PRICE_MANAGER_ROLE; include the deployment wallet in eval_shop.price_managers or grant it explicitly"
            );
            shop.methods()
                .set_price(entry.tier_id, entry.asset_id, entry.price)
                .call()
                .await
                .context("set EvalShop price")?;
        }
    }
    // The caller may deliberately retire its pricing authority in the same run
    // that updates prices. Apply explicit revocations only after those writes.
    for (role, revokes) in [
        (PRICE_MANAGER_ROLE, &config.remove_price_managers),
        (FEE_WITHDRAWER_ROLE, &config.remove_fee_withdrawers),
        (PAUSER_ROLE, &config.remove_pausers),
    ] {
        for value in revokes {
            let who = identity(value)?;
            if shop
                .methods()
                .has_role(role, who)
                .simulate(Execution::state_read_only())
                .await?
                .value
            {
                shop.methods().revoke_role(role, who).call().await?;
            }
        }
    }
    if let Some(owner) = params.new_proxy_owner {
        let desired =
            o2_tools::generic_proxy::State::Initialized(Identity::Address(owner));
        if deployment
            .proxy
            .methods()
            .proxy_owner()
            .simulate(Execution::state_read_only())
            .await?
            .value
            != desired
        {
            deployment
                .proxy
                .methods()
                .set_owner(Identity::Address(owner))
                .call()
                .await?;
        }
    }
    if let Some(owner) = params.new_contract_owner {
        let new_admin = Identity::Address(owner);
        let caller = Identity::Address(wallet.address());
        if !shop
            .methods()
            .has_role(DEFAULT_ADMIN_ROLE, new_admin)
            .simulate(Execution::state_read_only())
            .await?
            .value
        {
            shop.methods()
                .grant_role(DEFAULT_ADMIN_ROLE, new_admin)
                .call()
                .await?;
        }
        if caller != new_admin
            && shop
                .methods()
                .has_role(DEFAULT_ADMIN_ROLE, caller)
                .simulate(Execution::state_read_only())
                .await?
                .value
        {
            shop.methods()
                .revoke_role(DEFAULT_ADMIN_ROLE, caller)
                .call()
                .await?;
        }
    }
    Ok(EvalShopConfig {
        id: Some(deployment.contract_id),
        ..config.clone()
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn optional_prices_preserve_zero_and_require_explicit_null_for_removal() {
        let asset = AssetId::new([1; 32]);
        let value = serde_json::json!({"tier_id": 1, "asset_id": asset, "price": "0"});
        let mut config: EvalShopPriceConfig = serde_json::from_value(value).unwrap();
        assert_eq!(config.price, Some(0));
        config.price = None;
        let value = serde_json::to_value(&config).unwrap();
        assert!(value["price"].is_null());
        assert_eq!(
            serde_json::from_value::<EvalShopPriceConfig>(value)
                .unwrap()
                .price,
            None
        );
        assert!(
            serde_json::from_value::<EvalShopPriceConfig>(
                serde_json::json!({"tier_id": 1, "asset_id": asset})
            )
            .is_err()
        );
    }

    #[test]
    fn duplicate_prices_and_conflicting_roles_are_rejected() {
        let owner = format!("address:{}", Address::new([1; 32]));
        let mut config = EvalShopConfig {
            id: None,
            treasury: owner.clone(),
            prices: vec![],
            price_managers: vec![owner.clone()],
            remove_price_managers: vec![owner],
            fee_withdrawers: vec![],
            remove_fee_withdrawers: vec![],
            pausers: vec![],
            remove_pausers: vec![],
        };
        assert!(
            validate_config(&config)
                .unwrap_err()
                .to_string()
                .contains("conflicting")
        );
        config.remove_price_managers.clear();
        let price = EvalShopPriceConfig {
            tier_id: 1,
            asset_id: AssetId::new([2; 32]),
            price: Some(0),
        };
        config.prices = vec![price.clone(), price];
        assert!(
            validate_config(&config)
                .unwrap_err()
                .to_string()
                .contains("duplicate")
        );
    }
}