use anyhow::{
Context,
Result,
ensure,
};
use fuels::{
prelude::*,
types::Identity,
};
use o2_tools::eval_shop::{
DEFAULT_ADMIN_ROLE,
DISCOUNT_MANAGER_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,
#[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>,
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>,
#[serde(default)]
pub discount_managers: Vec<String>,
#[serde(default)]
pub remove_discount_managers: 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),
(&config.discount_managers, &config.remove_discount_managers),
] {
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),
(DISCOUNT_MANAGER_ROLE, &config.discount_managers),
] {
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")?;
}
}
for (role, revokes) in [
(PRICE_MANAGER_ROLE, &config.remove_price_managers),
(FEE_WITHDRAWER_ROLE, &config.remove_fee_withdrawers),
(PAUSER_ROLE, &config.remove_pausers),
(DISCOUNT_MANAGER_ROLE, &config.remove_discount_managers),
] {
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![],
discount_managers: vec![],
remove_discount_managers: 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")
);
}
#[test]
fn discount_manager_configuration_is_optional_and_validated() {
let owner = format!("address:{}", Address::new([1; 32]));
let mut config: EvalShopConfig = serde_json::from_value(serde_json::json!({
"treasury": owner,
}))
.unwrap();
assert!(config.discount_managers.is_empty());
assert!(config.remove_discount_managers.is_empty());
validate_config(&config).unwrap();
config.discount_managers.push(owner.clone());
config.remove_discount_managers.push(owner);
assert!(
validate_config(&config)
.unwrap_err()
.to_string()
.contains("conflicting")
);
config.remove_discount_managers.clear();
config.discount_managers = vec![format!("address:{}", Address::zeroed())];
assert!(validate_config(&config).is_err());
config.discount_managers.clear();
config.remove_discount_managers = vec!["invalid identity".into()];
assert!(validate_config(&config).is_err());
}
}