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#[derive(Debug, thiserror::Error)]
19#[non_exhaustive]
20pub enum NotePricingError {
21 #[error("cannot compute the fee for a note")]
25 Fee(#[source] TransactionFeeError),
26 #[error("accumulated note price overflows u64")]
28 PriceOverflow,
29 #[error("accumulated note price exceeds the maximum asset amount")]
31 PriceExceedsMaxAssetAmount(#[source] AssetError),
32 #[error("no consumption cost is known for note script root {0}")]
34 UnknownNoteScriptRoot(NoteScriptRoot),
35}
36
37#[derive(Debug, Clone, bon::Builder)]
60pub struct NetworkNotePricer {
61 #[builder(field)]
66 note_costs: BTreeMap<NoteScriptRoot, NoteCost>,
67 fee_parameters: FeeParameters,
69 fee_asset_id: AssetId,
71 #[builder(default = 1)]
73 safety_margin_verification_cycles: u32,
74}
75
76impl NetworkNotePricer {
77 pub fn fee_parameters(&self) -> &FeeParameters {
79 &self.fee_parameters
80 }
81
82 pub fn fee_asset_id(&self) -> AssetId {
84 self.fee_asset_id
85 }
86
87 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 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 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 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 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 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 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
200impl<S: network_note_pricer_builder::State> NetworkNotePricerBuilder<S> {
204 pub fn note_cost(mut self, root: NoteScriptRoot, cost: NoteCost) -> Self {
207 self.note_costs.insert(root, cost);
208 self
209 }
210
211 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#[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 assert_eq!(no_margin.fee(fee_inputs(1 << 16)).unwrap().as_u64(), 500 * 17);
265 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 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 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 assert!(matches!(
296 pricer(u32::MAX, u32::MAX).fee(fee_inputs(MAX_TX_EXECUTION_CYCLES)),
297 Err(NotePricingError::Fee(_))
298 ));
299 }
300
301 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 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 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 assert!(matches!(
344 max_fee_pricer().price(NoteScriptRoot::from_array([2, 0, 0, 0])),
345 Err(NotePricingError::PriceExceedsMaxAssetAmount(_))
346 ));
347 }
348
349 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 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 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 #[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 #[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 #[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 #[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 #[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}