Skip to main content

miden_standards/note/
constant_fee_policy_config.rs

1use alloc::vec::Vec;
2
3use miden_protocol::account::AccountId;
4use miden_protocol::assembly::Path;
5use miden_protocol::asset::FungibleAsset;
6use miden_protocol::crypto::rand::FeltRng;
7use miden_protocol::errors::NoteError;
8use miden_protocol::note::{
9    Note,
10    NoteAssets,
11    NoteAttachment,
12    NoteAttachments,
13    NoteRecipient,
14    NoteScript,
15    NoteScriptRoot,
16    NoteStorage,
17    NoteTag,
18    NoteType,
19    PartialNoteMetadata,
20};
21use miden_protocol::utils::sync::LazyLock;
22use miden_protocol::{Felt, Word};
23
24use crate::StandardsLib;
25use crate::note::NetworkAccountTarget;
26use crate::note::costs::{CONSTANT_FEE_POLICY_CONFIG_CONSUMPTION_CYCLES, NoteConsumptionCost};
27
28// NOTE SCRIPT
29// ================================================================================================
30
31/// Path to the CONSTANT_FEE_POLICY_CONFIG note script procedure in the standards library.
32const CONSTANT_FEE_POLICY_CONFIG_SCRIPT_PATH: &str =
33    "::miden::standards::notes::constant_fee_policy_config::main";
34
35// Initialize the CONSTANT_FEE_POLICY_CONFIG note script only once.
36static CONSTANT_FEE_POLICY_CONFIG_SCRIPT: LazyLock<NoteScript> = LazyLock::new(|| {
37    let standards_lib = StandardsLib::default();
38    let path = Path::new(CONSTANT_FEE_POLICY_CONFIG_SCRIPT_PATH);
39    NoteScript::from_package_reference(standards_lib.as_ref(), path)
40        .expect("Standards library contains CONSTANT_FEE_POLICY_CONFIG note script procedure")
41});
42
43// CONSTANT FEE POLICY CONFIG NOTE
44// ================================================================================================
45
46/// A ConstantFeePolicyConfig note: schedules a fee for a note script root in a
47/// [`BasicConstantFeePolicy`](crate::account::fees::BasicConstantFeePolicy)'s fee schedule by
48/// calling the [`ConstantFeeManager`](crate::account::fees::ConstantFeeManager)'s
49/// `set_note_fee` procedure on the account that consumes it.
50///
51/// The note script root and fee asset are carried in the note's storage as
52/// `[NOTE_SCRIPT_ROOT, FEE_ASSET_ID, FEE_ASSET_VALUE]` (see the [`Note`] conversion below). Because
53/// the storage is fixed at note creation and bound into the note commitment, the authorized party
54/// is the note sender: the consuming account's `set_note_fee` procedure authorizes the sender
55/// through the account-wide [`Authority`](crate::account::access::Authority) component. The fee
56/// asset's ID must match the account's configured fee asset ID.
57///
58/// The note is bound to the target `account` by a
59/// [`NetworkAccountTarget`](crate::note::NetworkAccountTarget) attachment: the script asserts the
60/// consuming account matches that target before calling `set_note_fee`, so the note cannot be
61/// consumed by a third-party account that merely accepts its sender.
62///
63/// # Consuming account requirements
64///
65/// The fee schedule and the fee asset ID live on an
66/// [`AuthNetworkAccount`](crate::account::auth::AuthNetworkAccount), so this note is consumed by a
67/// network account, which must:
68/// - install the [`ConstantFeeManager`](crate::account::fees::ConstantFeeManager) gated by an
69///   [`Authority`](crate::account::access::Authority) in
70///   [`OwnerControlled`](crate::account::access::Authority::OwnerControlled) or
71///   [`RbacControlled`](crate::account::access::Authority::RbacControlled) mode. It must NOT use
72///   [`AuthControlled`](crate::account::access::Authority::AuthControlled): that makes
73///   `set_note_fee` permissionless, letting anyone author a config note that rewrites the fee
74///   schedule.
75/// - allowlist this note's own script root ([`Self::script_root`]) so a network transaction is
76///   allowed to consume it.
77/// - carry a set-marked fee schedule entry for this note's own script root.
78///
79/// # Operational notes
80///
81/// - Any party can submit this note to an account that allowlists it; `set_note_fee` authorizes its
82///   sender during consumption.
83/// - `note_script_root` may be this note's own root. Fee collection reads the schedule after note
84///   execution, while sender-side sponsorship uses the pre-transaction estimate.
85/// - Lowering this note's own fee requires funding its previous fee. An unaffordable value freezes
86///   note-based fee administration.
87#[derive(Debug, Clone)]
88pub struct ConstantFeePolicyConfigNote {
89    sender: AccountId,
90    target: AccountId,
91    note_script_root: NoteScriptRoot,
92    fee_asset: FungibleAsset,
93    serial_number: Word,
94    attachments: NoteAttachments,
95}
96
97#[bon::bon]
98impl ConstantFeePolicyConfigNote {
99    /// Builds a new [`ConstantFeePolicyConfigNote`] scheduling `fee_asset` for
100    /// `note_script_root` on `account`.
101    ///
102    /// # Errors
103    ///
104    /// Returns an error if:
105    /// - `account` is not a public account (the note is bound to it via a `NetworkAccountTarget`,
106    ///   which requires a public target).
107    /// - the attachments carry a `NetworkAccountTarget` for an account other than `account`.
108    /// - the attachments exceed their protocol limit (see [`NoteAttachments::new`]); the target
109    ///   attachment occupies one of the available slots when the caller does not supply it.
110    #[builder]
111    pub fn new(
112        #[builder(field)] mut attachments: Vec<NoteAttachment>,
113        sender: AccountId,
114        target: AccountId,
115        note_script_root: NoteScriptRoot,
116        fee_asset: FungibleAsset,
117        serial_number: Word,
118    ) -> Result<Self, NoteError> {
119        // Bind the note to `account`: the note script asserts, before calling `set_note_fee`, that
120        // the consuming account matches this `NetworkAccountTarget`.
121        NetworkAccountTarget::ensure_presence(&mut attachments, target).map_err(|err| {
122            NoteError::other_with_source("failed to bind the note to its target account", err)
123        })?;
124
125        let attachments = NoteAttachments::new(attachments)?;
126
127        Ok(Self {
128            sender,
129            target,
130            note_script_root,
131            fee_asset,
132            serial_number,
133            attachments,
134        })
135    }
136}
137
138impl ConstantFeePolicyConfigNote {
139    // CONSTANTS
140    // --------------------------------------------------------------------------------------------
141
142    /// Number of storage items of a ConstantFeePolicyConfig note: the note script root word
143    /// plus the fee asset (its ID and value words).
144    ///
145    /// Must be kept in sync with `NUM_STORAGE_ITEMS` in the note script, which asserts the count.
146    pub const NUM_STORAGE_ITEMS: usize = 12;
147
148    // PUBLIC ACCESSORS
149    // --------------------------------------------------------------------------------------------
150
151    /// Returns the script of the ConstantFeePolicyConfig note.
152    pub fn script() -> NoteScript {
153        CONSTANT_FEE_POLICY_CONFIG_SCRIPT.clone()
154    }
155
156    /// Returns the ConstantFeePolicyConfig note script root.
157    pub fn script_root() -> NoteScriptRoot {
158        CONSTANT_FEE_POLICY_CONFIG_SCRIPT.root()
159    }
160
161    /// Returns the account ID of the note's sender (the account authorized for the action).
162    pub fn sender(&self) -> AccountId {
163        self.sender
164    }
165
166    /// Returns the account ID of the managed account: the account the note is tagged for and bound
167    /// to via its `NetworkAccountTarget` attachment (only this account can consume the note).
168    pub fn account(&self) -> AccountId {
169        self.target
170    }
171
172    /// Returns the note script root the fee is scheduled for.
173    pub fn note_script_root(&self) -> NoteScriptRoot {
174        self.note_script_root
175    }
176
177    /// Returns the fee asset scheduled for the note script root.
178    pub fn fee_asset(&self) -> FungibleAsset {
179        self.fee_asset
180    }
181
182    /// Returns the note's serial number.
183    pub fn serial_number(&self) -> Word {
184        self.serial_number
185    }
186
187    /// Returns the attachments carried by the note.
188    pub fn attachments(&self) -> &NoteAttachments {
189        &self.attachments
190    }
191
192    // HELPERS
193    // --------------------------------------------------------------------------------------------
194
195    /// Returns the note storage values encoding the action, laid out as
196    /// `[NOTE_SCRIPT_ROOT, FEE_ASSET_ID, FEE_ASSET_VALUE]`.
197    fn to_storage_values(&self) -> Vec<Felt> {
198        let mut values = Vec::with_capacity(Self::NUM_STORAGE_ITEMS);
199        values.extend_from_slice(self.note_script_root.as_word().as_elements());
200        values.extend_from_slice(self.fee_asset.to_id_word().as_elements());
201        values.extend_from_slice(self.fee_asset.to_value_word().as_elements());
202        values
203    }
204}
205
206// BUILDER EXTENSIONS
207// ================================================================================================
208
209impl<S: constant_fee_policy_config_note_builder::State> ConstantFeePolicyConfigNoteBuilder<S> {
210    /// Adds a single attachment to the note.
211    pub fn attachment(mut self, attachment: impl Into<NoteAttachment>) -> Self {
212        self.attachments.push(attachment.into());
213        self
214    }
215
216    /// Adds multiple attachments to the note.
217    pub fn attachments(
218        mut self,
219        attachments: impl IntoIterator<Item = impl Into<NoteAttachment>>,
220    ) -> Self {
221        self.attachments.extend(attachments.into_iter().map(Into::into));
222        self
223    }
224}
225
226impl<S: constant_fee_policy_config_note_builder::State> ConstantFeePolicyConfigNoteBuilder<S>
227where
228    S::SerialNumber: constant_fee_policy_config_note_builder::IsUnset,
229{
230    /// Draws a serial number from `rng` and sets it on the builder.
231    pub fn generate_serial_number(
232        self,
233        rng: &mut impl FeltRng,
234    ) -> ConstantFeePolicyConfigNoteBuilder<
235        constant_fee_policy_config_note_builder::SetSerialNumber<S>,
236    > {
237        self.serial_number(rng.draw_word())
238    }
239}
240
241// CONVERSIONS
242// ================================================================================================
243
244impl From<ConstantFeePolicyConfigNote> for Note {
245    fn from(note: ConstantFeePolicyConfigNote) -> Self {
246        // ConstantFeePolicyConfig notes carry no assets and are always public for network
247        // execution; the note script root and fee asset live in the note storage.
248        let metadata = PartialNoteMetadata::new(note.sender, NoteType::Public)
249            .with_tag(NoteTag::with_account_target(note.target));
250        let storage = NoteStorage::new(note.to_storage_values())
251            .expect("number of storage items should not exceed max storage items");
252        let recipient =
253            NoteRecipient::new(note.serial_number, ConstantFeePolicyConfigNote::script(), storage);
254
255        Note::with_attachments(NoteAssets::default(), metadata, recipient, note.attachments)
256    }
257}
258
259// NOTE CONSUMPTION COST
260// ================================================================================================
261
262impl NoteConsumptionCost for ConstantFeePolicyConfigNote {
263    fn consumption_cycles() -> u32 {
264        CONSTANT_FEE_POLICY_CONFIG_CONSUMPTION_CYCLES
265    }
266}
267
268// TESTS
269// ================================================================================================
270
271#[cfg(test)]
272mod tests {
273    use alloc::vec::Vec;
274
275    use assert_matches::assert_matches;
276    use miden_protocol::account::AccountType;
277    use miden_protocol::crypto::rand::RandomCoin;
278    use miden_protocol::note::NoteAttachmentScheme;
279    use miden_protocol::testing::account_id::ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET;
280
281    use super::*;
282    use crate::note::{NetworkAccountTargetError, NoteExecutionHint};
283
284    fn account_id(seed: u8) -> AccountId {
285        AccountId::builder()
286            .account_type(AccountType::Public)
287            .build_with_seed([seed; 32])
288    }
289
290    fn note_root(seed: u32) -> NoteScriptRoot {
291        NoteScriptRoot::from_array([seed, seed + 1, seed + 2, seed + 3])
292    }
293
294    fn fee_asset(amount: u64) -> FungibleAsset {
295        FungibleAsset::new(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET.try_into().unwrap(), amount).unwrap()
296    }
297
298    /// The builder produces a public, asset-less note tagged for the managed account.
299    #[test]
300    fn builder_builds_constant_fee_policy_config_note() {
301        let mut rng = RandomCoin::new(Word::empty());
302        let account = account_id(1);
303        let sender = account_id(2);
304
305        let note = ConstantFeePolicyConfigNote::builder()
306            .sender(sender)
307            .target(account)
308            .note_script_root(note_root(10))
309            .fee_asset(fee_asset(500))
310            .generate_serial_number(&mut rng)
311            .build()
312            .unwrap();
313
314        assert_eq!(note.sender(), sender);
315        assert_eq!(note.account(), account);
316
317        let note = Note::from(note);
318        assert_eq!(note.metadata().note_type(), NoteType::Public);
319        assert_eq!(note.metadata().tag(), NoteTag::with_account_target(account));
320        assert_eq!(note.assets().num_assets(), 0);
321    }
322
323    /// The built note carries a `NetworkAccountTarget` attachment bound to `account`, so the note
324    /// script can reject consumption by any other account.
325    #[test]
326    fn note_is_bound_to_target_account() {
327        let account = account_id(1);
328        let note = ConstantFeePolicyConfigNote::builder()
329            .sender(account_id(2))
330            .target(account)
331            .note_script_root(note_root(10))
332            .fee_asset(fee_asset(500))
333            .serial_number(Word::empty())
334            .build()
335            .unwrap();
336
337        let built = Note::from(note);
338        let target = NetworkAccountTarget::try_from(built.attachments())
339            .expect("note should carry a network account target attachment");
340        assert_eq!(target.target_id(), account);
341    }
342
343    /// A caller-supplied `NetworkAccountTarget` for another account is rejected rather than
344    /// silently coexisting with the note's own target.
345    #[test]
346    fn caller_supplied_target_for_other_account_is_rejected() {
347        let rogue_target =
348            NetworkAccountTarget::new(account_id(3), NoteExecutionHint::Always).unwrap();
349
350        let err = ConstantFeePolicyConfigNote::builder()
351            .sender(account_id(2))
352            .target(account_id(1))
353            .note_script_root(note_root(10))
354            .fee_asset(fee_asset(500))
355            .serial_number(Word::empty())
356            .attachment(rogue_target)
357            .build()
358            .unwrap_err();
359
360        assert_matches!(err, NoteError::Other { source, .. } => {
361            assert_matches!(
362              *source.unwrap().downcast().unwrap(),
363              NetworkAccountTargetError::TargetMismatch { .. }
364            )
365        });
366    }
367
368    /// A non-public `account` is rejected by the builder, since the note binds to it via a
369    /// `NetworkAccountTarget`, which requires a public target.
370    #[test]
371    fn private_target_account_is_rejected() {
372        let private_account =
373            AccountId::builder().account_type(AccountType::Private).build_with_seed([9; 32]);
374
375        let err = ConstantFeePolicyConfigNote::builder()
376            .sender(account_id(2))
377            .target(private_account)
378            .note_script_root(note_root(10))
379            .fee_asset(fee_asset(500))
380            .serial_number(Word::empty())
381            .build()
382            .unwrap_err();
383
384        assert_matches!(err, NoteError::Other { source, .. } => {
385            assert_matches!(
386              *source.unwrap().downcast().unwrap(),
387              NetworkAccountTargetError::TargetNotPublic { .. }
388            )
389        });
390    }
391
392    /// The bound target attachment reserves one of the `NoteAttachments::MAX_COUNT` slots, so a
393    /// caller supplying `MAX_COUNT` attachments of their own overflows the limit.
394    #[test]
395    fn caller_attachments_beyond_limit_are_rejected() {
396        let mut builder = ConstantFeePolicyConfigNote::builder()
397            .sender(account_id(2))
398            .target(account_id(1))
399            .note_script_root(note_root(10))
400            .fee_asset(fee_asset(500))
401            .serial_number(Word::empty());
402        for scheme in 0..NoteAttachments::MAX_COUNT as u16 {
403            let extra = NoteAttachment::with_word(
404                NoteAttachmentScheme::new(64 + scheme).unwrap(),
405                Word::empty(),
406            );
407            builder = builder.attachment(extra);
408        }
409
410        assert!(matches!(builder.build(), Err(NoteError::TooManyAttachments(_))));
411    }
412
413    /// Storage is `[NOTE_SCRIPT_ROOT, FEE_ASSET_ID, FEE_ASSET_VALUE]`.
414    #[test]
415    fn storage_layout() {
416        let root = note_root(10);
417        let asset = fee_asset(777);
418
419        let note = ConstantFeePolicyConfigNote::builder()
420            .sender(account_id(2))
421            .target(account_id(1))
422            .note_script_root(root)
423            .fee_asset(asset)
424            .serial_number(Word::empty())
425            .build()
426            .unwrap();
427
428        let built = Note::from(note);
429        let mut expected = Vec::from(root.as_word().as_elements());
430        expected.extend_from_slice(asset.to_id_word().as_elements());
431        expected.extend_from_slice(asset.to_value_word().as_elements());
432        assert_eq!(built.storage().items(), expected.as_slice());
433        assert_eq!(built.storage().items().len(), ConstantFeePolicyConfigNote::NUM_STORAGE_ITEMS);
434    }
435
436    /// The config-note script root is registered in the [`StandardNote`](crate::note::StandardNote)
437    /// reverse lookup.
438    #[test]
439    fn script_root_is_registered_standard_note() {
440        use crate::note::StandardNote;
441
442        let standard = StandardNote::from_script_root(ConstantFeePolicyConfigNote::script_root())
443            .expect("config note script root should be a registered standard note");
444        assert_eq!(standard.name(), "CONSTANT_FEE_POLICY_CONFIG");
445    }
446}