Skip to main content

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