miden-usdcx 0.18.0

USDCx components for the Miden protocol
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
//! `XReserveStablecoinBuilder` — the faucet account composition for the xUSDC faucet.
//! The mint path is the STOCK
//! `FungibleFaucet::mint_and_send` gated by the custom **attestation mint policy**
//! (`xreserve::mint_policy::check_policy` — the ENTIRE attestation pipeline lives in
//! the policy dispatch), so every supply increase passes the attestation gate — the
//! faucet's core mint-security invariant.
//!
//! Composes the `FungibleFaucet`, the attestation mint policy and `set_attester` extension,
//! a `TokenPolicyManager` whose burn policy checks required attachments and the minimum amount
//! stored by [`MinBurnAmount`], plus the stock [`PausableManager`], [`BlocklistManager`],
//! [`ConstantFeeManager`] and [`UpgradeManager`].
//! The RBAC seed holds five roles: `ADMIN`, `ATTEST_ADMIN`, `DOM_PAUSER`, `DOM_UNPAUSER` and
//! `BLK_MANAGER`. Every role is administered directly by `ADMIN`; there is no ownership component.
//! The standard role-action note rotates membership and can change role administration at runtime.
//!
//! [`XReserveAdminAuthority`] maps `pause` to `DOM_PAUSER`, `unpause` to `DOM_UNPAUSER`,
//! `set_attester` to `ATTEST_ADMIN`, and block/unblock to `BLK_MANAGER`. Other authority-gated
//! procedures, including `set_note_fee`, resolve to `ADMIN`. Pause and blocklist storage belong
//! to the base `Pausable` and `BasicBlocklist` companions; their managers add no storage.
//!
//! Fee administration uses a standard
//! [`BasicConstantFeePolicy`](miden_standards::account::fees::BasicConstantFeePolicy) constructed
//! from the network fee parameters and the xUSDC note-cost table.
//!
//! Domain config is entirely BUILD-SEEDED: `domain` is a required builder input written into its
//! declared slot at composition time. The faucet identifier has no slot — it is the account's own
//! id, which the mint path derives on chain, so the composed faucet is mint-ready when it exists.
//! The attester allowlist can be build-seeded the same way: the optional `attesters` input
//! allowlists keys at composition time, so their attestations mint without a `set_attester` note.

use bon::bon;
use miden_protocol::account::{AccountComponent, AccountId, RoleSymbol};
use miden_protocol::asset::{AssetAmount, AssetId};
use miden_protocol::block::FeeParameters;
use miden_protocol::crypto::dsa::ecdsa_k256_keccak::PublicKey;
use miden_standards::account::access::{
    Pausable,
    PausableManager,
    RoleBasedAccessControl,
    RoleConfig,
};
use miden_standards::account::faucets::FungibleFaucet;
use miden_standards::account::fees::ConstantFeeManager;
use miden_standards::account::policies::{
    BlocklistManager,
    BurnPolicy,
    MinBurnAmount,
    MintPolicy,
    TokenPolicyManager,
    TokenPolicyManagerV2,
    TransferPolicy,
};
use miden_standards::account::upgrade::UpgradeManager;

use crate::account::xreserve::XReserveAdminAuthority;
use crate::xreserve::encoding::CircleDomain;

mod construction;
mod error;
mod network_auth;

use construction::build_usdcx_faucet;
pub use construction::{
    XReserveFaucetExtension,
    build_faucet_account,
    record_used_nonces,
    upgrade_to_token_policy_manager_v2,
};
pub use error::XReserveStablecoinBuilderError;

/// Dedicated role symbols mapped to attester administration, pause and unpause by
/// [`XReserveAdminAuthority`]. All role administration resolves directly to `ADMIN`.
pub const DOM_PAUSER_ROLE: &str = "DOM_PAUSER";
pub const ATTEST_ADMIN_ROLE: &str = "ATTEST_ADMIN";
pub const DOM_UNPAUSER_ROLE: &str = "DOM_UNPAUSER";

/// The dedicated blocklist-administration RoleSymbol this faucet seeds under the ratified
/// transfer-blocklist decision: `BLK_MANAGER` is held by an EXTERNAL entity that
/// manages the transfer blocklist for Miden and has NO other admin capability (capability isolation
/// is two-way — the holder can ONLY block/unblock, and the administrator, lacking the role,
/// cannot). The stock `BlocklistManager`'s `block_account` / `unblock_account` roots are assigned
/// this symbol by [`XReserveAdminAuthority`], which is what keeps the capability off the
/// administrator — the owner-gated `BlocklistOwnerControlled` variant is the wrong identity and is
/// not installed. Its admin is left unset → resolves to the built-in `ADMIN`, so Miden rotates
/// or revokes the external entity through the allowlisted standard role-action note —
/// no new rotation machinery. `BLK_MANAGER` is seeded role id 4.
pub const BLK_MANAGER_ROLE: &str = "BLK_MANAGER";

/// Path of the attestation mint policy's `check_policy` procedure as the faucet component EXPORTS
/// it. The procedure is defined in the library's `mint_policy` module; the component re-exports it
/// under its own namespace, and it is that re-export the account installs and resolves by.
pub const ATTESTATION_MINT_POLICY_PROC_PATH: &str =
    "xreserve::components::faucet_extension::check_policy";

/// Path of the attester setter exported by the shipped faucet extension.
pub const XRESERVE_SET_ATTESTER_PROC_PATH: &str =
    "xreserve::components::faucet_extension::set_attester";

/// Path of the procedure exported by the burn policy component.
pub const XRESERVE_BURN_POLICY_PROC_PATH: &str =
    "xreserve::components::faucet_burn_policy::check_burn_policy";

/// The token policy manager version the faucet installs.
///
/// The V2 manager does not apply the pause check to transfers, so a pause of the faucet does not
/// block the fee payments of the network. See [`TokenPolicyManagerV2`].
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[non_exhaustive]
pub enum PolicyManagerVersion {
    /// The [`TokenPolicyManager`].
    #[default]
    V1,
    /// The [`TokenPolicyManagerV2`].
    V2,
}

/// The smallest admissible `min_burn_amount` (the zero floor). The stock [`MinBurnAmount`]
/// policy
/// asserts `min <= amount` ONLY (its authority-gated stock setter even accepts `0`), so the
/// zero-burn reject is enforced at note-building time: the builder rejects a floor below this at
/// construction, and the
/// [`XReserveMinBurnAmountNote`](crate::note::xreserve_admin::XReserveMinBurnAmountNote)
/// factory refuses a sub-floor value before assembling the standard config note.
pub const MIN_BURN_SIZE_FLOOR: u64 = 1;

/// The shipped on-chain `TokenSymbol` guard constant (token config). The token's identity is
/// **USDCx** — a DISTINCT identity from the "xUSDC" working label;
/// the two must not be confused.
pub const USDCX_TOKEN_SYMBOL: &str = "USDCX";

/// The spec-mandated token decimals (`token_config` decimals = 6; a Circle requirement of six
/// decimal places — the amount reducer scales to 6dp, so a mismatched faucet would silently
/// scale every minted amount wrongly).
pub const USDCX_DECIMALS: u8 = 6;

/// Composes the xUSDC faucet account: `FungibleFaucet` + the assembled `xreserve` library
/// component (attestation mint policy, admin procs) + a `TokenPolicyManager`
/// with the attestation mint policy and a burn policy that checks the required attachments
/// and the minimum amount stored by [`MinBurnAmount`], plus [`PausableManager`],
/// [`BlocklistManager`], and [`ConstantFeeManager`] components + a seeded `RoleBasedAccessControl`
/// governed by [`XReserveAdminAuthority`]'s `Authority::RbacControlled`.
///
/// Construct with the generated [`Self::builder`] (the faucet supply parameters, the `owner` and
/// role holders, the network fee parameters, and the build-seeded domain; the
/// min-burn floor and the build-seeded attesters are the optional inputs), then call
/// [`XReserveStablecoinBuilder::build_components`] (or the crate-root `build_faucet_account` /
/// [`Self::build_account`] for the finished `Account`).
#[derive(Debug)]
pub struct XReserveStablecoinBuilder {
    faucet: FungibleFaucet,
    /// The administrator: seeded as the sole member of the built-in `ADMIN` role, which is what
    /// gates every unmapped authority-gated procedure (the
    /// stock `set_min_burn_amount` / stock `set_max_supply` / the policy setters) under
    /// `Authority::RbacControlled`. It is the account's ONLY authority handle; rotating it is a
    /// grant and a revoke of `ADMIN` through the standard role-action note.
    owner: AccountId,
    /// The seeded `ATTEST_ADMIN` role members, authorized to call `set_attester`. May be empty:
    /// the role is then populated later through the standard role-action note.
    attest_admin_holders: Vec<AccountId>,
    /// The seeded `DOM_PAUSER` role members, authorized to pause the faucet.
    pauser_holders: Vec<AccountId>,
    /// The seeded `DOM_UNPAUSER` role members, authorized to unpause the faucet.
    unpauser_holders: Vec<AccountId>,
    /// The seeded `BLK_MANAGER` role members — the EXTERNAL entities that administer the transfer
    /// blocklist (block/unblock) and hold NO other admin capability. Their concrete
    /// account ids are supplied at deploy time; the built-in `ADMIN` rotates/revokes them via
    /// the standard role-action note.
    blocklist_manager_holders: Vec<AccountId>,
    /// Parameters used to price notes.
    fee_parameters: FeeParameters,
    /// The network fee asset the fee policies charge in.
    fee_asset_id: AssetId,
    /// The minimum burn amount stored by [`MinBurnAmount`]. Defaults to [`MIN_BURN_SIZE_FLOOR`]
    /// and is validated at construction.
    min_burn_amount: AssetAmount,
    /// The composed faucet extension.
    faucet_extension: XReserveFaucetExtension,
    /// The token policy manager version. Defaults to [`PolicyManagerVersion::V1`].
    policy_manager: PolicyManagerVersion,
}

#[bon]
impl XReserveStablecoinBuilder {
    // CONSTRUCTORS
    // --------------------------------------------------------------------------------------------

    /// Creates a builder from the initial `token_supply`, the
    /// `owner` (the seeded `ADMIN` member that gates every unmapped authority-gated procedure), the
    /// `attest_admin_holder`, `pauser_holder` and `unpauser_holder` seeded as the sole members of
    /// `ATTEST_ADMIN`, `DOM_PAUSER` and `DOM_UNPAUSER`, and the
    /// `blocklist_manager_holder` seeded as the sole member of `BLK_MANAGER` (the external
    /// transfer-blocklist administrator), the network `fee_parameters` and `fee_asset_id`, plus the
    /// BUILD-SEEDED `domain`. The domain is required because a faucet without it would ship a
    /// domain compare that reads an empty slot. `attesters` (default empty) are allowlisted at
    /// composition time.
    ///
    /// The faucet is NOT a parameter: it has a fixed identity — name `USDCx`, symbol
    /// [`USDCX_TOKEN_SYMBOL`], [`USDCX_DECIMALS`] decimals, the supply cap at [`AssetAmount::MAX`],
    /// and `is_max_supply_mutable(true)` — so the
    /// builder BUILDS it here from `token_supply`, and the cap, the mutability invariant, the
    /// decimals and the symbol are guaranteed BY CONSTRUCTION. There is no way to hand the
    /// builder an immutable or misconfigured faucet. The `xreserve` component is likewise not a
    /// parameter — there is exactly one valid value (the shipped MASM), so the builder assembles it
    /// via [`XReserveFaucetExtension`]. The active mint and burn policies are fixed by the
    /// composition. The burn policy reads `min_burn_amount` from [`MinBurnAmount`]; this builder
    /// input defaults to [`MIN_BURN_SIZE_FLOOR`].
    /// `policy_manager` selects the token policy manager and defaults to
    /// [`PolicyManagerVersion::V1`].
    ///
    /// # Errors
    ///
    /// [`XReserveStablecoinBuilderError::FaucetComposition`] if the supply parameters do not form a
    /// valid `FungibleFaucet`;
    /// [`XReserveStablecoinBuilderError::MinBurnSizeBelowFloor`] if `min_burn_amount` is below
    /// [`MIN_BURN_SIZE_FLOOR`] (the zero-floor invariant);
    /// [`XReserveStablecoinBuilderError::AttesterAllowlist`] if a key in `attesters` is listed
    /// twice.
    #[builder]
    pub fn new(
        token_supply: AssetAmount,
        owner: AccountId,
        attest_admin_holders: Vec<AccountId>,
        pauser_holders: Vec<AccountId>,
        unpauser_holders: Vec<AccountId>,
        blocklist_manager_holders: Vec<AccountId>,
        fee_parameters: FeeParameters,
        fee_asset_id: AssetId,
        domain: CircleDomain,
        #[builder(default)] attesters: Vec<PublicKey>,
        min_burn_amount: Option<AssetAmount>,
        #[builder(default)] policy_manager: PolicyManagerVersion,
    ) -> Result<Self, XReserveStablecoinBuilderError> {
        let min_burn_amount = min_burn_amount.unwrap_or(
            AssetAmount::new(MIN_BURN_SIZE_FLOOR)
                .expect("the shipped burn floor is a valid asset amount"),
        );
        if min_burn_amount.as_u64() < MIN_BURN_SIZE_FLOOR {
            return Err(XReserveStablecoinBuilderError::MinBurnSizeBelowFloor(
                min_burn_amount.as_u64(),
            ));
        }
        let faucet_extension = XReserveFaucetExtension::new(domain, &attesters)
            .map_err(XReserveStablecoinBuilderError::AttesterAllowlist)?;
        for (role, members) in [
            ("ATTEST_ADMIN", &attest_admin_holders),
            ("DOM_PAUSER", &pauser_holders),
            ("DOM_UNPAUSER", &unpauser_holders),
            ("BLK_MANAGER", &blocklist_manager_holders),
        ] {
            let mut seen = std::collections::BTreeSet::new();
            if !members.iter().all(|member| seen.insert(member)) {
                return Err(XReserveStablecoinBuilderError::DuplicateRoleMember { role });
            }
        }
        Ok(Self {
            faucet: build_usdcx_faucet(token_supply)?,
            owner,
            attest_admin_holders,
            pauser_holders,
            unpauser_holders,
            blocklist_manager_holders,
            fee_parameters,
            fee_asset_id,
            min_burn_amount,
            faucet_extension,
            policy_manager,
        })
    }
}

impl XReserveStablecoinBuilder {
    // BUILD / COMPOSE
    // --------------------------------------------------------------------------------------------

    /// Production composition: seeds the build-time domain,
    /// then composes the account components. The
    /// faucet's `max_supply` mutability is guaranteed by construction (the crate-root
    /// [`Self::build_account`] path builds the faucet `is_max_supply_mutable(true)`), so there is
    /// no runtime mutability reject. Public for the integration suite, which composes
    /// these components under a TEST auth account; [`Self::build_account`] is the production path.
    pub fn build_components(
        &self,
    ) -> Result<Vec<AccountComponent>, XReserveStablecoinBuilderError> {
        self.assert_roles_isolated()?;
        let manager = self.token_policy_manager()?;
        match self.policy_manager {
            PolicyManagerVersion::V1 => self.compose_components(manager),
            PolicyManagerVersion::V2 => self.compose_components(TokenPolicyManagerV2::new(manager)),
        }
    }

    /// Asserts that the `BLK_MANAGER` and `DOM_PAUSER` holders hold no other role.
    fn assert_roles_isolated(&self) -> Result<(), XReserveStablecoinBuilderError> {
        let overlaps = |left: &[AccountId], right: &[AccountId]| {
            left.iter().any(|member| right.contains(member))
        };
        let owner_holders = [self.owner];
        // No BLK_MANAGER member may hold any other role.
        for (collides_with, holders) in [
            ("ADMIN", owner_holders.as_slice()),
            ("ATTEST_ADMIN", &self.attest_admin_holders),
            ("DOM_PAUSER", &self.pauser_holders),
            ("DOM_UNPAUSER", &self.unpauser_holders),
        ] {
            if overlaps(&self.blocklist_manager_holders, holders) {
                return Err(XReserveStablecoinBuilderError::BlocklistManagerNotIsolated {
                    collides_with,
                });
            }
        }
        // No DOM_PAUSER member may hold another role; BLK_MANAGER collisions were checked above.
        for (collides_with, holders) in [
            ("ADMIN", owner_holders.as_slice()),
            ("ATTEST_ADMIN", &self.attest_admin_holders),
            ("DOM_UNPAUSER", &self.unpauser_holders),
        ] {
            if overlaps(&self.pauser_holders, holders) {
                return Err(XReserveStablecoinBuilderError::PauserNotIsolated { collides_with });
            }
        }
        Ok(())
    }

    /// Composes the faucet components around the given token policy manager components.
    fn compose_components(
        &self,
        manager: impl IntoIterator<Item = AccountComponent>,
    ) -> Result<Vec<AccountComponent>, XReserveStablecoinBuilderError> {
        let mut components = Vec::new();
        components.push(self.faucet.clone().into());
        components.push(Pausable::unpaused().into());
        components.extend(manager);
        components.push(PausableManager.into());
        components.push(BlocklistManager.into());
        components.push(ConstantFeeManager::for_basic_constant_fee_policy().into());
        components.push(UpgradeManager.into());
        components.push(seeded_dom_roles_rbac(
            self.owner,
            &self.attest_admin_holders,
            &self.pauser_holders,
            &self.unpauser_holders,
            &self.blocklist_manager_holders,
        ));
        components.push(XReserveAdminAuthority::new().into());
        Ok(components)
    }

    /// Returns the token policy manager with the attestation mint policy, the burn policy and the
    /// transfer blocklist.
    fn token_policy_manager(&self) -> Result<TokenPolicyManager, XReserveStablecoinBuilderError> {
        let xreserve_component = AccountComponent::from(self.faucet_extension.clone());
        let burn_policy_component = Self::burn_policy_component();
        let burn_root = burn_policy_component
            .get_procedure_root_by_path(XRESERVE_BURN_POLICY_PROC_PATH)
            .ok_or(XReserveStablecoinBuilderError::BurnPolicyProcNotFound)?;

        Ok(TokenPolicyManager::builder()
            .active_mint_policy(
                MintPolicy::custom(
                    xreserve_component
                        .get_procedure_root_by_path(ATTESTATION_MINT_POLICY_PROC_PATH)
                        .ok_or(XReserveStablecoinBuilderError::AttestationPolicyProcNotFound)?,
                    [xreserve_component],
                )
                .map_err(XReserveStablecoinBuilderError::MintPolicy)?,
            )
            .active_burn_policy(BurnPolicy::custom(
                burn_root,
                [burn_policy_component, MinBurnAmount::new(self.min_burn_amount).into()],
            )?)
            .active_send_policy(TransferPolicy::empty_basic_blocklist())
            .active_receive_policy(TransferPolicy::empty_basic_blocklist())
            .build())
    }
}

/// Seeds `ADMIN` with its sole owner and the four operational roles with their configured
/// members (possibly none) and direct `ADMIN` administration.
/// Construction failures are invariants, so this mirrors the stock `.expect()` pattern.
fn seeded_dom_roles_rbac(
    owner: AccountId,
    attest_admin_holders: &[AccountId],
    pauser_holders: &[AccountId],
    unpauser_holders: &[AccountId],
    blocklist_manager_holders: &[AccountId],
) -> AccountComponent {
    let pauser =
        RoleSymbol::new(DOM_PAUSER_ROLE).expect("DOM_PAUSER is a fixed valid role symbol (≤12)");
    let attest_admin = RoleSymbol::new(ATTEST_ADMIN_ROLE)
        .expect("ATTEST_ADMIN is a fixed valid role symbol (≤12)");
    let unpauser = RoleSymbol::new(DOM_UNPAUSER_ROLE)
        .expect("DOM_UNPAUSER is a fixed valid role symbol (≤12)");
    let blk_manager =
        RoleSymbol::new(BLK_MANAGER_ROLE).expect("BLK_MANAGER is a fixed valid role symbol (≤12)");
    let admin = RoleBasedAccessControl::admin_role();

    // The stock RBAC builder rejects a zero-member role declaration, so an empty role is simply
    // not declared: it materializes when its first member is granted through the standard
    // role-action note, and its administration resolves to the built-in `ADMIN` either way.
    let mut builder = RoleBasedAccessControl::builder();
    for (role, holders) in [
        (pauser, pauser_holders),
        (attest_admin, attest_admin_holders),
        (unpauser, unpauser_holders),
        (blk_manager, blocklist_manager_holders),
    ] {
        if !holders.is_empty() {
            builder = builder.role(RoleConfig::new(role).with_members(holders.iter().copied()));
        }
    }
    builder
        .role(RoleConfig::new(admin).with_member(owner))
        .build()
        .expect("the seeded DOM-roles RBAC configuration should be valid")
        .into()
}