Skip to main content

miden_tx/
pricer.rs

1use alloc::collections::BTreeMap;
2use alloc::vec::Vec;
3
4use miden_agglayer::AgglayerNote;
5use miden_protocol::asset::{AssetAmount, AssetId};
6use miden_protocol::block::FeeParameters;
7use miden_protocol::errors::AssetError;
8use miden_protocol::note::NoteScriptRoot;
9use miden_protocol::transaction::{TransactionFee, TransactionFeeError};
10use miden_standards::account::fees::{BasicConstantFeePolicy, FeePolicyManager};
11use miden_standards::note::costs::NoteCost;
12use miden_standards::note::{FeeSponsorshipNote, StandardNote};
13
14// NETWORK NOTE PRICER
15// ================================================================================================
16
17/// Error returned by [`NetworkNotePricer`] operations.
18#[derive(Debug, thiserror::Error)]
19#[non_exhaustive]
20pub enum NotePricingError {
21    /// The kernel fee computation rejected a note's fee inputs or the resulting fee. The cost
22    /// tables never contain out-of-range cycle counts, so a cycle-count error indicates a
23    /// broken cost table.
24    #[error("cannot compute the fee for a note")]
25    Fee(#[source] TransactionFeeError),
26    /// The prices accumulated across a note's created notes overflowed u64.
27    #[error("accumulated note price overflows u64")]
28    PriceOverflow,
29    /// The accumulated price exceeds the maximum representable asset amount.
30    #[error("accumulated note price exceeds the maximum asset amount")]
31    PriceExceedsMaxAssetAmount(#[source] AssetError),
32    /// The priced script root has no known consumption cost.
33    #[error("no consumption cost is known for note script root {0}")]
34    UnknownNoteScriptRoot(NoteScriptRoot),
35}
36
37/// Prices the consumption of notes by network accounts from their benchmarked cycle costs,
38/// e.g. to populate a network account's fee schedule or to size a sponsorship.
39///
40/// The fee formula lives in [`TransactionFee`]; this pricer adds a safety margin expressed in
41/// extra verification cycles on top. The default margin prices a note as-if it consumed at
42/// twice its measured cycles.
43///
44/// The chain's current [`FeeParameters`] provide the verification base fee. Costs are
45/// resolved from the standard and agglayer cost tables ([`StandardNote::note_cost`] and
46/// [`AgglayerNote::note_cost`]), so both families of notes are priced alike. Costs supplied
47/// through the builder's `note_costs` take precedence over the tables, letting an account
48/// price note families the tables do not know — or a table-known script root whose
49/// consumption on that account runs extra code and so measures a different cost.
50///
51/// [`FeeSponsorshipNote`] defaults to zero because standard network-account fee collection exempts
52/// sponsorship notes from sponsoring themselves. A cost supplied through the builder's `note_cost`
53/// or `note_costs` methods takes precedence over this default.
54///
55/// The computed fees are denominated in the given fee asset. A fee schedule stores bare amounts,
56/// so install the fees only into a policy whose
57/// [`FeePolicyManager`](miden_standards::account::fees::FeePolicyManager) charges in that same
58/// asset; [`Self::fee_asset_id`] exposes it for that check.
59#[derive(Debug, Clone, bon::Builder)]
60pub struct NetworkNotePricer {
61    /// Benchmarked costs overriding or extending the built-in tables: a root present here is
62    /// priced from this map, shadowing the standard and agglayer cost tables. Populated through
63    /// the builder's [`note_cost`](NetworkNotePricerBuilder::note_cost) and
64    /// [`note_costs`](NetworkNotePricerBuilder::note_costs) extensions.
65    #[builder(field)]
66    note_costs: BTreeMap<NoteScriptRoot, NoteCost>,
67    /// The chain's fee parameters, providing the verification base fee.
68    fee_parameters: FeeParameters,
69    /// The chain's fee asset, which the computed fees are denominated in.
70    fee_asset_id: AssetId,
71    /// Safety margin in verification cycles added on top of the kernel formula.
72    #[builder(default = 1)]
73    safety_margin_verification_cycles: u32,
74}
75
76impl NetworkNotePricer {
77    /// Returns the chain fee parameters the pricer computes fees under.
78    pub fn fee_parameters(&self) -> &FeeParameters {
79        &self.fee_parameters
80    }
81
82    /// Returns the asset the computed fees are denominated in.
83    pub fn fee_asset_id(&self) -> AssetId {
84        self.fee_asset_id
85    }
86
87    /// Returns the fee charged for a network transaction with the given fee inputs, including
88    /// the configured safety margin.
89    ///
90    /// The fee is computed entirely by [`TransactionFee::compute_fee`] under the pricer's
91    /// [`FeeParameters`], with the safety margin folded into the fee inputs
92    /// ([`TransactionFee::with_safety_margin`]), so fee terms added to the kernel formula in
93    /// the future flow through without changes here.
94    pub fn fee(&self, fee_inputs: TransactionFee) -> Result<AssetAmount, NotePricingError> {
95        fee_inputs
96            .with_safety_margin(self.safety_margin_verification_cycles)
97            .compute_fee(&self.fee_parameters)
98            .map_err(NotePricingError::Fee)
99    }
100
101    /// Prices the consumption by a network account of the note with the given script root.
102    ///
103    /// The price of a note is the fee for its own consumption plus the prices of the notes its
104    /// consumption creates:
105    ///
106    /// ```text
107    /// price(N) = fee(cycles(N)) + sum(price(M) for M created by consuming N)
108    /// ```
109    ///
110    /// Since a script root alone cannot tell whether a created note will be network-targeted,
111    /// EVERY created note is priced in, suiting root-keyed fee schedules - though like the
112    /// underlying costs, the result is an estimate, not a guaranteed upper bound (see the
113    /// [`miden_standards::note::costs`] module docs). To avoid infinite recursion, a root
114    /// already being priced further up the recursion contributes only its own consumption fee:
115    /// a partially filled PSWAP is priced for one fill level, and the paybacks of any further
116    /// partial fills are not covered.
117    pub fn price(&self, root: NoteScriptRoot) -> Result<AssetAmount, NotePricingError> {
118        let price = self.price_recursive(root, &mut Vec::new())?;
119        AssetAmount::new(price).map_err(NotePricingError::PriceExceedsMaxAssetAmount)
120    }
121
122    /// Builds a [`BasicConstantFeePolicy`] that prices every supplied note script root through
123    /// [`Self::price`].
124    ///
125    /// The policy's bare fee amounts are denominated in the fee asset configured by
126    /// [`Self::fee_asset_id`]. Each root is priced through [`Self::price`], so the fee includes
127    /// the default safety margin and the recursively priced notes created by consuming it.
128    pub fn basic_constant_fee_policy(
129        &self,
130        note_script_roots: impl IntoIterator<Item = NoteScriptRoot>,
131    ) -> Result<BasicConstantFeePolicy, NotePricingError> {
132        let mut policy = BasicConstantFeePolicy::new();
133        for root in note_script_roots {
134            policy = policy.with_fee(root, self.price(root)?);
135        }
136        Ok(policy)
137    }
138
139    /// Builds a fee policy manager whose active [`BasicConstantFeePolicy`] is generated from the
140    /// supplied note script roots.
141    ///
142    /// The manager charges in the fee asset configured by [`Self::fee_asset_id`], keeping the
143    /// policy's bare fee amounts and their denomination together.
144    pub fn basic_constant_fee_policy_manager(
145        &self,
146        note_script_roots: impl IntoIterator<Item = NoteScriptRoot>,
147    ) -> Result<FeePolicyManager, NotePricingError> {
148        let policy = self.basic_constant_fee_policy(note_script_roots)?;
149        Ok(FeePolicyManager::builder()
150            .fee_faucet_id(self.fee_asset_id.faucet_id())
151            .active_fee_policy(policy.into())
152            .build())
153    }
154
155    /// Resolves a note script root to its pricing cost. Supplied costs take precedence over the
156    /// defaults.
157    fn resolve_note_cost(&self, root: NoteScriptRoot) -> Option<NoteCost> {
158        self.note_costs
159            .get(&root)
160            .cloned()
161            .or_else(|| StandardNote::note_cost(root))
162            .or_else(|| AgglayerNote::note_cost(root))
163    }
164
165    /// Computes the recursive price of `root` as a raw `u64`, tracking the roots currently
166    /// being priced to cut off self-recursion.
167    fn price_recursive(
168        &self,
169        root: NoteScriptRoot,
170        pricing_stack: &mut Vec<NoteScriptRoot>,
171    ) -> Result<u64, NotePricingError> {
172        if root == FeeSponsorshipNote::script_root() && !self.note_costs.contains_key(&root) {
173            return Ok(0);
174        }
175
176        let cost = self
177            .resolve_note_cost(root)
178            .ok_or(NotePricingError::UnknownNoteScriptRoot(root))?;
179        // Cycle counts enter the fee computation only here, where the looked-up cost is
180        // converted into the kernel's fee inputs.
181        let fee_inputs = TransactionFee::new(cost.cycles()).map_err(NotePricingError::Fee)?;
182        let own_fee = self.fee(fee_inputs)?.as_u64();
183
184        if pricing_stack.contains(&root) {
185            return Ok(own_fee);
186        }
187
188        pricing_stack.push(root);
189        let mut total = own_fee;
190        for &created in cost.created_notes() {
191            let created_price = self.price_recursive(created, pricing_stack)?;
192            total = total.checked_add(created_price).ok_or(NotePricingError::PriceOverflow)?;
193        }
194        pricing_stack.pop();
195
196        Ok(total)
197    }
198}
199
200// BUILDER EXTENSIONS
201// ================================================================================================
202
203impl<S: network_note_pricer_builder::State> NetworkNotePricerBuilder<S> {
204    /// Adds a single benchmarked note cost, overriding or extending the built-in tables for the
205    /// given script root.
206    pub fn note_cost(mut self, root: NoteScriptRoot, cost: NoteCost) -> Self {
207        self.note_costs.insert(root, cost);
208        self
209    }
210
211    /// Adds multiple benchmarked note costs, overriding or extending the built-in tables.
212    pub fn note_costs(
213        mut self,
214        note_costs: impl IntoIterator<Item = (NoteScriptRoot, NoteCost)>,
215    ) -> Self {
216        self.note_costs.extend(note_costs);
217        self
218    }
219}
220
221// TESTS
222// ================================================================================================
223
224#[cfg(test)]
225mod tests {
226    use miden_agglayer::ClaimNote;
227    use miden_agglayer::costs::CLAIM_CONSUMPTION_CYCLES;
228    use miden_protocol::MAX_TX_EXECUTION_CYCLES;
229    use miden_protocol::account::AccountId;
230    use miden_protocol::testing::account_id::ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET;
231    use miden_standards::note::config::ConstantFeePolicyConfigNote;
232    use miden_standards::note::costs::{
233        MINT_CONSUMPTION_CYCLES,
234        P2ID_CONSUMPTION_CYCLES,
235        SWAP_CONSUMPTION_CYCLES,
236    };
237    use miden_standards::note::{FeeSponsorshipNote, P2idNote, SwapNote};
238
239    use super::*;
240
241    fn fee_asset_id() -> AssetId {
242        let fee_faucet_id = AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET)
243            .expect("testing faucet ID should be valid");
244        AssetId::new_fungible(fee_faucet_id)
245    }
246
247    fn pricer(base_fee: u32, margin: u32) -> NetworkNotePricer {
248        NetworkNotePricer::builder()
249            .fee_parameters(FeeParameters::new(base_fee))
250            .fee_asset_id(fee_asset_id())
251            .safety_margin_verification_cycles(margin)
252            .build()
253    }
254
255    fn fee_inputs(cycles: u32) -> TransactionFee {
256        TransactionFee::new(cycles).expect("test cycle counts are non-zero")
257    }
258
259    #[test]
260    fn fee_implements_the_kernel_formula() {
261        let no_margin = pricer(500, 0);
262
263        // ilog2(2^16) = 16 -> 17 verification cycles.
264        assert_eq!(no_margin.fee(fee_inputs(1 << 16)).unwrap().as_u64(), 500 * 17);
265        // The fee only changes at the next power of two.
266        assert_eq!(no_margin.fee(fee_inputs((1 << 17) - 1)).unwrap().as_u64(), 500 * 17);
267        assert_eq!(no_margin.fee(fee_inputs(1 << 17)).unwrap().as_u64(), 500 * 18);
268        // The smallest non-zero cycle count is charged one verification cycle.
269        assert_eq!(no_margin.fee(fee_inputs(1)).unwrap().as_u64(), 500);
270    }
271
272    #[test]
273    fn default_safety_margin_adds_one_verification_cycle() {
274        let default_margin = NetworkNotePricer::builder()
275            .fee_parameters(FeeParameters::new(500))
276            .fee_asset_id(fee_asset_id())
277            .build();
278        assert_eq!(default_margin.fee(fee_inputs(1 << 16)).unwrap().as_u64(), 500 * 18);
279    }
280
281    #[test]
282    fn out_of_range_cycle_costs_cannot_be_priced() {
283        let root = NoteScriptRoot::from_array([1, 0, 0, 0]);
284        // Zero-cycle and above-maximum costs, which the real tables never contain.
285        for cycles in [0, u32::MAX] {
286            let broken = custom_pricer([(root, NoteCost::new(cycles, Vec::new()))]);
287            assert!(matches!(broken.price(root), Err(NotePricingError::Fee(_))));
288        }
289    }
290
291    #[test]
292    fn fee_exceeding_max_asset_amount_is_rejected() {
293        // The margin saturates the charged verification cycles at u32::MAX; with a u32::MAX
294        // base fee the product exceeds `AssetAmount::MAX`.
295        assert!(matches!(
296            pricer(u32::MAX, u32::MAX).fee(fee_inputs(MAX_TX_EXECUTION_CYCLES)),
297            Err(NotePricingError::Fee(_))
298        ));
299    }
300
301    /// Fabricated creation graph shared by the recursion and accumulation tests, mirroring
302    /// PSWAP's self-recreation: `[1, 0, 0, 0]` (`2^16` cycles) creates `[2, 0, 0, 0]` and
303    /// itself, `[2, 0, 0, 0]` (`2^10` cycles) creates `[3, 0, 0, 0]`, and `[3, 0, 0, 0]`
304    /// (`2^16` cycles) creates nothing. The roots are fabricated, so the costs extend rather
305    /// than shadow the built-in tables.
306    fn test_graph() -> [(NoteScriptRoot, NoteCost); 3] {
307        let self_recursive = NoteScriptRoot::from_array([1, 0, 0, 0]);
308        let parent = NoteScriptRoot::from_array([2, 0, 0, 0]);
309        let leaf = NoteScriptRoot::from_array([3, 0, 0, 0]);
310        [
311            (self_recursive, NoteCost::new(1 << 16, vec![parent, self_recursive])),
312            (parent, NoteCost::new(1 << 10, vec![leaf])),
313            (leaf, NoteCost::new(1 << 16, Vec::new())),
314        ]
315    }
316
317    /// Builds a pricer whose fee for a `2^16`-cycle cost lands exactly at `AssetAmount::MAX`:
318    /// such a cost is charged `17` formula cycles plus the margin, and
319    /// `u32::MAX * 2^31 = AssetAmount::MAX`. The `2^10`-cycle parent's fee falls six base
320    /// fees short of that.
321    fn max_fee_pricer() -> NetworkNotePricer {
322        NetworkNotePricer::builder()
323            .fee_parameters(FeeParameters::new(u32::MAX))
324            .fee_asset_id(fee_asset_id())
325            .safety_margin_verification_cycles((1 << 31) - 17)
326            .note_costs(test_graph())
327            .build()
328    }
329
330    #[test]
331    fn overflowing_accumulated_price_is_rejected() {
332        // The self-recursive root accumulates nearly three maximal fees (its own, the
333        // parent's, and the leaf's), overflowing u64.
334        assert!(matches!(
335            max_fee_pricer().price(NoteScriptRoot::from_array([1, 0, 0, 0])),
336            Err(NotePricingError::PriceOverflow)
337        ));
338    }
339
340    #[test]
341    fn accumulated_price_above_max_asset_amount_is_rejected() {
342        // The parent's and leaf's fees together fit in a u64 but exceed `AssetAmount::MAX`.
343        assert!(matches!(
344            max_fee_pricer().price(NoteScriptRoot::from_array([2, 0, 0, 0])),
345            Err(NotePricingError::PriceExceedsMaxAssetAmount(_))
346        ));
347    }
348
349    /// Builds a zero-margin pricer carrying the given fabricated costs.
350    fn custom_pricer(
351        costs: impl IntoIterator<Item = (NoteScriptRoot, NoteCost)>,
352    ) -> NetworkNotePricer {
353        NetworkNotePricer::builder()
354            .fee_parameters(FeeParameters::new(500))
355            .fee_asset_id(fee_asset_id())
356            .safety_margin_verification_cycles(0)
357            .note_costs(costs)
358            .build()
359    }
360
361    #[test]
362    fn price_includes_created_notes() {
363        let parent = NoteScriptRoot::from_array([2, 0, 0, 0]);
364        // The parent's own fee (11 verification cycles) plus the leaf's (17).
365        let expected = 500 * 11 + 500 * 17;
366        assert_eq!(custom_pricer(test_graph()).price(parent).unwrap().as_u64(), expected);
367    }
368
369    #[test]
370    fn self_recursive_notes_are_priced_at_one_level_of_nesting() {
371        let selfish = NoteScriptRoot::from_array([1, 0, 0, 0]);
372        // Own fee + the created chain (parent + leaf) + own fee again (the nested
373        // self-reference, cut off there).
374        let expected = 500 * 17 + (500 * 11 + 500 * 17) + 500 * 17;
375        assert_eq!(custom_pricer(test_graph()).price(selfish).unwrap().as_u64(), expected);
376    }
377
378    #[test]
379    fn unknown_roots_cannot_be_priced() {
380        let unknown = NoteScriptRoot::from_array([9, 9, 9, 9]);
381        assert!(matches!(
382            pricer(500, 0).price(unknown),
383            Err(NotePricingError::UnknownNoteScriptRoot(root)) if root == unknown
384        ));
385    }
386
387    /// A root absent from the built-in tables is priced from the supplied costs, and its
388    /// created notes still resolve through the built-in tables.
389    #[test]
390    fn supplied_note_costs_extend_the_built_in_tables() {
391        let custom = NoteScriptRoot::from_array([7, 0, 0, 0]);
392        let pricer =
393            custom_pricer([(custom, NoteCost::new(1 << 16, vec![P2idNote::script_root()]))]);
394        let expected = pricer.fee(fee_inputs(1 << 16)).unwrap().as_u64()
395            + pricer.fee(fee_inputs(P2ID_CONSUMPTION_CYCLES)).unwrap().as_u64();
396        assert_eq!(pricer.price(custom).unwrap().as_u64(), expected);
397    }
398
399    /// Individual costs can be supplied one at a time through the `note_cost` builder extension,
400    /// accumulating across chained calls just like the iterator-taking `note_costs`.
401    #[test]
402    fn individual_note_costs_can_be_supplied_one_at_a_time() {
403        let first = NoteScriptRoot::from_array([7, 0, 0, 0]);
404        let second = NoteScriptRoot::from_array([8, 0, 0, 0]);
405        let pricer = NetworkNotePricer::builder()
406            .fee_parameters(FeeParameters::new(500))
407            .fee_asset_id(fee_asset_id())
408            .safety_margin_verification_cycles(0)
409            .note_cost(first, NoteCost::new(1 << 16, Vec::new()))
410            .note_cost(second, NoteCost::new(1 << 10, Vec::new()))
411            .build();
412        assert_eq!(
413            pricer.price(first).unwrap().as_u64(),
414            pricer.fee(fee_inputs(1 << 16)).unwrap().as_u64()
415        );
416        assert_eq!(
417            pricer.price(second).unwrap().as_u64(),
418            pricer.fee(fee_inputs(1 << 10)).unwrap().as_u64()
419        );
420    }
421
422    /// A root present in both the supplied costs and the built-in tables is priced from the
423    /// supplied cost: the map shadows the tables. The override drops the P2ID payback leg the
424    /// table's SWAP cost carries, so a table-derived price could not produce this value.
425    #[test]
426    fn supplied_note_costs_shadow_the_built_in_tables() {
427        let root = SwapNote::script_root();
428        let pricer =
429            custom_pricer([(root, NoteCost::new(2 * SWAP_CONSUMPTION_CYCLES, Vec::new()))]);
430        let expected = pricer.fee(fee_inputs(2 * SWAP_CONSUMPTION_CYCLES)).unwrap().as_u64();
431        assert_eq!(pricer.price(root).unwrap().as_u64(), expected);
432    }
433
434    /// The built-in lookup resolves standard notes: a SWAP prices as its own fee plus the
435    /// P2ID payback leg.
436    #[test]
437    fn swap_price_includes_the_p2id_payback_leg() {
438        let pricer = pricer(500, 0);
439        let p2id_fee = pricer.fee(fee_inputs(P2ID_CONSUMPTION_CYCLES)).unwrap().as_u64();
440        let swap_fee = pricer.fee(fee_inputs(SWAP_CONSUMPTION_CYCLES)).unwrap().as_u64();
441        assert_eq!(pricer.price(SwapNote::script_root()).unwrap().as_u64(), swap_fee + p2id_fee);
442    }
443
444    /// The built-in lookup resolves agglayer notes: a CLAIM's price covers the whole chain it
445    /// triggers - CLAIM + MINT + P2ID.
446    #[test]
447    fn claim_price_includes_the_mint_and_p2id_legs() {
448        let pricer = pricer(500, 0);
449        let expected = pricer.fee(fee_inputs(CLAIM_CONSUMPTION_CYCLES)).unwrap().as_u64()
450            + pricer.fee(fee_inputs(MINT_CONSUMPTION_CYCLES)).unwrap().as_u64()
451            + pricer.fee(fee_inputs(P2ID_CONSUMPTION_CYCLES)).unwrap().as_u64();
452        assert_eq!(pricer.price(ClaimNote::script_root()).unwrap().as_u64(), expected);
453    }
454
455    #[test]
456    fn basic_constant_fee_policy_manager_prices_every_root_in_the_native_fee_asset() {
457        let pricer = pricer(500, 0);
458        let roots = [
459            SwapNote::script_root(),
460            ClaimNote::script_root(),
461            ConstantFeePolicyConfigNote::script_root(),
462        ];
463
464        let manager = pricer.basic_constant_fee_policy_manager(roots).unwrap();
465        assert_eq!(manager.active_fee_policy(), BasicConstantFeePolicy::root());
466        assert_eq!(manager.fee_asset_id(), pricer.fee_asset_id());
467    }
468
469    #[test]
470    fn sponsorship_defaults_to_zero_but_allows_a_cost_override() {
471        let root = FeeSponsorshipNote::script_root();
472
473        let default_pricer = pricer(500, 0);
474        let default_policy = default_pricer.basic_constant_fee_policy([root]).unwrap();
475        assert_eq!(default_pricer.price(root).unwrap(), AssetAmount::ZERO);
476        assert_eq!(default_policy.fee_schedule().get(&root), Some(&AssetAmount::ZERO));
477
478        const CUSTOM_SPONSORSHIP_CYCLES: u32 = 65_536;
479        let custom_pricer =
480            custom_pricer([(root, NoteCost::new(CUSTOM_SPONSORSHIP_CYCLES, Vec::new()))]);
481        let custom_price = custom_pricer.fee(fee_inputs(CUSTOM_SPONSORSHIP_CYCLES)).unwrap();
482        let custom_policy = custom_pricer.basic_constant_fee_policy([root]).unwrap();
483
484        assert_eq!(custom_pricer.price(root).unwrap(), custom_price);
485        assert_eq!(custom_policy.fee_schedule().get(&root), Some(&custom_price));
486    }
487
488    #[test]
489    fn basic_constant_fee_policy_rejects_unknown_roots() {
490        let unknown = NoteScriptRoot::from_array([9, 9, 9, 9]);
491        assert!(matches!(
492            pricer(500, 0).basic_constant_fee_policy([unknown]),
493            Err(NotePricingError::UnknownNoteScriptRoot(root)) if root == unknown
494        ));
495    }
496}