Skip to main content

miden_usdcx/note/
xreserve_mint.rs

1//! `XUsdcMintNote`: builds the note that carries a Circle-attested deposit to the faucet.
2//!
3//! The note is a standard [`MintNote`] driving the standard `mint_and_send`, everything specific
4//! to xUSDC is in attachments. The authorization decision lives entirely in the faucet's mint
5//! policy.
6//!
7//! - The mint-note storage holds the output note's pay-to-id recipe: target = the intent's
8//!   `remoteRecipient`, serial = the key derived from the deposit nonce, asset = the reduced
9//!   attested amount, tag = the attested recipient.
10//! - Two attachments travel with it. Scheme 4 is the whole transport in one attachment: the
11//!   attestation as nine words — attester public key (16 felts), signature (17), 3 padding felts,
12//!   in the order the policy reads them — and then the carried mint payload, zero-padded to a word
13//!   boundary. The policy re-derives the true felt length from the payload's own `hookDataLen`, so
14//!   the padding cannot hide extra data. Scheme 2 routes the note to the faucet's network account.
15//!
16//!   The attestation comes FIRST because it is fixed-width: that keeps the payload's starting
17//!   offset a constant instead of a function of `hookDataLen`, which is what lets the policy read
18//!   every sub-region at a constant offset. The Circle-signed DepositIntent itself does not
19//!   travel — the faucet rebuilds it from the carried payload plus its own state.
20
21use miden_protocol::account::AccountId;
22use miden_protocol::asset::FungibleAsset;
23use miden_protocol::crypto::SequentialCommit;
24use miden_protocol::crypto::dsa::ecdsa_k256_keccak::PublicKey;
25use miden_protocol::crypto::rand::FeltRng;
26use miden_protocol::errors::NoteError;
27use miden_protocol::note::{
28    Note,
29    NoteAttachment,
30    NoteAttachmentScheme,
31    NoteScript,
32    NoteScriptRoot,
33    NoteTag,
34};
35use miden_protocol::{Felt, WORD_SIZE, Word};
36use miden_standards::note::{
37    MintNote,
38    MintNoteStorage,
39    NetworkAccountTarget,
40    NoteExecutionHint,
41    P2idNoteStorage,
42    StandardNoteAttachment,
43};
44
45use crate::xreserve::encoding::{
46    BYTES_PER_PACKED_FELT,
47    CircleDomain,
48    DepositIntent,
49    DepositIntentHeader,
50    DepositNonce,
51    MintIntent,
52    Signature,
53};
54
55/// The mint-note transport attachment scheme, see [`StandardNoteAttachment::UsdcxMint`].
56/// This attachment carries both the attestation and the DepositIntent preimage.
57pub const XUSDC_MINT_TRANSPORT_ATTACHMENT_SCHEME: u16 =
58    StandardNoteAttachment::UsdcxMint.attachment_scheme().as_u16();
59
60/// The attestation section word count: `[pubkey(16), signature(17), pad(3)]` = 36 felts (the
61/// pubkey is the 16-felt affine form). The operator `feeAmount` is not carried at all — the faucet
62/// writes a zero fee into the preimage it rebuilds, so a non-zero one is inexpressible.
63pub const XUSDC_MINT_ATTESTATION_NUM_WORDS: usize = 9;
64
65/// Word offset of the carried mint payload inside the transport attachment: past the fixed-width
66/// attestation. Constant by construction — see the module docs on why the attestation goes first.
67pub const XUSDC_MINT_TRANSPORT_PAYLOAD_WORD_OFF: usize = XUSDC_MINT_ATTESTATION_NUM_WORDS;
68
69/// How much hookData this transport can carry: the protocol's per-attachment ceiling less the fixed
70/// prefix (the attestation section and the carried payload), in bytes. Everything past that prefix
71/// is packed hookData. Deposits with more data than this cannot be processed.
72///
73/// This is what bounds hookData — see [`HookData::MAX_LEN`], which is defined as this value.
74///
75/// [`HookData::MAX_LEN`]: crate::xreserve::encoding::HookData::MAX_LEN
76pub const XUSDC_MINT_TRANSPORT_HOOK_DATA_MAX_LEN: usize = (NoteAttachment::MAX_NUM_WORDS as usize
77    * WORD_SIZE
78    - XUSDC_MINT_ATTESTATION_NUM_WORDS * WORD_SIZE
79    - MintIntent::NUM_FELTS)
80    * BYTES_PER_PACKED_FELT;
81
82/// The Circle deposit attestation crossing the note boundary.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct DepositAttestation {
85    signature: Signature,
86    pubkey: PublicKey,
87}
88
89impl DepositAttestation {
90    /// Bundles the signature with the candidate attester key.
91    pub fn new(signature: Signature, pubkey: PublicKey) -> Self {
92        Self { signature, pubkey }
93    }
94
95    /// The signature.
96    pub fn signature(&self) -> Signature {
97        self.signature
98    }
99
100    /// The candidate attester key.
101    pub fn pubkey(&self) -> &PublicKey {
102        &self.pubkey
103    }
104}
105
106/// The note storage of the [`XUsdcMintNote`] derived from a [`DepositIntent`].
107pub struct XUsdcMintNoteStorage {
108    storage: MintNoteStorage,
109}
110
111impl XUsdcMintNoteStorage {
112    /// Derives the mint-note storage from the decoded `intent` and the target faucet.
113    pub fn new(intent_header: &DepositIntentHeader, target: AccountId) -> Self {
114        let recipient_id = intent_header.remote_recipient();
115        let asset = FungibleAsset::new(target, intent_header.amount().as_u64())
116            .expect("asset amount should be valid");
117        let serial_num = intent_header.nonce().to_word();
118        let recipient = P2idNoteStorage::new(recipient_id).into_recipient(serial_num);
119        let tag = NoteTag::with_account_target(recipient_id);
120        let storage = MintNoteStorage::new_public(recipient, asset, tag)
121            .expect("p2id note storage should not exceed max number of storage items");
122
123        Self { storage }
124    }
125
126    /// A read-only view of the derived stock mint storage.
127    pub fn as_mint_storage(&self) -> &MintNoteStorage {
128        &self.storage
129    }
130
131    /// Consumes into the stock [`MintNoteStorage`] the [`MintNote`] builder installs.
132    pub fn into_mint_storage(self) -> MintNoteStorage {
133        self.storage
134    }
135}
136
137/// The Circle deposit a mint note carries: the decoded [`MintIntent`] together with the
138/// [`DepositAttestation`] that authorizes it.
139///
140/// This is the scheme-4 transport attachment in domain form. It converts into the
141/// [`NoteAttachment`] the faucet hash-verifies, the way the standards [`NetworkAccountTarget`]
142/// converts into the scheme-2 routing one.
143pub struct XUsdcDeposit {
144    intent: MintIntent,
145    attestation: DepositAttestation,
146}
147
148impl XUsdcDeposit {
149    /// Bundles a decoded intent with the attestation over the payload it was decoded from.
150    pub fn new(intent: MintIntent, attestation: DepositAttestation) -> Self {
151        Self { intent, attestation }
152    }
153
154    /// The carried mint payload.
155    pub fn intent(&self) -> &MintIntent {
156        &self.intent
157    }
158
159    /// The attestation travelling beside it.
160    pub fn attestation(&self) -> &DepositAttestation {
161        &self.attestation
162    }
163}
164
165impl From<&XUsdcDeposit> for NoteAttachment {
166    /// Builds the transport attachment — everything the faucet needs to rebuild and verify the
167    /// Circle-signed message, in three sections:
168    ///
169    /// 1. the [`DepositAttestation`].
170    /// 2. the [`MintIntent`].
171    /// 3. the [`HookData`](crate::xreserve::encoding::HookData).
172    fn from(deposit: &XUsdcDeposit) -> Self {
173        let mut elements: Vec<Felt> = Vec::new();
174
175        elements.extend(deposit.attestation.pubkey().to_elements());
176        elements.extend(deposit.attestation.signature().to_elements());
177        elements.extend([Felt::ZERO; 3]);
178        debug_assert_eq!(elements.len(), XUSDC_MINT_TRANSPORT_PAYLOAD_WORD_OFF * 4);
179
180        elements.extend(deposit.intent.to_elements());
181        while !elements.len().is_multiple_of(Word::NUM_ELEMENTS) {
182            elements.push(Felt::ZERO);
183        }
184
185        let words: Vec<Word> = elements
186            .as_chunks::<{ Word::NUM_ELEMENTS }>()
187            .0
188            .iter()
189            .map(|chunk| Word::new(*chunk))
190            .collect();
191        NoteAttachment::with_words(
192            NoteAttachmentScheme::new(XUSDC_MINT_TRANSPORT_ATTACHMENT_SCHEME)
193                .expect("the transport scheme is neither reserved nor past the protocol maximum"),
194            words,
195        )
196        // the compile-time assertion above ties MAX_HOOK_DATA_LEN to this transport's capacity, and
197        // the carried hookData is bounded by it, so the assembled words always fit
198        .expect("the hookData bound keeps the transport within the per-attachment word cap")
199    }
200}
201
202/// The mint note carrying the xUSDC attested transport: a [`MintNote`] whose two attachments are
203/// the [`XUsdcDeposit`] and the [`NetworkAccountTarget`].
204pub struct XUsdcMintNote {
205    sender: AccountId,
206    storage: XUsdcMintNoteStorage,
207    serial_number: Word,
208    deposit: XUsdcDeposit,
209    network_account_target: NetworkAccountTarget,
210}
211
212impl XUsdcMintNote {
213    /// The [`MintNote`] script the transport rides on.
214    pub fn script() -> NoteScript {
215        MintNote::script()
216    }
217
218    /// The [`MintNote`] script root.
219    pub fn script_root() -> NoteScriptRoot {
220        MintNote::script_root()
221    }
222
223    /// The Circle deposit the note carries.
224    pub fn deposit(&self) -> &XUsdcDeposit {
225        &self.deposit
226    }
227
228    /// The nonce of the carried deposit — the key of the faucet's replay guard.
229    pub fn nonce(&self) -> DepositNonce {
230        self.deposit.intent().nonce()
231    }
232}
233
234#[bon::bon]
235impl XUsdcMintNote {
236    /// Builds the mint note.
237    ///
238    /// The `sender` is the relayer account and `target` the consuming faucet. The `remote_domain`
239    /// must match the configured remote domain in the target faucet.
240    ///
241    /// # Errors
242    ///
243    /// Returns an error if:
244    /// - the intent is addressed to another target faucet or another domain
245    /// - if `target` is not a public account.
246    #[builder]
247    pub fn new(
248        sender: AccountId,
249        target: AccountId,
250        remote_domain: CircleDomain,
251        deposit_intent: DepositIntent,
252        attestation: DepositAttestation,
253        serial_number: Word,
254    ) -> Result<Self, NoteError> {
255        let mint_intent = MintIntent::from_deposit_intent(&deposit_intent, target, remote_domain)
256            .map_err(|source| {
257            NoteError::other_with_source(
258                "deposit intent cannot be carried by the mint transport",
259                source,
260            )
261        })?;
262
263        let network_account_target = NetworkAccountTarget::new(target, NoteExecutionHint::Always)
264            .map_err(|err| {
265            NoteError::other_with_source("faucet id is not a public network account", err)
266        })?;
267
268        let storage = XUsdcMintNoteStorage::new(deposit_intent.header(), target);
269
270        Ok(Self {
271            sender,
272            storage,
273            serial_number,
274            deposit: XUsdcDeposit::new(mint_intent, attestation),
275            network_account_target,
276        })
277    }
278}
279
280// BUILDER EXTENSIONS
281// ================================================================================================
282
283impl<S: x_usdc_mint_note_builder::State> XUsdcMintNoteBuilder<S>
284where
285    S::SerialNumber: x_usdc_mint_note_builder::IsUnset,
286{
287    /// Draws a serial number from `rng` and sets it on the builder.
288    pub fn generate_serial_number(
289        self,
290        rng: &mut impl FeltRng,
291    ) -> XUsdcMintNoteBuilder<x_usdc_mint_note_builder::SetSerialNumber<S>> {
292        self.serial_number(rng.draw_word())
293    }
294}
295
296// CONVERSIONS
297// ================================================================================================
298
299impl From<XUsdcMintNote> for Note {
300    /// Creates the stock [`MintNote`] and converts it into a protocol [`Note`].
301    fn from(note: XUsdcMintNote) -> Self {
302        MintNote::builder()
303            .sender(note.sender)
304            .mint_storage(note.storage.into_mint_storage())
305            .serial_number(note.serial_number)
306            .attachment(&note.deposit)
307            .attachment(note.network_account_target)
308            .build()
309            .expect("two attachments are within the protocol's per-note limit")
310            .into()
311    }
312}