Skip to main content

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