Skip to main content

o2_deploy/
eval_minter.rs

1//! Optional EvalMinter deployment and compare-first metadata/role reconciliation.
2use 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    /// Optional assertion of sha256(symbol bytes), always populated on output.
42    #[serde(default)]
43    #[serde_as(as = "Option<HexDisplayFromStr>")]
44    pub sub_id: Option<SubAssetId>,
45    /// Derived identity, validated when provided and always populated on output.
46    #[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
144/// Reconcile only an existing initialized minter. Never deploys, upgrades, mints,
145/// transfers ownership, or touches other contracts. Omission preserves state.
146pub 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    // Compare every asset and role before submitting any writes.
170    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
307/// Preserve all unrelated fields, including deployment output fields unknown to
308/// MarketsConfigPartial, when running the isolated CLI reconciliation mode.
309pub 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}