Skip to main content

miden_usdcx/note/
xreserve_burn.rs

1//! `XReserveBurnNote`: the Circle-facing public burn-event note.
2//!
3//! A withdrawing xUSDC holder creates this note carrying the burned xUSDC; Circle's off-chain
4//! withdrawal attester discovers it by its FIXED full-32-bit tag (`SyncNotes` exact-match) and
5//! reads its withdrawal-payload attachment `(destDomain, destRecipient)` to release
6//! USDC on the source chain.
7//!
8//! It is built as a standalone note factory. What it does reuse is the standard burn consume
9//! script, so consuming one of these notes runs `faucet::receive_and_burn` and the faucet's active
10//! burn policy exactly as any other burn would. The stock script reserves all eight `NoteStorage`
11//! items for the asset and asserts the stored asset equals the carried one, so this note keeps the
12//! stock 8-felt asset storage and carries its withdrawal payload in a scheme-tagged note
13//! ATTACHMENT instead. The note is forced public, carries the fixed xUSDC burn tag, and packs the
14//! payload with the shared codec so the listener decodes precisely what was encoded.
15//!
16//! The burn policy requires exactly two attachments: a routing target to the faucet and a
17//! withdrawal payload of three words. It checks the withdrawal content against the commitment in
18//! the note and accepts only destinations the off-chain withdrawal attester can pay out.
19
20use miden_protocol::account::AccountId;
21use miden_protocol::asset::{Asset, AssetAmount, FungibleAsset};
22use miden_protocol::crypto::rand::FeltRng;
23use miden_protocol::errors::NoteError;
24use miden_protocol::note::{
25    Note,
26    NoteAssets,
27    NoteAttachment,
28    NoteAttachmentScheme,
29    NoteAttachments,
30    NoteRecipient,
31    NoteScript,
32    NoteScriptRoot,
33    NoteStorage,
34    NoteTag,
35    NoteType,
36    PartialNoteMetadata,
37};
38use miden_standards::note::{BurnNote, StandardNoteAttachment};
39
40use crate::xreserve::encoding::{EncodingError, XReserveBurnItems};
41
42/// The fixed tag every xUSDC burn note carries — ASCII `"BURN"`.
43///
44/// The off-chain listener discovers burn notes by asking the node for this exact 32-bit value, so
45/// it has to be a constant shared by every burn note rather than anything per-account. Its low 18
46/// bits are non-zero, which means it can never be mistaken for an account-target tag: those are
47/// built with the low 18 bits zeroed. It identifies a use case, not a destination.
48///
49/// The specific value is provisional and awaits Circle's confirmation — it is not a value Circle
50/// has assigned.
51pub const FIXED_XUSDC_BURN_TAG: u32 = 0x4255_524e;
52
53/// The withdrawal-payload attachment scheme, see [`StandardNoteAttachment::UsdcxBurn`].
54pub const XRESERVE_BURN_WITHDRAWAL_ATTACHMENT_SCHEME: u16 =
55    StandardNoteAttachment::UsdcxBurn.attachment_scheme().as_u16();
56
57/// The withdrawal payload a burn note carries — the [`XReserveBurnItems`] the off-chain attester
58/// decodes.
59///
60/// This is the withdrawal attachment in domain form. It converts into the
61/// [`NoteAttachment`] the note id commits to, the way [`XUsdcDeposit`] converts into the mint
62/// note's transport one.
63///
64/// [`XUsdcDeposit`]: crate::note::xreserve_mint::XUsdcDeposit
65pub struct XUsdcBurnAttachment {
66    items: XReserveBurnItems,
67}
68
69impl XUsdcBurnAttachment {
70    /// Word count of the withdrawal attachment.
71    pub const NUM_WORDS: usize = 3;
72
73    /// Wraps the withdrawal payload the burn note carries.
74    pub fn new(items: XReserveBurnItems) -> Self {
75        Self { items }
76    }
77
78    /// The carried withdrawal payload.
79    pub fn items(&self) -> &XReserveBurnItems {
80        &self.items
81    }
82
83    /// Consumes the attachment, returning the carried withdrawal payload.
84    pub fn into_items(self) -> XReserveBurnItems {
85        self.items
86    }
87}
88
89impl From<&XUsdcBurnAttachment> for NoteAttachment {
90    /// Builds the withdrawal attachment from the payload words.
91    ///
92    /// The encoding is the shared codec the off-chain attester decodes with, so the write and the
93    /// read side cannot drift apart.
94    fn from(attachment: &XUsdcBurnAttachment) -> Self {
95        NoteAttachment::with_words(
96            NoteAttachmentScheme::new(XRESERVE_BURN_WITHDRAWAL_ATTACHMENT_SCHEME)
97                .expect("the withdrawal scheme is neither reserved nor past the protocol maximum"),
98            attachment.items.encode().to_vec(),
99        )
100        // the payload has `NUM_WORDS` words, which the assertion below keeps within the cap
101        .expect("the withdrawal payload is within the per-attachment word cap")
102    }
103}
104
105impl TryFrom<&NoteAttachment> for XUsdcBurnAttachment {
106    type Error = EncodingError;
107
108    /// Decodes the withdrawal payload.
109    fn try_from(attachment: &NoteAttachment) -> Result<Self, Self::Error> {
110        if attachment.attachment_scheme().as_u16() != XRESERVE_BURN_WITHDRAWAL_ATTACHMENT_SCHEME {
111            return Err(EncodingError::BurnItemsMalformed);
112        }
113
114        let items = XReserveBurnItems::decode(attachment.content().as_words())?;
115        Ok(Self { items })
116    }
117}
118
119// the fixed word count is what makes the conversion above infallible.
120const _: () = assert!(
121    XUsdcBurnAttachment::NUM_WORDS <= NoteAttachment::MAX_NUM_WORDS as usize,
122    "the withdrawal payload must fit in one attachment"
123);
124
125/// The public burn-event note. A standalone unit-struct note factory.
126pub struct XReserveBurnNote;
127
128impl XReserveBurnNote {
129    /// Returns the (reused) stock burn note consume script — targets `faucet::receive_and_burn`.
130    pub fn script() -> NoteScript {
131        BurnNote::script()
132    }
133
134    /// Returns the (reused) stock burn note script root.
135    pub fn script_root() -> NoteScriptRoot {
136        BurnNote::script_root()
137    }
138
139    /// Convenience constructor over the [`XReserveBurnItems`] payload (a thin delegator to the
140    /// [`builder`](Self::builder)); retained because the frozen conformance suites pin this
141    /// signature.
142    pub fn create<R: FeltRng>(
143        sender: AccountId,
144        faucet_id: AccountId,
145        amount: AssetAmount,
146        items: XReserveBurnItems,
147        rng: &mut R,
148    ) -> Result<Note, NoteError> {
149        Self::builder()
150            .sender(sender)
151            .faucet_id(faucet_id)
152            .amount(amount)
153            .items(items)
154            .rng(rng)
155            .build()
156    }
157}
158
159#[bon::bon]
160impl XReserveBurnNote {
161    /// Builds an `XReserveBurnNote` via a `bon` builder
162    /// (`XReserveBurnNote::builder().sender(..).faucet_id(..).amount(..).items(..).rng(..).
163    /// build()`): `NoteType::Public`, the fixed xUSDC burn tag, `metadata.sender = sender` (the
164    /// depositor), `NoteAssets` = the burned xUSDC `FungibleAsset` (`amount` issued by
165    /// `faucet_id`), and `NoteStorage.items` = the stock 8-felt asset layout the stock burn
166    /// script asserts against. The `(destDomain, destRecipient)` withdrawal payload rides in a
167    /// scheme-tagged [`XUsdcBurnAttachment`]. The amount is supplied separately for the burned
168    /// asset.
169    #[builder]
170    pub fn new<R: FeltRng>(
171        sender: AccountId,
172        faucet_id: AccountId,
173        amount: AssetAmount,
174        items: XReserveBurnItems,
175        rng: &mut R,
176    ) -> Result<Note, NoteError> {
177        let serial_num = rng.draw_word();
178
179        let asset = FungibleAsset::new(faucet_id, u64::from(amount))
180            .map_err(|err| NoteError::other_with_source("invalid burned xUSDC asset", err))?;
181
182        // NoteStorage carries the STOCK 8-felt asset layout (ASSET_ID(4) + ASSET_VALUE(4)); the
183        // stock consume script requires exactly that and asserts the stored asset equals
184        // the carried one. The withdrawal payload no longer lives here — it rides in the
185        // attachment below.
186        let storage = NoteStorage::new(Asset::from(asset).as_elements().to_vec())?;
187        let recipient = NoteRecipient::new(serial_num, BurnNote::script(), storage);
188
189        // the burn note is always Public — there is no note_type parameter.
190        let metadata = PartialNoteMetadata::new(sender, NoteType::Public)
191            .with_tag(NoteTag::new(FIXED_XUSDC_BURN_TAG));
192
193        let vault = NoteAssets::new(vec![asset.into()])?;
194
195        // two attachments: the scheme-2 NetworkAccountTarget routing bind (routing only, as any
196        // faucet-targeted note carries), and the scheme-tagged withdrawal payload the off-chain
197        // attester decodes. The stock consume script ignores attachments, so the burn stays gated
198        // by receive_and_burn and the burn policy.
199        let attachments = NoteAttachments::new(vec![
200            super::network_routing_attachment(faucet_id)?,
201            NoteAttachment::from(&XUsdcBurnAttachment::new(items)),
202        ])?;
203
204        Ok(Note::with_attachments(vault, metadata, recipient, attachments))
205    }
206}