1use anyhow::{
3 Context,
4 Result,
5 ensure,
6};
7use fuels::{
8 crypto::Hasher,
9 prelude::*,
10 tx::ContractIdExt,
11 types::{
12 Bits256,
13 Identity,
14 SubAssetId,
15 },
16};
17use o2_api_types::parse::HexDisplayFromStr;
18use o2_tools::{
19 eval_minter::{
20 DEFAULT_ADMIN_ROLE,
21 EvalMinterContract,
22 EvalMinterDeployConfig,
23 EvalMinterDeployment,
24 MINTER_ROLE,
25 PAUSER_ROLE,
26 RegisteredAsset,
27 },
28 generic_proxy::State,
29};
30use serde::{
31 Deserialize,
32 Serialize,
33};
34use serde_with::serde_as;
35use std::collections::HashSet;
36
37#[serde_as]
38#[derive(Debug, Clone, Serialize, Deserialize)]
39#[serde(deny_unknown_fields)]
40pub struct EvalAssetConfig {
41 #[serde(default)]
43 #[serde_as(as = "Option<HexDisplayFromStr>")]
44 pub sub_id: Option<SubAssetId>,
45 #[serde(default)]
47 #[serde_as(as = "Option<HexDisplayFromStr>")]
48 pub asset_id: Option<AssetId>,
49 pub name: String,
50 pub symbol: String,
51 pub decimals: u8,
52}
53
54#[serde_as]
55#[derive(Debug, Clone, Default, Serialize, Deserialize)]
56#[serde(deny_unknown_fields)]
57pub struct EvalMinterConfig {
58 #[serde_as(as = "Option<HexDisplayFromStr>")]
59 pub id: Option<ContractId>,
60 #[serde(default)]
61 pub assets: Vec<EvalAssetConfig>,
62 #[serde(default)]
63 pub minters: Vec<String>,
64 #[serde(default)]
65 pub remove_minters: Vec<String>,
66 #[serde(default)]
67 pub pausers: Vec<String>,
68 #[serde(default)]
69 pub remove_pausers: Vec<String>,
70}
71
72fn identity(value: &str) -> Result<Identity> {
73 let who = super::parse_margin_identity(value)?;
74 let nonzero = match who {
75 Identity::Address(id) => id != Address::zeroed(),
76 Identity::ContractId(id) => id != ContractId::zeroed(),
77 };
78 ensure!(nonzero, "EvalMinter role identity cannot be zero");
79 Ok(who)
80}
81
82fn symbol_sub_id(symbol: &str) -> SubAssetId {
83 SubAssetId::new(Hasher::hash(symbol.as_bytes()).into())
84}
85
86pub(crate) fn validate_config(config: &EvalMinterConfig) -> Result<()> {
87 ensure!(
88 config.id != Some(ContractId::zeroed()),
89 "EvalMinter id cannot be zero"
90 );
91 let mut symbols = HashSet::new();
92 for asset in &config.assets {
93 ensure!(
94 !asset.name.is_empty() && !asset.symbol.is_empty(),
95 "EvalMinter name and symbol must be nonempty"
96 );
97 ensure!(
98 symbols.insert(&asset.symbol),
99 "duplicate EvalMinter symbol: {}",
100 asset.symbol
101 );
102 let sub_id = symbol_sub_id(&asset.symbol);
103 if let Some(expected) = asset.sub_id {
104 ensure!(
105 expected == sub_id,
106 "EvalMinter sub ID mismatch for {}",
107 asset.symbol
108 );
109 }
110 if let (Some(id), Some(expected)) = (config.id, asset.asset_id) {
111 ensure!(
112 expected == id.asset_id(&sub_id),
113 "EvalMinter asset ID mismatch for {}",
114 asset.symbol
115 );
116 }
117 }
118 for (grants, revokes) in [
119 (&config.minters, &config.remove_minters),
120 (&config.pausers, &config.remove_pausers),
121 ] {
122 let grants = grants
123 .iter()
124 .map(|s| identity(s))
125 .collect::<Result<Vec<_>>>()?;
126 for value in revokes {
127 ensure!(
128 !grants.contains(&identity(value)?),
129 "conflicting EvalMinter role grant/revoke: {value}"
130 );
131 }
132 }
133 Ok(())
134}
135
136struct AssetChange {
137 desired: EvalAssetConfig,
138 asset: RegisteredAsset,
139 registered: bool,
140 name_changed: bool,
141 decimals_changed: bool,
142}
143
144pub async fn reconcile_eval_minter<W: Account + Clone>(
147 wallet: &W,
148 config: &EvalMinterConfig,
149) -> Result<EvalMinterConfig> {
150 validate_config(config)?;
151 let id = config
152 .id
153 .context("EvalMinter reconciliation requires a deployed id")?;
154 ensure!(
155 wallet.try_provider()?.contract_exists(&id).await?,
156 "configured EvalMinter does not exist: {id}"
157 );
158 let minter = EvalMinterContract::new(id, wallet.clone());
159 ensure!(
160 minter
161 .methods()
162 .initialized()
163 .simulate(Execution::state_read_only())
164 .await?
165 .value,
166 "EvalMinter must already be initialized"
167 );
168
169 let mut changes = Vec::new();
171 for desired in &config.assets {
172 let sub_id = symbol_sub_id(&desired.symbol);
173 let asset = RegisteredAsset {
174 sub_id: Bits256::from(sub_id),
175 asset_id: id.asset_id(&sub_id),
176 };
177 let registered = minter
178 .methods()
179 .total_supply(asset.asset_id)
180 .simulate(Execution::state_read_only())
181 .await?
182 .value
183 .is_some();
184 let (name_changed, decimals_changed) = if registered {
185 (
186 minter
187 .methods()
188 .name(asset.asset_id)
189 .simulate(Execution::state_read_only())
190 .await?
191 .value
192 .as_deref()
193 != Some(desired.name.as_str()),
194 minter
195 .methods()
196 .decimals(asset.asset_id)
197 .simulate(Execution::state_read_only())
198 .await?
199 .value
200 != Some(desired.decimals),
201 )
202 } else {
203 (true, true)
204 };
205 changes.push(AssetChange {
206 desired: desired.clone(),
207 asset,
208 registered,
209 name_changed,
210 decimals_changed,
211 });
212 }
213
214 let mut role_changes = Vec::new();
215 for (role, grants, revokes) in [
216 (MINTER_ROLE, &config.minters, &config.remove_minters),
217 (PAUSER_ROLE, &config.pausers, &config.remove_pausers),
218 ] {
219 let mut seen = HashSet::new();
220 for (values, wanted) in [(grants, true), (revokes, false)] {
221 for value in values {
222 let who = identity(value)?;
223 if !seen.insert(who) {
224 continue;
225 }
226 if minter
227 .methods()
228 .has_role(role, who)
229 .simulate(Execution::state_read_only())
230 .await?
231 .value
232 != wanted
233 {
234 role_changes.push((role, who, wanted));
235 }
236 }
237 }
238 }
239 if !role_changes.is_empty()
240 || changes.iter().any(|a| a.name_changed || a.decimals_changed)
241 {
242 ensure!(
243 minter
244 .methods()
245 .has_role(DEFAULT_ADMIN_ROLE, Identity::Address(wallet.address()))
246 .simulate(Execution::state_read_only())
247 .await?
248 .value,
249 "EvalMinter reconciliation requires DEFAULT_ADMIN_ROLE"
250 );
251 }
252
253 let mut output = config.clone();
254 output.assets.clear();
255 for mut change in changes {
256 let asset = change.asset;
257 if change.registered {
258 if change.name_changed {
259 minter
260 .methods()
261 .set_name(asset.sub_id, change.desired.name.clone())
262 .call()
263 .await
264 .context("set EvalMinter name")?;
265 }
266 if change.decimals_changed {
267 minter
268 .methods()
269 .set_decimals(asset.sub_id, change.desired.decimals)
270 .call()
271 .await
272 .context("set EvalMinter decimals")?;
273 }
274 } else {
275 let registered = minter
276 .methods()
277 .register_asset(
278 change.desired.name.clone(),
279 change.desired.symbol.clone(),
280 change.desired.decimals,
281 )
282 .call()
283 .await
284 .context("register EvalMinter asset")?
285 .value;
286 ensure!(
287 registered.sub_id == asset.sub_id
288 && registered.asset_id == asset.asset_id,
289 "EvalMinter registered unexpected IDs for {}",
290 change.desired.symbol
291 );
292 }
293 change.desired.sub_id = Some(SubAssetId::new(asset.sub_id.0));
294 change.desired.asset_id = Some(asset.asset_id);
295 output.assets.push(change.desired);
296 }
297 for (role, who, wanted) in role_changes {
298 if wanted {
299 minter.methods().grant_role(role, who).call().await?;
300 } else {
301 minter.methods().revoke_role(role, who).call().await?;
302 }
303 }
304 Ok(output)
305}
306
307pub async fn reconcile_eval_minter_document<W: Account + Clone>(
310 wallet: &W,
311 mut document: serde_json::Value,
312) -> Result<serde_json::Value> {
313 let config: EvalMinterConfig = serde_json::from_value(
314 document
315 .get("eval_minter")
316 .context("configuration has no eval_minter section")?
317 .clone(),
318 )?;
319 let result = reconcile_eval_minter(wallet, &config).await?;
320 document["eval_minter"] = serde_json::to_value(result)?;
321 Ok(document)
322}
323
324pub(crate) async fn deploy_and_reconcile<W: Account + Clone>(
325 wallet: &W,
326 config: &EvalMinterConfig,
327 salt: Salt,
328 params: &super::DeployParams,
329) -> Result<EvalMinterConfig> {
330 validate_config(config)?;
331 let deployed = EvalMinterDeployment::deploy(
332 wallet,
333 &EvalMinterDeployConfig {
334 contract_id: config.id,
335 salt,
336 upgrade_bytecode: params.upgrade_bytecode,
337 ..Default::default()
338 },
339 )
340 .await?;
341 let output = reconcile_eval_minter(
342 wallet,
343 &EvalMinterConfig {
344 id: Some(deployed.contract_id),
345 ..config.clone()
346 },
347 )
348 .await?;
349 if let Some(owner) = params.new_proxy_owner
350 && deployed
351 .proxy
352 .methods()
353 .proxy_owner()
354 .simulate(Execution::state_read_only())
355 .await?
356 .value
357 != State::Initialized(Identity::Address(owner))
358 {
359 deployed
360 .proxy
361 .methods()
362 .set_owner(Identity::Address(owner))
363 .call()
364 .await?;
365 }
366 if let Some(owner) = params.new_contract_owner {
367 let new_admin = Identity::Address(owner);
368 let caller = Identity::Address(wallet.address());
369 if !deployed
370 .minter
371 .methods()
372 .has_role(DEFAULT_ADMIN_ROLE, new_admin)
373 .simulate(Execution::state_read_only())
374 .await?
375 .value
376 {
377 deployed
378 .minter
379 .methods()
380 .grant_role(DEFAULT_ADMIN_ROLE, new_admin)
381 .call()
382 .await?;
383 }
384 if caller != new_admin
385 && deployed
386 .minter
387 .methods()
388 .has_role(DEFAULT_ADMIN_ROLE, caller)
389 .simulate(Execution::state_read_only())
390 .await?
391 .value
392 {
393 deployed
394 .minter
395 .methods()
396 .revoke_role(DEFAULT_ADMIN_ROLE, caller)
397 .call()
398 .await?;
399 }
400 }
401 Ok(output)
402}
403
404#[cfg(test)]
405mod tests {
406 use super::*;
407
408 #[test]
409 fn initial_json_needs_no_ids_and_derived_ids_round_trip() {
410 let mut config: EvalMinterConfig = serde_json::from_value(serde_json::json!({
411 "assets": [{"name": "Evaluation", "symbol": "EVAL", "decimals": 0}]
412 }))
413 .unwrap();
414 assert!(config.id.is_none());
415 assert!(config.assets[0].sub_id.is_none());
416 assert!(config.minters.is_empty() && config.pausers.is_empty());
417 validate_config(&config).unwrap();
418 config.id = Some(ContractId::new([0x11; 32]));
419 let sub_id = symbol_sub_id("EVAL");
420 config.assets[0].sub_id = Some(sub_id);
421 config.assets[0].asset_id = Some(config.id.unwrap().asset_id(&sub_id));
422 validate_config(&config).unwrap();
423 let json = serde_json::to_value(&config).unwrap();
424 assert_eq!(
425 json["assets"][0]["sub_id"],
426 "0x4851296850b83e5ce0c7a74420da1eca82bd3dcfb9b614fdd3fe671910a3b58f"
427 );
428 let recovered: EvalMinterConfig = serde_json::from_value(json).unwrap();
429 assert_eq!(recovered.assets[0].sub_id, Some(sub_id));
430 validate_config(&recovered).unwrap();
431 }
432
433 #[test]
434 fn sub_ids_hash_exact_symbol_bytes() {
435 for (symbol, expected) in [
436 (
437 "EVAL",
438 "4851296850b83e5ce0c7a74420da1eca82bd3dcfb9b614fdd3fe671910a3b58f",
439 ),
440 (
441 "eval",
442 "1bbd174404efbce95f1af489ef93f4aa0f4d55718f24c3504682216afa7b7fb1",
443 ),
444 (
445 "ÉVAL",
446 "0f029f13c76e14feb1ba8c4ff351deac9b31a412fd7c9b1818871151a965f6bb",
447 ),
448 ] {
449 assert_eq!(hex::encode(symbol_sub_id(symbol)), expected);
450 }
451 }
452
453 #[test]
454 fn malformed_ids_unknown_fields_and_zero_role_identities_are_rejected() {
455 assert!(
456 serde_json::from_value::<EvalMinterConfig>(serde_json::json!({"pauser": []}))
457 .is_err()
458 );
459 assert!(serde_json::from_value::<EvalMinterConfig>(serde_json::json!({
460 "assets": [{"name": "Evaluation", "symbol": "EVAL", "decimals": 9, "sub_id": "0x01"}]
461 })).is_err());
462 let config = EvalMinterConfig {
463 pausers: vec![format!("address:{}", Address::zeroed())],
464 ..Default::default()
465 };
466 assert!(validate_config(&config).is_err());
467 }
468}