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    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    /// Persisted identity. Required for renames; omission resolves the current symbol.
40    #[serde(default)]
41    #[serde_as(as = "Option<HexDisplayFromStr>")]
42    pub sub_id: Option<SubAssetId>,
43    /// Derived identity, validated when provided and always populated on output.
44    #[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
131/// Reconcile only an existing initialized minter. Never deploys, upgrades, mints,
132/// transfers ownership, or touches other contracts. Omission preserves state.
133pub 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    // Resolve and validate every identity and symbol before submitting any writes.
157    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) = &registered {
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
345/// Preserve all unrelated fields, including deployment output fields unknown to
346/// MarketsConfigPartial, when running the isolated CLI reconciliation mode.
347pub 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}