miden_standards/note/tx_fee.rs
1use alloc::vec::Vec;
2
3use miden_protocol::account::AccountId;
4use miden_protocol::assembly::Path;
5use miden_protocol::asset::Asset;
6use miden_protocol::block::BlockNumber;
7use miden_protocol::crypto::rand::FeltRng;
8use miden_protocol::errors::NoteError;
9use miden_protocol::note::{
10 Note,
11 NoteAssets,
12 NoteRecipient,
13 NoteScript,
14 NoteScriptRoot,
15 NoteStorage,
16 NoteTag,
17 NoteType,
18 PartialNoteMetadata,
19};
20use miden_protocol::utils::sync::LazyLock;
21use miden_protocol::{Felt, Hasher, Word};
22
23use crate::StandardsLib;
24
25// NOTE SCRIPT
26// ================================================================================================
27
28/// Path to the TX_FEE note script procedure in the standards library.
29const TX_FEE_SCRIPT_PATH: &str = "::miden::standards::notes::tx_fee::main";
30
31// Initialize the TX_FEE note script only once
32static TX_FEE_SCRIPT: LazyLock<NoteScript> = LazyLock::new(|| {
33 let standards_lib = StandardsLib::default();
34 let path = Path::new(TX_FEE_SCRIPT_PATH);
35 NoteScript::from_package_reference(standards_lib.as_ref(), path)
36 .expect("Standards library contains TX_FEE note script procedure")
37});
38
39// FEE NOTE
40// ================================================================================================
41
42/// A TX_FEE note: the canonical way for a transaction to pay its fee to a batch builder.
43///
44/// The note does not restrict who can consume it: any account (i.e. whichever account builds the
45/// batch) can consume the note. Its script leaves the assets in the note, so the consuming
46/// account's own code must move them out. The note is completely unopinionated about which assets
47/// are used to pay the fee.
48///
49/// TX_FEE notes are always [public](NoteType::Public), carry no storage and no attachments, and
50/// are tagged with the unique [`TxFeeNote::TAG`].
51///
52/// Construct one with the [builder](TxFeeNote::builder), which requires at least one asset.
53/// Convert a `TxFeeNote` into a protocol [`Note`] infallibly via `Note::from`.
54#[derive(Debug, Clone)]
55pub struct TxFeeNote {
56 sender: AccountId,
57 serial_number: Word,
58 assets: NoteAssets,
59}
60
61#[bon::bon]
62impl TxFeeNote {
63 /// Builds a new [`TxFeeNote`].
64 ///
65 /// # Errors
66 ///
67 /// Returns an error if:
68 /// - No assets were provided.
69 /// - The assets exceed their protocol limits (see [`NoteAssets::new`]).
70 #[builder]
71 pub fn new(
72 #[builder(field)] assets: Vec<Asset>,
73 sender: AccountId,
74 serial_number: Word,
75 ) -> Result<Self, NoteError> {
76 if assets.is_empty() {
77 return Err(NoteError::other("a TX_FEE note must contain at least one asset"));
78 }
79
80 let assets = NoteAssets::new(assets)?;
81
82 Ok(Self { sender, serial_number, assets })
83 }
84}
85
86impl TxFeeNote {
87 // CONSTANTS
88 // --------------------------------------------------------------------------------------------
89
90 /// Expected number of storage items of the TX_FEE note.
91 pub const NUM_STORAGE_ITEMS: usize = 0;
92
93 /// The raw `u32` value of [`Self::TAG`] (`0xFEE`, "fee" in hex), also used as the
94 /// domain-separation tag by [`Self::derive_serial_number`].
95 ///
96 /// This constant must be kept in sync with the `TX_FEE_NOTE_TAG` and `FEE_DOMAIN_TAG`
97 /// constants in the standards MASM library.
98 pub const TAG_ID: u32 = 0xfee;
99
100 /// The unique note tag of TX_FEE notes.
101 ///
102 /// The tag's 18 least significant bits are non-zero, so it can never collide with a default
103 /// account-target tag, which has its 18 least significant bits set to zero (see
104 /// [`NoteTag::with_account_target`]). Note that this guarantee does not extend to custom
105 /// account-target tags built with a length greater than 14 bits (see
106 /// [`NoteTag::with_custom_account_target`]), which can set lower bits.
107 pub const TAG: NoteTag = NoteTag::new(Self::TAG_ID);
108
109 // PUBLIC ACCESSORS
110 // --------------------------------------------------------------------------------------------
111
112 /// Returns the script of the TX_FEE note.
113 pub fn script() -> NoteScript {
114 TX_FEE_SCRIPT.clone()
115 }
116
117 /// Returns the TX_FEE note script root.
118 pub fn script_root() -> NoteScriptRoot {
119 TX_FEE_SCRIPT.root()
120 }
121
122 /// Returns the account ID of the note's sender.
123 pub fn sender(&self) -> AccountId {
124 self.sender
125 }
126
127 /// Returns the note's serial number.
128 pub fn serial_number(&self) -> Word {
129 self.serial_number
130 }
131
132 /// Returns the assets carried by the note.
133 pub fn assets(&self) -> &NoteAssets {
134 &self.assets
135 }
136
137 // SERIAL NUMBER DERIVATION
138 // --------------------------------------------------------------------------------------------
139
140 /// Derives the serial number that `miden::standards::fee::pay_fee` uses for
141 /// the TX_FEE note it creates during a transaction.
142 ///
143 /// The serial number is `hash(FEE_DOMAIN || [serial_number_block, initial_nonce,
144 /// account_id_suffix, account_id_prefix])` with the FEE domain tag `[0xFEE, 0, 0, 0]`. It is
145 /// unique per (account, nonce) pair and lets clients precompute the note's recipient before
146 /// executing the transaction, while the domain tag separates it from serial numbers derived
147 /// from similar tuples in other contexts.
148 ///
149 /// For multisigs, `serial_number_block` is the block bound by the signed summary. Other
150 /// standard auth components use the execution reference block.
151 ///
152 /// This derivation must be kept in sync with `create_and_fund_fee_note` in the
153 /// `miden::standards::fee` MASM module.
154 pub fn derive_serial_number(
155 sender: AccountId,
156 initial_nonce: Felt,
157 serial_number_block: BlockNumber,
158 ) -> Word {
159 // Domain-separation tag for the fee note's serial number ("fee" in hex).
160 let fee_domain = Word::from([Felt::from(Self::TAG_ID), Felt::ZERO, Felt::ZERO, Felt::ZERO]);
161 let tuple = Word::from([
162 Felt::from(serial_number_block.as_u32()),
163 initial_nonce,
164 sender.suffix(),
165 sender.prefix().as_felt(),
166 ]);
167
168 Hasher::merge(&[fee_domain, tuple])
169 }
170}
171
172// BUILDER EXTENSIONS
173// ================================================================================================
174
175impl<S: tx_fee_note_builder::State> TxFeeNoteBuilder<S> {
176 /// Adds a single asset to the note. At least one asset is required for `.build()` to succeed.
177 pub fn asset(mut self, asset: impl Into<Asset>) -> Self {
178 self.assets.push(asset.into());
179 self
180 }
181
182 /// Adds multiple assets to the note.
183 pub fn assets(mut self, assets: impl IntoIterator<Item = impl Into<Asset>>) -> Self {
184 self.assets.extend(assets.into_iter().map(Into::into));
185 self
186 }
187}
188
189impl<S: tx_fee_note_builder::State> TxFeeNoteBuilder<S>
190where
191 S::SerialNumber: tx_fee_note_builder::IsUnset,
192{
193 /// Draws a serial number from `rng` and sets it on the builder.
194 pub fn generate_serial_number(
195 self,
196 rng: &mut impl FeltRng,
197 ) -> TxFeeNoteBuilder<tx_fee_note_builder::SetSerialNumber<S>> {
198 self.serial_number(rng.draw_word())
199 }
200}
201
202// CONVERSIONS
203// ================================================================================================
204
205impl From<TxFeeNote> for Note {
206 fn from(note: TxFeeNote) -> Self {
207 // TX_FEE notes are always public, carry no storage, and use the unique TX_FEE note
208 // tag.
209 let metadata =
210 PartialNoteMetadata::new(note.sender, NoteType::Public).with_tag(TxFeeNote::TAG);
211 let recipient =
212 NoteRecipient::new(note.serial_number, TxFeeNote::script(), NoteStorage::default());
213
214 Note::new(note.assets, metadata, recipient)
215 }
216}
217
218// TESTS
219// ================================================================================================
220
221#[cfg(test)]
222mod tests {
223 use assert_matches::assert_matches;
224 use miden_protocol::account::{AccountId, AccountType};
225 use miden_protocol::asset::FungibleAsset;
226 use miden_protocol::block::BlockNumber;
227 use miden_protocol::{Felt, Word};
228
229 use super::*;
230 use crate::note::{NoteConsumptionStatus, StandardNote};
231
232 fn sender() -> AccountId {
233 AccountId::builder()
234 .account_type(AccountType::Private)
235 .build_with_seed([1u8; 32])
236 }
237
238 fn unrelated_consumer() -> AccountId {
239 AccountId::builder()
240 .account_type(AccountType::Public)
241 .build_with_seed([2u8; 32])
242 }
243
244 fn faucet_a() -> AccountId {
245 AccountId::builder()
246 .account_type(AccountType::Public)
247 .build_with_seed([3u8; 32])
248 }
249
250 // CONVERSION TESTS
251 // --------------------------------------------------------------------------------------------
252
253 /// The protocol note produced from a TX_FEE note is public, tagged with the unique TX_FEE
254 /// note tag, and carries no storage and no attachments.
255 #[test]
256 fn conversion_produces_public_untargeted_note() {
257 let serial_number = Word::from([1u32, 2, 3, 4]);
258 let note: Note = TxFeeNote::builder()
259 .sender(sender())
260 .serial_number(serial_number)
261 .asset(FungibleAsset::new(faucet_a(), 100).unwrap())
262 .build()
263 .unwrap()
264 .into();
265
266 assert_eq!(note.metadata().note_type(), NoteType::Public);
267 assert_eq!(note.metadata().sender(), sender());
268 assert_eq!(note.metadata().tag(), TxFeeNote::TAG);
269 assert_eq!(usize::from(note.storage().num_items()), TxFeeNote::NUM_STORAGE_ITEMS);
270 assert_eq!(note.attachments().num_attachments(), 0);
271 assert_eq!(
272 *note.recipient(),
273 NoteRecipient::new(serial_number, TxFeeNote::script(), NoteStorage::default())
274 );
275 }
276
277 /// The TX_FEE note tag can never collide with a default account-target tag: those have their
278 /// 18 least significant bits set to zero, while the TX_FEE note tag has non-zero bits
279 /// there.
280 #[test]
281 fn tag_never_collides_with_default_account_target_tags() {
282 const LOW_18_BITS: u32 = (1 << 18) - 1;
283 assert_ne!(TxFeeNote::TAG.as_u32() & LOW_18_BITS, 0);
284 assert_eq!(Felt::from(TxFeeNote::TAG), Felt::from(TxFeeNote::TAG_ID));
285 }
286
287 // CONSUMPTION ANALYSIS TESTS
288 // --------------------------------------------------------------------------------------------
289
290 /// Static consumption analysis accepts a well-formed TX_FEE note for an arbitrary account
291 /// and rejects a note that shares the TX_FEE script root but carries unexpected storage
292 /// items (such a note would panic in the note script on execution).
293 #[test]
294 fn is_consumable_validates_storage() {
295 let block_ref = BlockNumber::from(0u32);
296 let asset = FungibleAsset::new(faucet_a(), 100).unwrap();
297
298 let standard_note = StandardNote::from_script_root(TxFeeNote::script_root())
299 .expect("TX_FEE script root should be recognized as a standard note");
300
301 let fee_note: Note = TxFeeNote::builder()
302 .sender(sender())
303 .serial_number(Word::empty())
304 .asset(asset)
305 .build()
306 .unwrap()
307 .into();
308
309 assert_matches!(
310 standard_note.is_consumable(&fee_note, unrelated_consumer(), block_ref),
311 Some(NoteConsumptionStatus::ConsumableWithAuthorization)
312 );
313
314 // A note with the TX_FEE script root but non-empty storage can never be consumed.
315 let malformed_storage = NoteStorage::new(vec![Felt::from(1u32)]).unwrap();
316 let malformed_note = Note::new(
317 NoteAssets::new(vec![asset.into()]).unwrap(),
318 PartialNoteMetadata::new(sender(), NoteType::Public).with_tag(TxFeeNote::TAG),
319 NoteRecipient::new(Word::empty(), TxFeeNote::script(), malformed_storage),
320 );
321
322 assert_matches!(
323 standard_note.is_consumable(&malformed_note, unrelated_consumer(), block_ref),
324 Some(NoteConsumptionStatus::NeverConsumable(_))
325 );
326 }
327}