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}