use anyhow::{
Context,
Result,
ensure,
};
use fuels::{
prelude::*,
types::{
Bits256,
Identity,
SubAssetId,
},
};
use o2_api_types::parse::HexDisplayFromStr;
use o2_tools::{
eval_minter::{
DEFAULT_ADMIN_ROLE,
EvalMinterContract,
EvalMinterDeployConfig,
EvalMinterDeployment,
MINTER_ROLE,
PAUSER_ROLE,
RegisteredAsset,
},
generic_proxy::State,
};
use serde::{
Deserialize,
Serialize,
};
use serde_with::serde_as;
use std::collections::HashSet;
#[serde_as]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EvalAssetConfig {
#[serde(default)]
#[serde_as(as = "Option<HexDisplayFromStr>")]
pub sub_id: Option<SubAssetId>,
#[serde(default)]
#[serde_as(as = "Option<HexDisplayFromStr>")]
pub asset_id: Option<AssetId>,
pub name: String,
pub symbol: String,
pub decimals: u8,
}
#[serde_as]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EvalMinterConfig {
#[serde_as(as = "Option<HexDisplayFromStr>")]
pub id: Option<ContractId>,
#[serde(default)]
pub assets: Vec<EvalAssetConfig>,
#[serde(default)]
pub minters: Vec<String>,
#[serde(default)]
pub remove_minters: Vec<String>,
#[serde(default)]
pub pausers: Vec<String>,
#[serde(default)]
pub remove_pausers: Vec<String>,
}
fn identity(value: &str) -> Result<Identity> {
let who = super::parse_margin_identity(value)?;
let nonzero = match who {
Identity::Address(id) => id != Address::zeroed(),
Identity::ContractId(id) => id != ContractId::zeroed(),
};
ensure!(nonzero, "EvalMinter role identity cannot be zero");
Ok(who)
}
pub(crate) fn validate_config(config: &EvalMinterConfig) -> Result<()> {
ensure!(
config.id != Some(ContractId::zeroed()),
"EvalMinter id cannot be zero"
);
let mut symbols = HashSet::new();
let mut sub_ids = HashSet::new();
let mut asset_ids = HashSet::new();
for asset in &config.assets {
ensure!(
!asset.name.is_empty() && !asset.symbol.is_empty(),
"EvalMinter name and symbol must be nonempty"
);
ensure!(
symbols.insert(&asset.symbol),
"duplicate EvalMinter symbol: {}",
asset.symbol
);
if let Some(id) = asset.sub_id {
ensure!(sub_ids.insert(id), "duplicate EvalMinter sub ID: {id}");
}
if let Some(id) = asset.asset_id {
ensure!(asset_ids.insert(id), "duplicate EvalMinter asset ID: {id}");
}
}
for (grants, revokes) in [
(&config.minters, &config.remove_minters),
(&config.pausers, &config.remove_pausers),
] {
let grants = grants
.iter()
.map(|s| identity(s))
.collect::<Result<Vec<_>>>()?;
for value in revokes {
ensure!(
!grants.contains(&identity(value)?),
"conflicting EvalMinter role grant/revoke: {value}"
);
}
}
Ok(())
}
struct AssetChange {
desired: EvalAssetConfig,
registered: Option<RegisteredAsset>,
name_changed: bool,
symbol_changed: bool,
decimals_changed: bool,
}
pub async fn reconcile_eval_minter<W: Account + Clone>(
wallet: &W,
config: &EvalMinterConfig,
) -> Result<EvalMinterConfig> {
validate_config(config)?;
let id = config
.id
.context("EvalMinter reconciliation requires a deployed id")?;
ensure!(
wallet.try_provider()?.contract_exists(&id).await?,
"configured EvalMinter does not exist: {id}"
);
let minter = EvalMinterContract::new(id, wallet.clone());
ensure!(
minter
.methods()
.initialized()
.simulate(Execution::state_read_only())
.await?
.value,
"EvalMinter must already be initialized"
);
let mut changes = Vec::new();
let mut resolved_ids = HashSet::new();
for desired in &config.assets {
let symbol_sub_id = minter
.methods()
.sub_id_by_symbol(desired.symbol.clone())
.simulate(Execution::state_read_only())
.await?
.value;
let sub_id = desired.sub_id.map(Bits256::from).or(symbol_sub_id);
let registered = if let Some(sub_id) = sub_id {
let asset_id = minter
.methods()
.asset_id(sub_id)
.simulate(Execution::state_read_only())
.await?
.value
.context("configured EvalMinter sub ID is not registered")?;
ensure!(
symbol_sub_id.is_none() || symbol_sub_id == Some(sub_id),
"EvalMinter symbol is already assigned to a different asset: {}",
desired.symbol
);
ensure!(
resolved_ids.insert(asset_id),
"multiple entries resolve to EvalMinter asset {asset_id}"
);
Some(RegisteredAsset { sub_id, asset_id })
} else {
None
};
if let Some(expected) = desired.asset_id {
ensure!(
registered.as_ref().map(|r| r.asset_id) == Some(expected),
"EvalMinter asset ID mismatch for {}",
desired.symbol
);
}
let (name_changed, symbol_changed, decimals_changed) =
if let Some(asset) = ®istered {
(
minter
.methods()
.name(asset.asset_id)
.simulate(Execution::state_read_only())
.await?
.value
.as_deref()
!= Some(desired.name.as_str()),
minter
.methods()
.symbol(asset.asset_id)
.simulate(Execution::state_read_only())
.await?
.value
.as_deref()
!= Some(desired.symbol.as_str()),
minter
.methods()
.decimals(asset.asset_id)
.simulate(Execution::state_read_only())
.await?
.value
!= Some(desired.decimals),
)
} else {
(true, true, true)
};
changes.push(AssetChange {
desired: desired.clone(),
registered,
name_changed,
symbol_changed,
decimals_changed,
});
}
let new_assets = changes.iter().filter(|a| a.registered.is_none()).count() as u64;
let count = minter
.methods()
.total_assets()
.simulate(Execution::state_read_only())
.await?
.value;
ensure!(
count.checked_add(new_assets).is_some(),
"EvalMinter sub IDs exhausted"
);
let mut role_changes = Vec::new();
for (role, grants, revokes) in [
(MINTER_ROLE, &config.minters, &config.remove_minters),
(PAUSER_ROLE, &config.pausers, &config.remove_pausers),
] {
let mut seen = HashSet::new();
for (values, wanted) in [(grants, true), (revokes, false)] {
for value in values {
let who = identity(value)?;
if !seen.insert(who) {
continue;
}
if minter
.methods()
.has_role(role, who)
.simulate(Execution::state_read_only())
.await?
.value
!= wanted
{
role_changes.push((role, who, wanted));
}
}
}
}
if !role_changes.is_empty()
|| changes
.iter()
.any(|a| a.name_changed || a.symbol_changed || a.decimals_changed)
{
ensure!(
minter
.methods()
.has_role(DEFAULT_ADMIN_ROLE, Identity::Address(wallet.address()))
.simulate(Execution::state_read_only())
.await?
.value,
"EvalMinter reconciliation requires DEFAULT_ADMIN_ROLE"
);
}
let mut output = config.clone();
output.assets.clear();
for mut change in changes {
let registered = match change.registered {
Some(asset) => {
if change.symbol_changed {
minter
.methods()
.set_symbol(asset.sub_id, change.desired.symbol.clone())
.call()
.await
.context("set EvalMinter symbol")?;
}
if change.name_changed {
minter
.methods()
.set_name(asset.sub_id, change.desired.name.clone())
.call()
.await
.context("set EvalMinter name")?;
}
if change.decimals_changed {
minter
.methods()
.set_decimals(asset.sub_id, change.desired.decimals)
.call()
.await
.context("set EvalMinter decimals")?;
}
asset
}
None => {
minter
.methods()
.register_asset(
change.desired.name.clone(),
change.desired.symbol.clone(),
change.desired.decimals,
)
.call()
.await
.context("register EvalMinter asset")?
.value
}
};
change.desired.sub_id = Some(SubAssetId::new(registered.sub_id.0));
change.desired.asset_id = Some(registered.asset_id);
output.assets.push(change.desired);
}
for (role, who, wanted) in role_changes {
if wanted {
minter.methods().grant_role(role, who).call().await?;
} else {
minter.methods().revoke_role(role, who).call().await?;
}
}
Ok(output)
}
pub async fn reconcile_eval_minter_document<W: Account + Clone>(
wallet: &W,
mut document: serde_json::Value,
) -> Result<serde_json::Value> {
let config: EvalMinterConfig = serde_json::from_value(
document
.get("eval_minter")
.context("configuration has no eval_minter section")?
.clone(),
)?;
let result = reconcile_eval_minter(wallet, &config).await?;
document["eval_minter"] = serde_json::to_value(result)?;
Ok(document)
}
pub(crate) async fn deploy_and_reconcile<W: Account + Clone>(
wallet: &W,
config: &EvalMinterConfig,
salt: Salt,
params: &super::DeployParams,
) -> Result<EvalMinterConfig> {
validate_config(config)?;
let deployed = EvalMinterDeployment::deploy(
wallet,
&EvalMinterDeployConfig {
contract_id: config.id,
salt,
upgrade_bytecode: params.upgrade_bytecode,
..Default::default()
},
)
.await?;
let output = reconcile_eval_minter(
wallet,
&EvalMinterConfig {
id: Some(deployed.contract_id),
..config.clone()
},
)
.await?;
if let Some(owner) = params.new_proxy_owner
&& deployed
.proxy
.methods()
.proxy_owner()
.simulate(Execution::state_read_only())
.await?
.value
!= State::Initialized(Identity::Address(owner))
{
deployed
.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 !deployed
.minter
.methods()
.has_role(DEFAULT_ADMIN_ROLE, new_admin)
.simulate(Execution::state_read_only())
.await?
.value
{
deployed
.minter
.methods()
.grant_role(DEFAULT_ADMIN_ROLE, new_admin)
.call()
.await?;
}
if caller != new_admin
&& deployed
.minter
.methods()
.has_role(DEFAULT_ADMIN_ROLE, caller)
.simulate(Execution::state_read_only())
.await?
.value
{
deployed
.minter
.methods()
.revoke_role(DEFAULT_ADMIN_ROLE, caller)
.call()
.await?;
}
}
Ok(output)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn initial_json_needs_no_ids_and_persisted_zero_sub_id_round_trips() {
let mut config: EvalMinterConfig = serde_json::from_value(serde_json::json!({
"assets": [{"name": "Evaluation", "symbol": "EVAL", "decimals": 0}]
}))
.unwrap();
assert!(config.id.is_none());
assert!(config.assets[0].sub_id.is_none());
assert!(config.minters.is_empty() && config.pausers.is_empty());
validate_config(&config).unwrap();
config.assets[0].sub_id = Some(SubAssetId::zeroed());
let json = serde_json::to_value(&config).unwrap();
assert_eq!(
json["assets"][0]["sub_id"],
format!("0x{}", "00".repeat(32))
);
let recovered: EvalMinterConfig = serde_json::from_value(json).unwrap();
assert_eq!(recovered.assets[0].sub_id, Some(SubAssetId::zeroed()));
}
#[test]
fn malformed_ids_unknown_fields_and_zero_role_identities_are_rejected() {
assert!(
serde_json::from_value::<EvalMinterConfig>(serde_json::json!({"pauser": []}))
.is_err()
);
assert!(serde_json::from_value::<EvalMinterConfig>(serde_json::json!({
"assets": [{"name": "Evaluation", "symbol": "EVAL", "decimals": 9, "sub_id": "0x01"}]
})).is_err());
let config = EvalMinterConfig {
pausers: vec![format!("address:{}", Address::zeroed())],
..Default::default()
};
assert!(validate_config(&config).is_err());
}
}