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