use anyhow::{
Context,
Result,
ensure,
};
use fuels::{
crypto::Hasher,
prelude::*,
tx::ContractIdExt,
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)
}
fn symbol_sub_id(symbol: &str) -> SubAssetId {
SubAssetId::new(Hasher::hash(symbol.as_bytes()).into())
}
pub(crate) fn validate_config(config: &EvalMinterConfig) -> Result<()> {
ensure!(
config.id != Some(ContractId::zeroed()),
"EvalMinter id cannot be zero"
);
let mut symbols = 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
);
let sub_id = symbol_sub_id(&asset.symbol);
if let Some(expected) = asset.sub_id {
ensure!(
expected == sub_id,
"EvalMinter sub ID mismatch for {}",
asset.symbol
);
}
if let (Some(id), Some(expected)) = (config.id, asset.asset_id) {
ensure!(
expected == id.asset_id(&sub_id),
"EvalMinter asset ID mismatch for {}",
asset.symbol
);
}
}
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,
asset: RegisteredAsset,
registered: bool,
name_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();
for desired in &config.assets {
let sub_id = symbol_sub_id(&desired.symbol);
let asset = RegisteredAsset {
sub_id: Bits256::from(sub_id),
asset_id: id.asset_id(&sub_id),
};
let registered = minter
.methods()
.total_supply(asset.asset_id)
.simulate(Execution::state_read_only())
.await?
.value
.is_some();
let (name_changed, decimals_changed) = if registered {
(
minter
.methods()
.name(asset.asset_id)
.simulate(Execution::state_read_only())
.await?
.value
.as_deref()
!= Some(desired.name.as_str()),
minter
.methods()
.decimals(asset.asset_id)
.simulate(Execution::state_read_only())
.await?
.value
!= Some(desired.decimals),
)
} else {
(true, true)
};
changes.push(AssetChange {
desired: desired.clone(),
asset,
registered,
name_changed,
decimals_changed,
});
}
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.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 asset = change.asset;
if change.registered {
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")?;
}
} else {
let registered = minter
.methods()
.register_asset(
change.desired.name.clone(),
change.desired.symbol.clone(),
change.desired.decimals,
)
.call()
.await
.context("register EvalMinter asset")?
.value;
ensure!(
registered.sub_id == asset.sub_id
&& registered.asset_id == asset.asset_id,
"EvalMinter registered unexpected IDs for {}",
change.desired.symbol
);
}
change.desired.sub_id = Some(SubAssetId::new(asset.sub_id.0));
change.desired.asset_id = Some(asset.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_derived_ids_round_trip() {
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.id = Some(ContractId::new([0x11; 32]));
let sub_id = symbol_sub_id("EVAL");
config.assets[0].sub_id = Some(sub_id);
config.assets[0].asset_id = Some(config.id.unwrap().asset_id(&sub_id));
validate_config(&config).unwrap();
let json = serde_json::to_value(&config).unwrap();
assert_eq!(
json["assets"][0]["sub_id"],
"0x4851296850b83e5ce0c7a74420da1eca82bd3dcfb9b614fdd3fe671910a3b58f"
);
let recovered: EvalMinterConfig = serde_json::from_value(json).unwrap();
assert_eq!(recovered.assets[0].sub_id, Some(sub_id));
validate_config(&recovered).unwrap();
}
#[test]
fn sub_ids_hash_exact_symbol_bytes() {
for (symbol, expected) in [
(
"EVAL",
"4851296850b83e5ce0c7a74420da1eca82bd3dcfb9b614fdd3fe671910a3b58f",
),
(
"eval",
"1bbd174404efbce95f1af489ef93f4aa0f4d55718f24c3504682216afa7b7fb1",
),
(
"ÉVAL",
"0f029f13c76e14feb1ba8c4ff351deac9b31a412fd7c9b1818871151a965f6bb",
),
] {
assert_eq!(hex::encode(symbol_sub_id(symbol)), expected);
}
}
#[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());
}
}