Skip to main content

miden_usdcx/account/xreserve/builder/
construction.rs

1//! Faucet-account CONSTRUCTION: the faucet-extension component type, the fixed-identity USDCx
2//! faucet, the crate-root `Account` constructor, and [`XReserveStablecoinBuilder::build_account`].
3//!
4//! Split out of `builder/mod.rs` (which composes the component SET) so the two separable concerns —
5//! composing the components vs. turning them into the deployable `Account` — live apart and each
6//! file stays within the Rust file-size ceiling.
7
8use miden_protocol::account::component::{AccountComponentCode, AccountComponentMetadata};
9use miden_protocol::account::{
10    Account,
11    AccountCode,
12    AccountComponent,
13    AccountId,
14    AccountStorage,
15    AccountType,
16    StorageMap,
17    StorageMapKey,
18    StorageSlot,
19    StorageSlotName,
20};
21use miden_protocol::asset::{AssetAmount, AssetCallbacks, AssetId, TokenSymbol};
22use miden_protocol::block::FeeParameters;
23use miden_protocol::crypto::dsa::ecdsa_k256_keccak::PublicKey;
24use miden_protocol::errors::{AccountError, StorageMapError};
25use miden_protocol::utils::sync::LazyLock;
26use miden_protocol::vm::Package;
27use miden_protocol::{Felt, Word};
28use miden_standards::account::faucets::{FungibleFaucet, TokenName};
29use miden_standards::account::fees::FeePolicyManager;
30use miden_standards::account::policies::{TokenPolicyManager, TokenPolicyManagerV2};
31
32use super::{
33    PolicyManagerVersion,
34    USDCX_DECIMALS,
35    USDCX_TOKEN_SYMBOL,
36    XReserveStablecoinBuilder,
37    XReserveStablecoinBuilderError,
38};
39use crate::xreserve::encoding::{CircleDomain, DepositNonce};
40
41// CONSTANTS
42// ================================================================================================
43
44/// The metadata label the assembled `xreserve` component carries. It is a build-time label only —
45/// the account's code commitment is over the procedure roots and its storage over the slot values,
46/// neither of which depends on this string (the byte-identity suite proves it).
47const XRESERVE_COMPONENT_LABEL: &str = "xusdc-xreserve";
48const BURN_POLICY_COMPONENT_LABEL: &str = "xusdc-burn-policy";
49
50/// The allowlist row the attestation check accepts (the MASM `ATTESTER_ENABLED_MARKER`).
51const ATTESTER_ENABLED_MARKER: [u32; 4] = [1, 0, 0, 0];
52
53/// The registry row the mint policy writes for a consumed nonce (the MASM `NONCE_USED_MARKER`).
54const NONCE_USED_MARKER: [u32; 4] = [1, 0, 0, 0];
55
56/// What the faucet adds on top of the stock fungible faucet, assembled at build time from
57/// `asm/components/faucet_extension/`: the attestation mint policy and the attester allowlist
58/// setter.
59static FAUCET_EXTENSION_CODE: LazyLock<AccountComponentCode> = LazyLock::new(|| {
60    AccountComponentCode::from(
61        Package::read_from_bytes_trusted(include_bytes!(concat!(
62            env!("OUT_DIR"),
63            "/assets/components/xreserve-faucet-extension.masp"
64        )))
65        .expect("the shipped account-component package deserializes"),
66    )
67});
68
69static BURN_POLICY_CODE: LazyLock<AccountComponentCode> = LazyLock::new(|| {
70    AccountComponentCode::from(
71        Package::read_from_bytes_trusted(include_bytes!(concat!(
72            env!("OUT_DIR"),
73            "/assets/components/xreserve-faucet-burn-policy.masp"
74        )))
75        .expect("the shipped burn-policy package deserializes"),
76    )
77});
78
79static DOMAIN_CONFIG_SLOT_NAME: LazyLock<StorageSlotName> = LazyLock::new(|| {
80    StorageSlotName::new("xusdc::xreserve::domain_config::domain")
81        .expect("storage slot name should be valid")
82});
83
84/// The nonce registry the replay guard reads and the mint path writes.
85static USED_NONCES_SLOT_NAME: LazyLock<StorageSlotName> = LazyLock::new(|| {
86    StorageSlotName::new("xusdc::xreserve::nonce_registry::used_nonces")
87        .expect("storage slot name should be valid")
88});
89
90/// The attester allowlist: the attestation check reads it and the `set_attester` admin path writes
91/// it, so the two co-own the same slot.
92static XRESERVE_ATTESTERS_SLOT_NAME: LazyLock<StorageSlotName> = LazyLock::new(|| {
93    StorageSlotName::new("xusdc::xreserve::attester_admin::xreserve_attesters")
94        .expect("storage slot name should be valid")
95});
96
97/// The xUSDC faucet's extension of the stock [`FungibleFaucet`] component:
98/// - the attestation-gated mint policy
99/// - the attester administration
100#[derive(Debug, Clone)]
101pub struct XReserveFaucetExtension {
102    domain: CircleDomain,
103    attesters: StorageMap,
104}
105
106impl XReserveFaucetExtension {
107    /// Instantiates a new [`XReserveFaucetExtension`] for `domain` with every key in `attesters`
108    /// allowlisted under its [`PublicKey::to_commitment`], so the faucet accepts their
109    /// attestations from its first block.
110    ///
111    /// # Errors
112    ///
113    /// [`StorageMapError::DuplicateKey`] if a key is listed twice.
114    pub fn new(domain: CircleDomain, attesters: &[PublicKey]) -> Result<Self, StorageMapError> {
115        let attesters = StorageMap::with_entries(attesters.iter().map(|key| {
116            (StorageMapKey::new(key.to_commitment()), Word::from(ATTESTER_ENABLED_MARKER))
117        }))?;
118        Ok(Self { domain, attesters })
119    }
120
121    // PUBLIC ACCESSORS
122    // --------------------------------------------------------------------------------------------
123
124    /// Returns the [`AccountComponentCode`] of this component.
125    pub fn code() -> &'static AccountComponentCode {
126        &FAUCET_EXTENSION_CODE
127    }
128
129    /// Returns the [`StorageSlotName`] holding the faucet's own Circle domain id.
130    pub fn domain_config_slot() -> &'static StorageSlotName {
131        &DOMAIN_CONFIG_SLOT_NAME
132    }
133
134    /// Returns the [`StorageSlotName`] of the consumed-nonce registry map.
135    pub fn used_nonces_slot() -> &'static StorageSlotName {
136        &USED_NONCES_SLOT_NAME
137    }
138
139    /// Returns the [`StorageSlotName`] of the attester allowlist map.
140    pub fn xreserve_attesters_slot() -> &'static StorageSlotName {
141        &XRESERVE_ATTESTERS_SLOT_NAME
142    }
143}
144
145impl From<XReserveFaucetExtension> for AccountComponent {
146    fn from(faucet_ext: XReserveFaucetExtension) -> Self {
147        AccountComponent::new(
148            FAUCET_EXTENSION_CODE.clone(),
149            vec![
150                StorageSlot::with_value(
151                    XReserveFaucetExtension::domain_config_slot().clone(),
152                    Word::from([faucet_ext.domain.as_u32(), 0, 0, 0]),
153                ),
154                StorageSlot::with_empty_map(XReserveFaucetExtension::used_nonces_slot().clone()),
155                StorageSlot::with_map(
156                    XReserveFaucetExtension::xreserve_attesters_slot().clone(),
157                    faucet_ext.attesters,
158                ),
159            ],
160            AccountComponentMetadata::new(XRESERVE_COMPONENT_LABEL),
161        )
162        .expect("the faucet extension binds with its three declared slots")
163    }
164}
165
166impl XReserveStablecoinBuilder {
167    /// Builds the burn policy component. It reads the minimum burn amount from the storage slot
168    /// owned by `MinBurnAmount` and has no storage slots of its own.
169    pub fn burn_policy_component() -> AccountComponent {
170        AccountComponent::new(
171            BURN_POLICY_CODE.clone(),
172            vec![],
173            AccountComponentMetadata::new(BURN_POLICY_COMPONENT_LABEL),
174        )
175        .expect("the burn policy binds with no storage slots")
176    }
177
178    /// Builds the final composed faucet [`Account`] from `init_seed`: [`Self::build_components`]
179    /// plus the production keyless-network `AuthNetworkAccount` auth component
180    /// ([`Self::auth_component`]), assembled as `AccountType::Public`, with asset
181    /// callbacks enabled iff the composition installs the transfer-policy callback slots (it does:
182    /// xUSDC is a policed asset).
183    ///
184    /// This entry is byte-identical to composing the components and auth component with the same
185    /// seed.
186    pub fn build_account(
187        &self,
188        init_seed: [u8; 32],
189    ) -> Result<Account, XReserveStablecoinBuilderError> {
190        let components = self.build_components()?;
191        let mut builder = Account::builder(init_seed).account_type(AccountType::Public);
192        for component in components {
193            builder = builder.with_component(component);
194        }
195        builder = builder
196            .with_components(Self::auth_component(self.fee_parameters.clone(), self.fee_asset_id)?);
197        builder.build().map_err(XReserveStablecoinBuilderError::AccountComposition)
198    }
199
200    /// Builds the faucet for inclusion in a genesis block.
201    /// The account is created with a nonce of one and its own ID as the fee asset. Consumed
202    /// deposit nonces are recorded afterwards with [`record_used_nonces`].
203    ///
204    /// # Warning
205    ///
206    /// The returned account can only be added at genesis. With nonce one and no seed it cannot be
207    /// deployed in a transaction.
208    pub fn build_genesis_account(
209        &self,
210        init_seed: [u8; 32],
211    ) -> Result<Account, XReserveStablecoinBuilderError> {
212        let account = self.build_account(init_seed)?;
213        let fee_asset_id = AssetId::new_fungible(account.id());
214        let (id, vault, mut storage, code, _nonce, _seed) = account.into_parts();
215        storage
216            .set_item(FeePolicyManager::fee_asset_id_slot(), fee_asset_id.to_word())
217            .map_err(XReserveStablecoinBuilderError::AccountComposition)?;
218        Account::new(id, vault, storage, code, Felt::ONE, None)
219            .map_err(XReserveStablecoinBuilderError::AccountComposition)
220    }
221}
222
223/// Records every nonce in `used_nonces` as consumed in the genesis faucet `account` and returns
224/// it in genesis form (nonce one, no seed). Recording a nonce twice is a no-op.
225///
226/// # Errors
227///
228/// [`XReserveStablecoinBuilderError::AccountComposition`] if `account` has no nonce registry
229/// slot, i.e. it is not an xUSDC faucet.
230///
231/// # Warning
232///
233/// The returned account can only be added at genesis. With nonce one and no seed it cannot be
234/// deployed in a transaction.
235pub fn record_used_nonces(
236    account: Account,
237    used_nonces: &[DepositNonce],
238) -> Result<Account, XReserveStablecoinBuilderError> {
239    let (id, vault, mut storage, code, _nonce, _seed) = account.into_parts();
240    for nonce in used_nonces {
241        storage
242            .set_map_item(
243                XReserveFaucetExtension::used_nonces_slot(),
244                nonce.to_storage_map_key(),
245                Word::from(NONCE_USED_MARKER),
246            )
247            .map_err(XReserveStablecoinBuilderError::AccountComposition)?;
248    }
249    Account::new(id, vault, storage, code, Felt::ONE, None)
250        .map_err(XReserveStablecoinBuilderError::AccountComposition)
251}
252
253/// Upgrades the existing faucet `account` from the [`TokenPolicyManager`] to the
254/// [`TokenPolicyManagerV2`], e.g. a faucet built for genesis.
255///
256/// The account keeps its ID, vault, nonce and storage, except that its code is replaced and the
257/// asset callback slots point at the V2 callbacks. A faucet built with
258/// [`PolicyManagerVersion::V2`] gets a different ID instead, because the ID commits to the code.
259///
260/// # Errors
261///
262/// [`XReserveStablecoinBuilderError::AccountComposition`] if:
263/// - `account` does not have the V1 manager's `invoke_send_policy` and `invoke_receive_policy`
264///   procedures.
265/// - the asset callback slots are missing.
266/// - `account` is new: its seed derives the ID from the V1 code.
267pub fn upgrade_to_token_policy_manager_v2(
268    account: Account,
269) -> Result<Account, XReserveStablecoinBuilderError> {
270    let has_v1_manager = [
271        TokenPolicyManager::invoke_send_policy_root(),
272        TokenPolicyManager::invoke_receive_policy_root(),
273    ]
274    .into_iter()
275    .all(|procedure_root| account.code().has_procedure(procedure_root.as_word()));
276    if !has_v1_manager {
277        return Err(XReserveStablecoinBuilderError::AccountComposition(AccountError::other(
278            "the account is not a faucet with the V1 token policy manager",
279        )));
280    }
281    let v2_code = faucet_code(account.id(), PolicyManagerVersion::V2)?;
282
283    let (id, vault, mut storage, _code, nonce, seed) = account.into_parts();
284    set_v2_callback_slots(&mut storage)
285        .map_err(XReserveStablecoinBuilderError::AccountComposition)?;
286    Account::new(id, vault, storage, v2_code, nonce, seed)
287        .map_err(XReserveStablecoinBuilderError::AccountComposition)
288}
289
290/// Points the asset callback slots in `storage` at the [`TokenPolicyManagerV2`] callbacks.
291fn set_v2_callback_slots(storage: &mut AccountStorage) -> Result<(), AccountError> {
292    let callback_mappings = [
293        (
294            AssetCallbacks::on_before_asset_added_to_note_slot(),
295            TokenPolicyManagerV2::invoke_send_policy_root(),
296        ),
297        (
298            AssetCallbacks::on_before_asset_added_to_account_slot(),
299            TokenPolicyManagerV2::invoke_receive_policy_root(),
300        ),
301    ];
302
303    for (slot_name, callback_root) in callback_mappings {
304        storage.set_item(slot_name, callback_root.as_word())?;
305    }
306
307    Ok(())
308}
309
310/// Returns the code of an xUSDC faucet with the token policy manager `version`.
311///
312/// The code depends only on the components and not on their configuration, so a minimal
313/// configuration builds it.
314fn faucet_code(
315    faucet_id: AccountId,
316    version: PolicyManagerVersion,
317) -> Result<AccountCode, XReserveStablecoinBuilderError> {
318    let account = XReserveStablecoinBuilder::builder()
319        .token_supply(AssetAmount::ZERO)
320        .owner(faucet_id)
321        .attest_admin_holders(Vec::new())
322        .pauser_holders(Vec::new())
323        .unpauser_holders(Vec::new())
324        .blocklist_manager_holders(Vec::new())
325        .fee_parameters(FeeParameters::new(0))
326        .fee_asset_id(AssetId::new_fungible(faucet_id))
327        .domain(CircleDomain::new(0))
328        .policy_manager(version)
329        .build()?
330        .build_account([0; 32])?;
331    Ok(account.code().clone())
332}
333
334/// Crate-root constructor for the final xUSDC faucet [`Account`]: builds the fixed-identity USDCx
335/// faucet (`is_max_supply_mutable(true)` — the mutability invariant enforced BY CONSTRUCTION rather
336/// than a runtime reject), then composes it into the attestation-gated keyless network account via
337/// [`XReserveStablecoinBuilder`]. It is the single entry point that turns deploy parameters into
338/// the deployable account, so account construction is traceable from the library root (the agglayer
339/// `create_bridge_account` pattern). `init_seed` seeds the account id. The optional inputs (the
340/// min-burn floor and the build-seeded attesters) keep their defaults here and are set through
341/// [`XReserveStablecoinBuilder::builder`].
342#[allow(clippy::too_many_arguments)]
343pub fn build_faucet_account(
344    init_seed: [u8; 32],
345    token_supply: AssetAmount,
346    owner: AccountId,
347    attest_admin_holders: Vec<AccountId>,
348    pauser_holders: Vec<AccountId>,
349    unpauser_holders: Vec<AccountId>,
350    blocklist_manager_holders: Vec<AccountId>,
351    fee_parameters: FeeParameters,
352    fee_asset_id: AssetId,
353    domain: CircleDomain,
354) -> Result<Account, XReserveStablecoinBuilderError> {
355    XReserveStablecoinBuilder::builder()
356        .token_supply(token_supply)
357        .owner(owner)
358        .attest_admin_holders(attest_admin_holders)
359        .pauser_holders(pauser_holders)
360        .unpauser_holders(unpauser_holders)
361        .blocklist_manager_holders(blocklist_manager_holders)
362        .fee_parameters(fee_parameters)
363        .fee_asset_id(fee_asset_id)
364        .domain(domain)
365        .build()?
366        .build_account(init_seed)
367}
368
369/// Builds the fixed-identity USDCx [`FungibleFaucet`]: name `USDCx`, symbol [`USDCX_TOKEN_SYMBOL`],
370/// [`USDCX_DECIMALS`] decimals, the supply cap at [`AssetAmount::MAX`], and
371/// `is_max_supply_mutable(true)` so the deployed `set_max_supply`
372/// stays operable. The identity fields are constants (the `.expect`s are invariants); setting the
373/// mutability flag here is what guarantees it by construction, replacing the removed runtime
374/// reject.
375pub(super) fn build_usdcx_faucet(
376    token_supply: AssetAmount,
377) -> Result<FungibleFaucet, XReserveStablecoinBuilderError> {
378    FungibleFaucet::builder()
379        .name(TokenName::new("USDCx").expect("USDCx is a valid token name"))
380        .symbol(TokenSymbol::new(USDCX_TOKEN_SYMBOL).expect("the USDCX symbol constant is valid"))
381        .decimals(USDCX_DECIMALS)
382        .max_supply(AssetAmount::MAX)
383        .token_supply(token_supply)
384        .is_max_supply_mutable(true)
385        .build()
386        .map_err(XReserveStablecoinBuilderError::FaucetComposition)
387}