Skip to main content

ark/
fees.rs

1use std::cmp::PartialOrd;
2use std::{iter, ops};
3
4use bitcoin::{Amount, FeeRate, ScriptBuf, Weight};
5
6use bitcoin_ext::{BlockHeight};
7
8use crate::Vtxo;
9
10/// Complete fee schedule for all operations.
11#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
12pub struct FeeSchedule {
13	pub board: BoardFees,
14	pub offboard: OffboardFees,
15	pub refresh: RefreshFees,
16	pub lightning_receive: LightningReceiveFees,
17	pub lightning_send: LightningSendFees,
18}
19
20impl FeeSchedule {
21	pub fn validate(&self) -> Result<(), FeeScheduleValidationError> {
22		// Validate the order of the fee structs
23		let tables = [
24			("lightning_send", &self.lightning_send.ppm_expiry_table),
25			("offboard", &self.offboard.ppm_expiry_table),
26			("refresh", &self.refresh.ppm_expiry_table),
27		];
28		for (name, ppm_expiry_table) in tables {
29			let mut prev_entry : Option<&PpmExpiryFeeEntry> = None;
30			for current in ppm_expiry_table {
31				if let Some(previous) = prev_entry {
32					// Expiry blocks should be in ascending order.
33					if current.expiry_blocks_threshold < previous.expiry_blocks_threshold {
34						return Err(FeeScheduleValidationError::UnsortedPpmFeeTable {
35							name: name.to_string(),
36							current: current.expiry_blocks_threshold,
37							previous: previous.expiry_blocks_threshold,
38						})
39					}
40					// Ensuring the curve always increases means that we can avoid a whole host of
41					// problems where the tip is different to that of the client. We prefer to
42					// overpay slightly for a fee than to make operations brittle.
43					if current.ppm < previous.ppm {
44						return Err(FeeScheduleValidationError::IncorrectPpmFeeCurve {
45							name: name.to_string(),
46							current: current.ppm.0,
47							previous: previous.ppm.0,
48						});
49					}
50				}
51				prev_entry = Some(current);
52			}
53		}
54		Ok(())
55	}
56}
57
58impl Default for FeeSchedule {
59	/// Returns a fee schedule with zero fees.
60	fn default() -> Self {
61		let table = vec![PpmExpiryFeeEntry { expiry_blocks_threshold: 0, ppm: PpmFeeRate::ZERO }];
62		Self {
63			board: BoardFees {
64				min_fee: Amount::ZERO,
65				base_fee: Amount::ZERO,
66				ppm: PpmFeeRate::ZERO,
67			},
68			offboard: OffboardFees {
69				base_fee: Amount::ZERO,
70				fixed_additional_vb: 0,
71				ppm_expiry_table: table.clone(),
72			},
73			refresh: RefreshFees {
74				base_fee: Amount::ZERO,
75				ppm_expiry_table: table.clone(),
76			},
77			lightning_receive: LightningReceiveFees {
78				base_fee: Amount::ZERO,
79				ppm: PpmFeeRate::ZERO,
80			},
81			lightning_send: LightningSendFees {
82				min_fee: Amount::ZERO,
83				base_fee: Amount::ZERO,
84				ppm_expiry_table: table.clone(),
85			},
86		}
87	}
88}
89
90/// Error types for fee schedule validation.
91#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq, Hash)]
92pub enum FeeScheduleValidationError {
93	#[error("{name} ppm expiry table must be sorted by expiry threshold in ascending order of expiry. {previous} is higher than {current}.")]
94	UnsortedPpmFeeTable { name: String, current: u32, previous: u32 },
95
96	#[error("{name} ppm expiry table fee curve must be in ascending order. {previous} is higher than {current}.")]
97	IncorrectPpmFeeCurve { name: String, current: u64, previous: u64 },
98}
99
100/// Fees for boarding the ark.
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
102pub struct BoardFees {
103	/// Minimum fee to charge.
104	#[serde(rename = "min_fee_sat", with = "bitcoin::amount::serde::as_sat")]
105	pub min_fee: Amount,
106	/// A fee applied to every transaction regardless of value.
107	#[serde(rename = "base_fee_sat", with = "bitcoin::amount::serde::as_sat")]
108	pub base_fee: Amount,
109	/// PPM (parts per million) fee rate to apply based on the value of the transaction.
110	#[serde(rename = "ppm")]
111	pub ppm: PpmFeeRate,
112}
113
114impl BoardFees {
115	/// Calculate the total fee for a board operation.
116	/// Returns the maximum of the calculated fee (base_fee + ppm) and the minimum fee. `None` if an
117	/// overflow occurs.
118	pub fn calculate(&self, amount: Amount) -> Option<Amount> {
119		let fee = (amount * self.ppm).to_amount_ceil()?.checked_add(self.base_fee)?;
120		Some(fee.max(self.min_fee))
121	}
122
123	/// [BoardFees::calculate] for clients on protocol versions that round the ppm fee down.
124	#[deprecated(note = "only for protocol versions <= 3")]
125	pub fn calculate_legacy(&self, amount: Amount) -> Option<Amount> {
126		let numerator = amount.to_sat().checked_mul(self.ppm.0)?;
127		let fee = Amount::from_sat(numerator / 1_000_000).checked_add(self.base_fee)?;
128		Some(fee.max(self.min_fee))
129	}
130}
131
132/// Fees for offboarding from the ark.
133#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
134pub struct OffboardFees {
135	/// A fee applied to every transaction regardless of value.
136	#[serde(rename = "base_fee_sat", with = "bitcoin::amount::serde::as_sat")]
137	pub base_fee: Amount,
138
139	/// Fixed number of virtual bytes charged offboard on top of the output size.
140	///
141	/// The fee for an offboard will be this value, plus the offboard output virtual size,
142	/// multiplied with the offboard fee rate, plus the `base_fee`, and plus the additional fee
143	/// calculated with the `ppm_expiry_table`.
144	pub fixed_additional_vb: u64,
145
146	/// A table mapping how soon a VTXO will expire to a PPM (parts per million) fee rate.
147	/// The table should be sorted by each `expiry_blocks_threshold` value in ascending order.
148	pub ppm_expiry_table: Vec<PpmExpiryFeeEntry>,
149}
150
151impl OffboardFees {
152	/// Returns the fee charged for the user to make an offboard given the fee rate.
153	///
154	/// Returns `None` in the calculation overflows because of insane destinations or fee rates.
155	pub fn calculate(
156		&self,
157		destination: &ScriptBuf,
158		amount: Amount,
159		fee_rate: FeeRate,
160		vtxos: impl IntoIterator<Item = VtxoFeeInfo>,
161	) -> Option<Amount> {
162		let weight_fee = self.fixed_additional_vb.checked_add(destination.as_script().len() as u64)
163			.and_then(Weight::from_vb)
164			.and_then(|w| fee_rate.checked_mul_by_weight(w))?;
165		let ppm_fee = calc_ppm_expiry_fee(Some(amount), &self.ppm_expiry_table, vtxos)?;
166		self.base_fee.checked_add(weight_fee)?.checked_add(ppm_fee)
167	}
168
169	/// [OffboardFees::calculate] for clients on protocol versions that calculate ppm fees
170	/// per VTXO.
171	#[deprecated(note = "only for protocol versions <= 3")]
172	#[allow(deprecated)]
173	pub fn calculate_legacy(
174		&self,
175		destination: &ScriptBuf,
176		amount: Amount,
177		fee_rate: FeeRate,
178		vtxos: impl IntoIterator<Item = VtxoFeeInfo>,
179	) -> Option<Amount> {
180		let weight_fee = self.fixed_additional_vb.checked_add(destination.as_script().len() as u64)
181			.and_then(Weight::from_vb)
182			.and_then(|w| fee_rate.checked_mul_by_weight(w))?;
183		let ppm_fee = calc_ppm_expiry_fee_legacy(Some(amount), &self.ppm_expiry_table, vtxos)?;
184		self.base_fee.checked_add(weight_fee)?.checked_add(ppm_fee)
185	}
186}
187
188/// Fees for refresh operations.
189#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
190pub struct RefreshFees {
191	/// A fee applied to every transaction regardless of value.
192	#[serde(rename = "base_fee_sat", with = "bitcoin::amount::serde::as_sat")]
193	pub base_fee: Amount,
194	/// A table mapping how soon a VTXO will expire to a PPM (parts per million) fee rate.
195	/// The table should be sorted by each `expiry_blocks_threshold` value in ascending order.
196	pub ppm_expiry_table: Vec<PpmExpiryFeeEntry>,
197}
198
199impl RefreshFees {
200	/// Calculate the total fee for a refresh operation.
201	///
202	/// Returns `None` if an overflow occurs.
203	pub fn calculate(
204		&self,
205		vtxos: impl IntoIterator<Item = VtxoFeeInfo>,
206	) -> Option<Amount> {
207		self.base_fee.checked_add(self.calculate_no_base_fee(vtxos)?)
208	}
209
210	/// Calculate the fee for a refresh operation, excluding the base fee.
211	///
212	/// Returns `None` if an overflow occurs.
213	pub fn calculate_no_base_fee(
214		&self,
215		vtxos: impl IntoIterator<Item = VtxoFeeInfo>,
216	) -> Option<Amount> {
217		calc_ppm_expiry_fee(None, &self.ppm_expiry_table, vtxos)
218	}
219
220	/// [RefreshFees::calculate] for clients on protocol versions that calculate ppm fees
221	/// per VTXO.
222	#[deprecated(note = "only for protocol versions <= 3")]
223	#[allow(deprecated)]
224	pub fn calculate_legacy(
225		&self,
226		vtxos: impl IntoIterator<Item = VtxoFeeInfo>,
227	) -> Option<Amount> {
228		let fee = calc_ppm_expiry_fee_legacy(None, &self.ppm_expiry_table, vtxos)?;
229		self.base_fee.checked_add(fee)
230	}
231}
232
233/// Fees for lightning receive operations.
234#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
235pub struct LightningReceiveFees {
236	/// A fee applied to every transaction regardless of value.
237	#[serde(rename = "base_fee_sat", with = "bitcoin::amount::serde::as_sat")]
238	pub base_fee: Amount,
239	/// PPM (parts per million) fee rate to apply based on the value of the transaction.
240	pub ppm: PpmFeeRate,
241}
242
243impl LightningReceiveFees {
244	/// Calculate the total fee for a lightning receive operation.
245	///
246	/// Returns `None` if an overflow occurs.
247	pub fn calculate(&self, amount: Amount) -> Option<Amount> {
248		self.base_fee.checked_add((amount * self.ppm).to_amount_ceil()?)
249	}
250
251	/// [LightningReceiveFees::calculate] for clients on protocol versions that round the ppm
252	/// fee down.
253	#[deprecated(note = "only for protocol versions <= 3")]
254	pub fn calculate_legacy(&self, amount: Amount) -> Option<Amount> {
255		let numerator = amount.to_sat().checked_mul(self.ppm.0)?;
256		self.base_fee.checked_add(Amount::from_sat(numerator / 1_000_000))
257	}
258}
259
260/// Fees for lightning send operations.
261#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
262pub struct LightningSendFees {
263	/// Minimum fee to charge.
264	#[serde(rename = "min_fee_sat", with = "bitcoin::amount::serde::as_sat")]
265	pub min_fee: Amount,
266	/// A fee applied to every transaction regardless of value.
267	#[serde(rename = "base_fee_sat", with = "bitcoin::amount::serde::as_sat")]
268	pub base_fee: Amount,
269	/// A table mapping how soon a VTXO will expire to a PPM (parts per million) fee rate.
270	/// The table should be sorted by each `expiry_blocks_threshold` value in ascending order.
271	pub ppm_expiry_table: Vec<PpmExpiryFeeEntry>,
272}
273
274impl LightningSendFees {
275	/// Calculate the total fee for a lightning send operation.
276	///
277	/// Returns `None` if an overflow occurs.
278	pub fn calculate(
279		&self,
280		amount: Amount,
281		vtxos: impl IntoIterator<Item = VtxoFeeInfo>,
282	) -> Option<Amount> {
283		let ppm = calc_ppm_expiry_fee(Some(amount), &self.ppm_expiry_table, vtxos)?;
284		Some(self.base_fee.checked_add(ppm)?.max(self.min_fee))
285	}
286
287	/// [LightningSendFees::calculate] for clients on protocol versions that calculate ppm fees
288	/// per VTXO.
289	#[deprecated(note = "only for protocol versions <= 3")]
290	#[allow(deprecated)]
291	pub fn calculate_legacy(
292		&self,
293		amount: Amount,
294		vtxos: impl IntoIterator<Item = VtxoFeeInfo>,
295	) -> Option<Amount> {
296		let ppm = calc_ppm_expiry_fee_legacy(Some(amount), &self.ppm_expiry_table, vtxos)?;
297		Some(self.base_fee.checked_add(ppm)?.max(self.min_fee))
298	}
299}
300
301/// A very basic struct to hold information for use in calculating the fees of transactions.
302#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
303pub struct VtxoFeeInfo {
304	/// The total amount of the VTXO.
305	pub amount: Amount,
306	/// Number of blocks until expiry.
307	pub expiry_blocks: u32,
308}
309
310impl VtxoFeeInfo {
311	/// Constructs a [VtxoFeeInfo] instance from the given [Vtxo] and tip [BlockHeight]
312	pub fn from_vtxo_and_tip<G>(vtxo: &Vtxo<G>, tip: BlockHeight) -> Self {
313		Self {
314			amount: vtxo.amount(),
315			expiry_blocks: vtxo.expiry_height().saturating_sub(tip),
316		}
317	}
318}
319
320#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Deserialize, Serialize)]
321pub struct PpmFeeRate(pub u64);
322
323impl PpmFeeRate {
324	/// The zero amount.
325	pub const ZERO: PpmFeeRate = PpmFeeRate(0);
326	/// Represents a fee rate of 1%.
327	pub const ONE_PERCENT: PpmFeeRate = PpmFeeRate(10_000);
328
329}
330
331/// A fee with sub-satoshi precision: the undivided numerator of a ppm fee calculation, in
332/// millionths of a satoshi.
333///
334/// Produced by `Amount * PpmFeeRate`. To be usable as money it must be rounded to whole
335/// satoshis with [PpmFee::to_amount_ceil]; keeping the numerator exact until then lets sums
336/// of fees round once on the total instead of once per term.
337#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
338pub struct PpmFee(u128);
339
340impl PpmFee {
341	/// The zero amount.
342	pub const ZERO: PpmFee = PpmFee(0);
343
344	/// The fee in satoshis, rounded up to the next whole satoshi.
345	/// Returns `None` if the result exceeds `u64::MAX`.
346	pub fn to_sat_ceil(self) -> Option<u64> {
347		u64::try_from(self.0.div_ceil(1_000_000)).ok()
348	}
349
350	/// The fee rounded up to the next whole satoshi.
351	/// Returns `None` if the result exceeds `u64::MAX` satoshis.
352	pub fn to_amount_ceil(self) -> Option<Amount> {
353		Some(Amount::from_sat(self.to_sat_ceil()?))
354	}
355
356	/// Returns `None` if the sum overflows.
357	pub fn checked_add(self, other: PpmFee) -> Option<PpmFee> {
358		Some(PpmFee(self.0.checked_add(other.0)?))
359	}
360}
361
362impl ops::Mul<PpmFeeRate> for Amount {
363	type Output = PpmFee;
364
365	/// Calculates a fee for the current amount using a parts-per-million (PPM) rate.
366	///
367	/// # Example
368	///
369	/// ```rust
370	/// use ark::fees::PpmFeeRate;
371	/// use bitcoin::Amount;
372	///
373	/// let fee_chargeable_amount = Amount::from_sat(10_000);
374	/// let ppm = PpmFeeRate(5_000); // 0.5%
375	/// let fee = (fee_chargeable_amount * ppm).to_amount_ceil().unwrap();
376	/// assert_eq!(fee, Amount::from_sat(50)); // 10,000 * 5,000 / 1,000,000 = 50
377	/// ```
378	fn mul(self, ppm: PpmFeeRate) -> Self::Output {
379		PpmFee((self.to_sat() as u128).checked_mul(ppm.0 as u128)
380			.expect("widening u64 * u64 to u128 is exact and cannot overflow"))
381	}
382}
383
384/// Entry in a table to calculate the PPM (parts per million) fee rate of a transaction based on how
385/// new a VTXO is.
386#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
387pub struct PpmExpiryFeeEntry {
388	/// A threshold for the number of blocks until a VTXO expires for the `ppm` amount to apply.
389	/// As an example, if this value is set to 50 and a VTXO expires in 60 blocks, this
390	/// [PpmExpiryFeeEntry] will be used to calculate the fee unless another entry exists with an
391	/// `expiry_blocks_threshold` with a value between 51 and 60 (inclusive).
392	pub expiry_blocks_threshold: u32,
393	/// PPM (parts per million) fee rate to apply for this expiry period.
394	pub ppm: PpmFeeRate,
395}
396
397/// Error types for fee validation.
398#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq, Hash)]
399pub enum FeeValidationError {
400	#[error("Fee ({fee}) exceeds amount ({amount})")]
401	FeeExceedsAmount { amount: Amount, fee: Amount },
402
403	#[error("Amount after fee ({amount_after_fee}) is below dust limit ({dust}). Amount: {amount}, Fee: {fee}")]
404	AmountAfterFeeBelowDust {
405		amount: Amount,
406		fee: Amount,
407		dust: Amount,
408		amount_after_fee: Amount,
409	},
410}
411
412/// Validates fee amounts and calculates the resulting amount after fee.
413///
414/// This function ensures two critical conditions are met:
415/// 1. Fee doesn't exceed the original amount (prevents overflow)
416/// 2. Amount after fee is > zero
417///
418/// # Returns
419/// * `Ok(Amount)` - The amount after subtracting the fee
420/// * `Err(FeeValidationError)` - If any validation condition fails
421///
422/// # Example
423/// ```
424/// use ark::fees::{validate_and_subtract_fee, FeeValidationError};
425/// use bitcoin::Amount;
426///
427/// let amount = Amount::from_sat(10_000);
428/// let fee = Amount::from_sat(100);
429/// let result = validate_and_subtract_fee(amount, fee);
430/// assert_eq!(result.unwrap(), Amount::from_sat(9_900));
431///
432/// let amount = Amount::from_sat(10_000);
433/// let fee = Amount::from_sat(10_000);
434/// let result = validate_and_subtract_fee(amount, fee);
435/// assert_eq!(result.unwrap_err(), FeeValidationError::FeeExceedsAmount { amount, fee });
436///
437/// let amount = Amount::from_sat(10_000);
438/// let fee = Amount::from_sat(11_000);
439/// let result = validate_and_subtract_fee(amount, fee);
440/// assert_eq!(result.unwrap_err(), FeeValidationError::FeeExceedsAmount { amount, fee });
441/// ```
442pub fn validate_and_subtract_fee(
443	amount: Amount,
444	fee: Amount,
445) -> Result<Amount, FeeValidationError> {
446	let amount_after_fee = amount.checked_sub(fee)
447		.ok_or(FeeValidationError::FeeExceedsAmount { amount, fee })?;
448
449	if amount_after_fee == Amount::ZERO {
450		Err(FeeValidationError::FeeExceedsAmount { amount, fee })
451	} else {
452		Ok(amount_after_fee)
453	}
454}
455
456/// Validates fee amounts and calculates the resulting amount after fee.
457///
458/// This function ensures two critical conditions are met:
459/// 1. Fee doesn't exceed the original amount (prevents overflow)
460/// 2. Amount after fee is >= dust (ensures economically viable output)
461///
462/// # Returns
463/// * `Ok(Amount)` - The amount after subtracting the fee
464/// * `Err(FeeValidationError)` - If any validation condition fails
465///
466/// # Example
467/// ```
468/// use ark::fees::{validate_and_subtract_fee_min_dust, FeeValidationError};
469/// use ark::vtxo::VTXO_DUST;
470/// use bitcoin::Amount;
471///
472/// let dust = VTXO_DUST;
473/// let amount = Amount::from_sat(10_000);
474/// let fee = Amount::from_sat(100);
475/// let result = validate_and_subtract_fee_min_dust(amount, fee, dust);
476/// assert_eq!(result.unwrap(), Amount::from_sat(9_900));
477///
478/// let amount = Amount::from_sat(10_000);
479/// let fee = Amount::from_sat(9_670);
480/// let result = validate_and_subtract_fee_min_dust(amount, fee, dust);
481/// assert_eq!(result.unwrap(), dust);
482///
483/// let amount = Amount::from_sat(10_000);
484/// let fee = Amount::from_sat(11_000);
485/// let result = validate_and_subtract_fee_min_dust(amount, fee, dust);
486/// assert_eq!(result.unwrap_err(), FeeValidationError::FeeExceedsAmount { amount, fee, });
487///
488/// let amount = Amount::from_sat(10_000);
489/// let fee = Amount::from_sat(10_000);
490/// let result = validate_and_subtract_fee_min_dust(amount, fee, dust);
491/// assert_eq!(result.unwrap_err(), FeeValidationError::AmountAfterFeeBelowDust {
492/// 	amount,
493/// 	fee,
494/// 	dust,
495/// 	amount_after_fee: amount - fee,
496/// });
497/// ```
498pub fn validate_and_subtract_fee_min_dust(
499	amount: Amount,
500	fee: Amount,
501	dust: Amount,
502) -> Result<Amount, FeeValidationError> {
503	let amount_after_fee = amount.checked_sub(fee)
504		.ok_or(FeeValidationError::FeeExceedsAmount { amount, fee })?;
505
506	// amount - fee must be >= dust
507	if amount_after_fee < dust {
508		return Err(FeeValidationError::AmountAfterFeeBelowDust {
509			amount,
510			fee,
511			dust,
512			amount_after_fee,
513		});
514	}
515
516	Ok(amount_after_fee)
517}
518
519/// Calculates the total fee based on the provided fee-chargeable amount, a table of PPM
520/// (Parts Per Million) expiry-based fee rates, and an iterable list of VTXO information.
521///
522/// # Parameters
523///
524/// * `fee_chargeable_amount` - An optional total amount from which the fee is chargeable. If
525///   specified, this amount determines the maximum amount to be used for fee calculations across
526///   all VTXOs. The value decreases as portions of it are consumed for each VTXOs fee calculation.
527///   If `None`, each VTXOs full amount is considered chargeable.
528///
529/// * `ppm_expiry_table` - Each entry contains an expiry threshold and a corresponding PPM fee. This
530///   table is assumed to be sorted in ascending order of `expiry_blocks_threshold` for correct
531///   behavior.
532///
533/// * `vtxos` - An iterable input of `VtxoFeeInfo`, where each element contains the amount and
534///   the number of blocks until the VTXO expires, which is relevant for fee calculation. The
535///   fee doesn't depend on the order they are provided in.
536///
537/// # Returns
538///
539/// Returns an `Amount` representing the total calculated fee based on the provided inputs,
540/// rounded up to the next satoshi. `None` if an overflow occurs.
541///
542/// # Example Usage
543///
544/// ```rust
545/// use ark::fees::{PpmExpiryFeeEntry, PpmFeeRate, VtxoFeeInfo, calc_ppm_expiry_fee};
546/// use bitcoin::Amount;
547///
548/// let fee_chargeable_amount = Some(Amount::from_sat(15_000));
549/// let ppm_expiry_table = vec![
550///     PpmExpiryFeeEntry { expiry_blocks_threshold: 10, ppm: PpmFeeRate::ONE_PERCENT },
551///     PpmExpiryFeeEntry { expiry_blocks_threshold: 20, ppm: PpmFeeRate(50_000) }, // 5%
552/// ];
553/// let vtxos = vec![
554///     VtxoFeeInfo { amount: Amount::from_sat(5_000), expiry_blocks: 2 },
555///     VtxoFeeInfo { amount: Amount::from_sat(3_000), expiry_blocks: 12 },
556///     VtxoFeeInfo { amount: Amount::from_sat(7_000), expiry_blocks: 22 },
557/// ];
558///
559/// let total_fee = calc_ppm_expiry_fee(fee_chargeable_amount, &ppm_expiry_table, vtxos);
560/// assert_eq!(total_fee, Some(Amount::from_sat(380))); // 5,000 * 0% + 3,000 * 1% + 7,000 * 5% = 380
561/// ```
562pub fn calc_ppm_expiry_fee(
563	fee_chargeable_amount: Option<Amount>,
564	ppm_expiry_table: &Vec<PpmExpiryFeeEntry>,
565	vtxos: impl IntoIterator<Item = VtxoFeeInfo>,
566) -> Option<Amount> {
567	// Charge per table entry (soonest-expiring first) instead of per VTXO: the last
568	// VTXO charged only pays on part of its amount, so charging one by one makes the
569	// fee depend on the order they come in. The leading pair collects the VTXOs no
570	// entry applies to, which pay nothing.
571	let mut entry_totals = iter::once((Amount::ZERO, PpmFeeRate::ZERO))
572		.chain(ppm_expiry_table.iter().map(|entry| (Amount::ZERO, entry.ppm)))
573		.collect::<Vec<(Amount, PpmFeeRate)>>();
574	for v in vtxos {
575		// The table order is expected to be validated by the server config.
576		let i = ppm_expiry_table
577			.iter()
578			.rposition(|entry| v.expiry_blocks >= entry.expiry_blocks_threshold)
579			.map_or(0, |i| i.saturating_add(1));
580		entry_totals[i].0 = entry_totals[i].0.checked_add(v.amount)?;
581	}
582
583	// We use the PpmFee type to accumulate sub-satoshi fee values which we can later round up
584	// to the nearest satoshi.
585	let mut total_fee = PpmFee::ZERO;
586	let mut remaining = fee_chargeable_amount;
587	for (amount, ppm) in entry_totals {
588		// If a fee_chargeable_amount was provided, we should only account for that amount, else we
589		// should assume every VTXO will be fully spent.
590		let fee_chargeable_amount = if let Some(ref mut remaining) = remaining {
591			let amount = amount.min(*remaining);
592			*remaining -= amount;
593			amount
594		} else {
595			amount
596		};
597
598		total_fee = total_fee.checked_add(fee_chargeable_amount * ppm)?;
599	}
600	total_fee.to_amount_ceil()
601}
602
603/// [calc_ppm_expiry_fee] as calculated by clients on protocol versions that apply the fee rate
604/// to each VTXO separately rather than to the total.
605#[deprecated(note = "only for protocol versions <= 3")]
606pub fn calc_ppm_expiry_fee_legacy(
607	fee_chargeable_amount: Option<Amount>,
608	ppm_expiry_table: &Vec<PpmExpiryFeeEntry>,
609	vtxos: impl IntoIterator<Item = VtxoFeeInfo>,
610) -> Option<Amount> {
611	let mut total_fee = Amount::ZERO;
612	let mut remaining = fee_chargeable_amount;
613	for v in vtxos {
614		// If we were given a total amount, we should only account for that amount, else we should
615		// assume every VTXO will be fully spent.
616		let fee_chargeable_amount = if let Some(ref mut remaining) = remaining {
617			let amount = v.amount.min(*remaining);
618			*remaining -= amount;
619			amount
620		} else {
621			v.amount
622		};
623
624		// We assume the table is sorted by expiry_blocks_threshold in ascending order
625		let entry = ppm_expiry_table
626			.iter()
627			.rev()
628			.find(|entry| v.expiry_blocks >= entry.expiry_blocks_threshold);
629
630		// If we can't find an entry that is suitable, we assume no fee is necessary
631		if let Some(entry) = entry {
632			let numerator = fee_chargeable_amount.to_sat().checked_mul(entry.ppm.0)?;
633			total_fee = total_fee.checked_add(Amount::from_sat(numerator / 1_000_000))?;
634		}
635	}
636	Some(total_fee)
637}
638
639#[cfg(test)]
640mod tests {
641	use super::*;
642
643	#[test]
644	fn test_board_fees() {
645		let mut fees = BoardFees {
646			min_fee: Amount::ZERO,
647			base_fee: Amount::from_sat(100),
648			ppm: PpmFeeRate(1_000), // 0.1%
649		};
650
651		// Test with 10,000 sats
652		let amount = Amount::from_sat(10_000);
653		let fee = fees.calculate(amount).unwrap();
654		// base (100) + (10,000 * 1,000) / 1,000,000 = 100 + 10 = 110
655		assert_eq!(fee, Amount::from_sat(110));
656
657		// Test with 10,000 sats and min fee
658		fees.min_fee = Amount::from_sat(330);
659		let amount = Amount::from_sat(10_000);
660		let fee = fees.calculate(amount).unwrap();
661		// base (100) + (10,000 * 1,000) / 1,000,000 = 100 + 10 = MAX(110, 330) = 330
662		assert_eq!(fee, Amount::from_sat(330));
663
664		// Fractional fees round up, the legacy calculation rounds down.
665		fees.min_fee = Amount::ZERO;
666		let amount = Amount::from_sat(10_500);
667		// base (100) + ceil(10.5) = 111
668		assert_eq!(fees.calculate(amount), Some(Amount::from_sat(111)));
669		#[allow(deprecated)]
670		let fee = fees.calculate_legacy(amount);
671		// base (100) + floor(10.5) = 110
672		assert_eq!(fee, Some(Amount::from_sat(110)));
673	}
674
675	#[test]
676	fn test_offboard_fees_with_single_vtxo() {
677		let fees = OffboardFees {
678			base_fee: Amount::from_sat(200),
679			fixed_additional_vb: 100,
680			ppm_expiry_table: vec![
681				PpmExpiryFeeEntry { expiry_blocks_threshold: 100, ppm: PpmFeeRate(1_000) },
682				PpmExpiryFeeEntry { expiry_blocks_threshold: 500, ppm: PpmFeeRate(2_000) },
683				PpmExpiryFeeEntry { expiry_blocks_threshold: 1_000, ppm: PpmFeeRate(3_000) },
684			],
685		};
686
687		let script_str = "6a0474657374"; // OP_RETURN, push 4 bytes with the string "test"
688		let destination = ScriptBuf::from_hex(script_str)
689			.expect("Failed to parse OP_RETURN script hex string");
690		let fee_rate = FeeRate::from_sat_per_vb_u32(10);
691		let amount = Amount::from_sat(100_000);
692
693		// Test with expiry < 100 blocks (should use 0 ppm)
694		let vtxo = VtxoFeeInfo { amount, expiry_blocks: 50 };
695		let fee = fees.calculate(&destination, amount, fee_rate, vec![vtxo]).unwrap();
696		// base (200) + ((100,000 * 0) / 1,000,000) + ((6 + 100) * 10) = 200 + 0 + 1,060 = 1,260
697		assert_eq!(fee, Amount::from_sat(1_260));
698
699		// Test with expiry = 150 blocks (should use 1,000 ppm)
700		let vtxo = VtxoFeeInfo { amount, expiry_blocks: 150 };
701		let fee = fees.calculate(&destination, amount, fee_rate, vec![vtxo]).unwrap();
702		// base (200) + ((100,000 * 1,000) / 1,000,000) + ((6 + 100) * 10) = 200 + 100 + 1,060 = 1,360
703		assert_eq!(fee, Amount::from_sat(1_360));
704
705		// Test with expiry = 750 blocks (should use 2,000 ppm)
706		let vtxo = VtxoFeeInfo { amount, expiry_blocks: 750 };
707		let fee = fees.calculate(&destination, amount, fee_rate, vec![vtxo]).unwrap();
708		// base (200) + ((100,000 * 2,000) / 1,000,000) + ((6 + 100) * 10) = 200 + 200 + 1,060 = 1,460
709		assert_eq!(fee, Amount::from_sat(1_460));
710
711		// Test with expiry = 2,000 blocks (should use 3,000 ppm)
712		let vtxo = VtxoFeeInfo { amount, expiry_blocks: 2_000 };
713		let fee = fees.calculate(&destination, amount, fee_rate, vec![vtxo]).unwrap();
714		// base (200) + ((100,000 * 3,000) / 1,000,000) + ((6 + 100) * 10) = 200 + 300 + 1,060 = 1,560
715		assert_eq!(fee, Amount::from_sat(1_560));
716	}
717
718	#[test]
719	fn test_offboard_fees_with_multiple_vtxos() {
720		let fees = OffboardFees {
721			base_fee: Amount::from_sat(200),
722			fixed_additional_vb: 100,
723			ppm_expiry_table: vec![
724				PpmExpiryFeeEntry { expiry_blocks_threshold: 100, ppm: PpmFeeRate(1_000) },
725				PpmExpiryFeeEntry { expiry_blocks_threshold: 500, ppm: PpmFeeRate(2_000) },
726			],
727		};
728
729		let script_str = "6a0474657374"; // OP_RETURN, push 4 bytes with the string "test"
730		let destination = ScriptBuf::from_hex(script_str)
731			.expect("Failed to parse OP_RETURN script hex string");
732		let fee_rate = FeeRate::from_sat_per_vb_u32(10);
733		// Test with multiple VTXOs where total VTXO value exceeds amount being sent
734		// VTXOs total 120,000 but we're only sending 100,000
735		let vtxos = vec![
736			VtxoFeeInfo { amount: Amount::from_sat(30_000), expiry_blocks: 50 },  // 0 ppm (< 100)
737			VtxoFeeInfo { amount: Amount::from_sat(50_000), expiry_blocks: 150 }, // 1,000 ppm
738			VtxoFeeInfo { amount: Amount::from_sat(40_000), expiry_blocks: 600 }, // 2,000 ppm
739		];
740
741		let amount_to_send = Amount::from_sat(100_000);
742		let fee = fees.calculate(&destination, amount_to_send, fee_rate, vtxos).unwrap();
743		// We consume VTXOs in order until we have enough:
744		// - First VTXO: 30,000 at 0 ppm -> fee = 30,000 * 0 / 1,000,000 = 0
745		// - Second VTXO: 50,000 at 1,000 ppm -> fee = 50,000 * 1,000 / 1,000,000 = 50
746		// - Third VTXO: Only need 20,000 at 2,000 ppm -> fee = 20,000 * 2,000 / 1,000,000 = 40
747		// Total: base (200) + (0 + 50 + 40) + ((6 + 100) * 10) = 200 + 90 + 1,060 = 1,350
748		assert_eq!(fee, Amount::from_sat(1_350));
749	}
750
751	#[test]
752	fn test_offboard_fees_with_no_fee_rate() {
753		let fees = OffboardFees {
754			base_fee: Amount::from_sat(200),
755			fixed_additional_vb: 100,
756			ppm_expiry_table: vec![
757				PpmExpiryFeeEntry { expiry_blocks_threshold: 1, ppm: PpmFeeRate(1_000) },
758			],
759		};
760
761		let script_str = "6a0474657374"; // OP_RETURN, push 4 bytes with the string "test"
762		let destination = ScriptBuf::from_hex(script_str)
763			.expect("Failed to parse OP_RETURN script hex string");
764		let fee_rate = FeeRate::from_sat_per_vb_u32(0);
765		let vtxos = vec![
766			VtxoFeeInfo { amount: Amount::from_sat(200_000), expiry_blocks: 50 },  // 1,000 ppm (> 1)
767		];
768
769		let amount_to_send = Amount::from_sat(100_000);
770		let fee = fees.calculate(&destination, amount_to_send, fee_rate, vtxos).unwrap();
771		// base (200) + ((100,000 * 1,000) / 1,000,000) + ((6 + 100) * 0) = 200 + 100 + 0 = 300
772		assert_eq!(fee, Amount::from_sat(300));
773	}
774
775	#[test]
776	fn test_offboard_fees_with_no_additional_vb() {
777		let fees = OffboardFees {
778			base_fee: Amount::from_sat(200),
779			fixed_additional_vb: 0,
780			ppm_expiry_table: vec![
781				PpmExpiryFeeEntry { expiry_blocks_threshold: 1, ppm: PpmFeeRate(1_000) },
782			],
783		};
784
785		let script_str = "6a0474657374"; // OP_RETURN, push 4 bytes with the string "test"
786		let destination = ScriptBuf::from_hex(script_str)
787			.expect("Failed to parse OP_RETURN script hex string");
788		let fee_rate = FeeRate::from_sat_per_vb_u32(10);
789		let vtxos = vec![
790			VtxoFeeInfo { amount: Amount::from_sat(200_000), expiry_blocks: 50 },  // 1,000 ppm (> 1)
791		];
792
793		let amount_to_send = Amount::from_sat(100_000);
794		let fee = fees.calculate(&destination, amount_to_send, fee_rate, vtxos).unwrap();
795		// base (200) + ((100,000 * 1,000) / 1,000,000) + ((6 + 0) * 10) = 200 + 100 + 60 = 360
796		assert_eq!(fee, Amount::from_sat(360));
797	}
798
799	#[test]
800	fn test_refresh_fees_with_single_vtxo() {
801		let fees = RefreshFees {
802			base_fee: Amount::from_sat(150),
803			ppm_expiry_table: vec![
804				PpmExpiryFeeEntry { expiry_blocks_threshold: 200, ppm: PpmFeeRate(500) },
805				PpmExpiryFeeEntry { expiry_blocks_threshold: 600, ppm: PpmFeeRate(1_500) },
806			],
807		};
808
809		let amount = Amount::from_sat(200_000);
810
811		// Test with expiry = 400 blocks (should use 500 ppm)
812		let vtxo = VtxoFeeInfo { amount, expiry_blocks: 400 };
813		let fee = fees.calculate(vec![vtxo]).unwrap();
814		// base (150) + (200,000 * 500) / 1,000,000 = 150 + 100 = 250
815		assert_eq!(fee, Amount::from_sat(250));
816
817		// Test with expiry = 800 blocks (should use 1,500 ppm)
818		let vtxo = VtxoFeeInfo { amount, expiry_blocks: 800 };
819		let fee = fees.calculate(vec![vtxo]).unwrap();
820		// base (150) + (200,000 * 1,500) / 1,000,000 = 150 + 300 = 450
821		assert_eq!(fee, Amount::from_sat(450));
822	}
823
824	#[test]
825	fn test_refresh_fees_with_multiple_vtxos() {
826		let fees = RefreshFees {
827			base_fee: Amount::from_sat(50),
828			ppm_expiry_table: vec![
829				PpmExpiryFeeEntry { expiry_blocks_threshold: 200, ppm: PpmFeeRate(500) },
830				PpmExpiryFeeEntry { expiry_blocks_threshold: 600, ppm: PpmFeeRate(1_500) },
831			],
832		};
833
834		// Test with multiple VTXOs
835		let vtxos = vec![
836			VtxoFeeInfo { amount: Amount::from_sat(70_000), expiry_blocks: 100 },  // 0 ppm (< 200)
837			VtxoFeeInfo { amount: Amount::from_sat(100_000), expiry_blocks: 300 }, // 500 ppm
838			VtxoFeeInfo { amount: Amount::from_sat(80_000), expiry_blocks: 700 },  // 1,500 ppm
839		];
840
841		let fee = fees.calculate(vtxos).unwrap();
842		// We consume VTXOs in order until we have enough:
843		// - First VTXO: 70,000 at 0 ppm -> fee = 70,000 * 0 / 1,000,000 = 0
844		// - Second VTXO: 100,000 at 500 ppm -> fee = 100,000 * 500 / 1,000,000 = 50
845		// - Third VTXO: 80,000 at 1,500 ppm -> fee = 80,000 * 1,500 / 1,000,000 = 120
846		// Total: base (50) + 0 + 50 + 120 = 220
847		assert_eq!(fee, Amount::from_sat(220));
848	}
849
850	#[test]
851	fn test_lightning_receive_fees() {
852		let fees = LightningReceiveFees {
853			base_fee: Amount::from_sat(100),
854			ppm: PpmFeeRate(2_000), // 0.2%
855		};
856
857		let amount = Amount::from_sat(10_000);
858		let fee = fees.calculate(amount).unwrap();
859		// base (100) + (10,000 * 2,000) / 1,000,000 = 100 + 20 = 120
860		assert_eq!(fee, Amount::from_sat(120));
861
862		// Fractional fees round up, the legacy calculation rounds down.
863		let amount = Amount::from_sat(10_400);
864		// base (100) + ceil(20.8) = 121
865		assert_eq!(fees.calculate(amount), Some(Amount::from_sat(121)));
866		#[allow(deprecated)]
867		let fee = fees.calculate_legacy(amount);
868		// base (100) + floor(20.8) = 120
869		assert_eq!(fee, Some(Amount::from_sat(120)));
870	}
871
872	#[test]
873	fn test_lightning_send_fees_with_single_vtxo() {
874		let mut fees = LightningSendFees {
875			min_fee: Amount::from_sat(10),
876			base_fee: Amount::from_sat(75),
877			ppm_expiry_table: vec![
878				PpmExpiryFeeEntry { expiry_blocks_threshold: 50, ppm: PpmFeeRate(250) },
879				PpmExpiryFeeEntry { expiry_blocks_threshold: 100, ppm: PpmFeeRate(750) },
880			],
881		};
882
883		let amount = Amount::from_sat(1_000_000);
884
885		// Test with expiry = 75 blocks (should use 250 ppm)
886		let vtxo = VtxoFeeInfo { amount, expiry_blocks: 75 };
887		let fee = fees.calculate(amount, vec![vtxo]).unwrap();
888		// base (75) + (1,000,000 * 250) / 1,000,000 = 75 + 250 = 325
889		assert_eq!(fee, Amount::from_sat(325));
890
891		// Test with expiry = 150 blocks (should use 750 ppm)
892		let vtxo = VtxoFeeInfo { amount, expiry_blocks: 150 };
893		let fee = fees.calculate(amount, vec![vtxo]).unwrap();
894		// base (75) + (1,000,000 * 750) / 1,000,000 = 75 + 750 = 825
895		assert_eq!(fee, Amount::from_sat(825));
896
897		// Test with 1,000 sats and min fee
898		fees.min_fee = Amount::from_sat(330);
899		let vtxo = VtxoFeeInfo { amount: Amount::from_sat(1_000), expiry_blocks: 150 };
900		let fee = fees.calculate(amount, vec![vtxo]).unwrap();
901		// base (75) + ceil((1,000 * 750) / 1,000,000) = 75 + 1 = MAX(76, 330) = 330
902		assert_eq!(fee, Amount::from_sat(330));
903	}
904
905	#[test]
906	fn test_lightning_send_fees_with_multiple_vtxos() {
907		let fees = LightningSendFees {
908			min_fee: Amount::from_sat(10),
909			base_fee: Amount::from_sat(25),
910			ppm_expiry_table: vec![
911				PpmExpiryFeeEntry { expiry_blocks_threshold: 50, ppm: PpmFeeRate(250) },
912				PpmExpiryFeeEntry { expiry_blocks_threshold: 100, ppm: PpmFeeRate(750) },
913				PpmExpiryFeeEntry { expiry_blocks_threshold: 200, ppm: PpmFeeRate(1_500) },
914			],
915		};
916
917		// Test with multiple VTXOs where the total VTXO value exceeds the amount being paid.
918		// The VTXOs total 1,500,000 sats but we're only sending 1,000,000.
919		let vtxos = vec![
920			VtxoFeeInfo { amount: Amount::from_sat(400_000), expiry_blocks: 75 },  // 250 ppm
921			VtxoFeeInfo { amount: Amount::from_sat(500_000), expiry_blocks: 150 }, // 750 ppm
922			VtxoFeeInfo { amount: Amount::from_sat(600_000), expiry_blocks: 250 }, // 1,500 ppm
923		];
924
925		let amount_to_send = Amount::from_sat(1_000_000);
926		let fee = fees.calculate(amount_to_send, vtxos).unwrap();
927		// We consume VTXOs in order until we have enough:
928		// - First VTXO: 400,000 at 250 ppm -> fee = 400,000 * 250 / 1,000,000 = 100
929		// - Second VTXO: 500,000 at 750 ppm -> fee = 500,000 * 750 / 1,000,000 = 375
930		// - Third VTXO: only need 100,000 at 1,500 ppm -> fee = 100,000 * 1,500 / 1,000,000 = 150
931		// Total: base (25) + 100 + 375 + 150 = 650
932		assert_eq!(fee, Amount::from_sat(650));
933	}
934
935	#[test]
936	#[allow(deprecated)]
937	fn test_ppm_expiry_fee_totals() {
938		let table = vec![
939			PpmExpiryFeeEntry { expiry_blocks_threshold: 1_008, ppm: PpmFeeRate(2_000) },
940			PpmExpiryFeeEntry { expiry_blocks_threshold: 2_016, ppm: PpmFeeRate(4_000) },
941		];
942
943		// Small amounts truncate to zero per VTXO but not on the total.
944		let vtxos = vec![VtxoFeeInfo { amount: Amount::from_sat(330), expiry_blocks: 1_500 }; 100];
945		let fee = calc_ppm_expiry_fee_legacy(None, &table, vtxos.clone());
946		// floor(330 * 2,000 / 1,000,000) = 0 per VTXO
947		assert_eq!(fee, Some(Amount::ZERO));
948		let fee = calc_ppm_expiry_fee(None, &table, vtxos);
949		// 100 * 330 = 33,000; 33,000 * 2,000 / 1,000,000 = 66
950		assert_eq!(fee, Some(Amount::from_sat(66)));
951
952		// The fee is rounded once on the total across entries.
953		let vtxos = vec![
954			VtxoFeeInfo { amount: Amount::from_sat(900), expiry_blocks: 1_100 },
955			VtxoFeeInfo { amount: Amount::from_sat(900), expiry_blocks: 1_200 },
956			VtxoFeeInfo { amount: Amount::from_sat(1_300), expiry_blocks: 2_500 },
957		];
958		let fee = calc_ppm_expiry_fee_legacy(None, &table, vtxos.clone());
959		// floor(1.8) + floor(1.8) + floor(5.2) = 1 + 1 + 5 = 7
960		assert_eq!(fee, Some(Amount::from_sat(7)));
961		let fee = calc_ppm_expiry_fee(None, &table, vtxos);
962		// ceil((1,800 * 2,000 + 1,300 * 4,000) / 1,000,000) = ceil(8.8) = 9
963		assert_eq!(fee, Some(Amount::from_sat(9)));
964
965		// A capped chargeable amount is allocated to VTXOs in order in both variants.
966		let vtxos = vec![
967			VtxoFeeInfo { amount: Amount::from_sat(900), expiry_blocks: 1_100 },
968			VtxoFeeInfo { amount: Amount::from_sat(900), expiry_blocks: 1_200 },
969			VtxoFeeInfo { amount: Amount::from_sat(900), expiry_blocks: 2_500 },
970		];
971		let cap = Some(Amount::from_sat(1_500));
972		let fee = calc_ppm_expiry_fee_legacy(cap, &table, vtxos.clone());
973		// Chargeable 900 + 600 + 0: floor(1.8) + floor(1.2) = 2
974		assert_eq!(fee, Some(Amount::from_sat(2)));
975		let fee = calc_ppm_expiry_fee(cap, &table, vtxos);
976		// Chargeable 900 + 600 + 0: ceil(1,500 * 2,000 / 1,000,000) = 3
977		assert_eq!(fee, Some(Amount::from_sat(3)));
978
979		// VTXOs below every threshold are free in both variants.
980		let vtxos = vec![VtxoFeeInfo { amount: Amount::from_sat(100_000), expiry_blocks: 500 }; 10];
981		let fee = calc_ppm_expiry_fee_legacy(None, &table, vtxos.clone());
982		assert_eq!(fee, Some(Amount::ZERO));
983		let fee = calc_ppm_expiry_fee(None, &table, vtxos);
984		assert_eq!(fee, Some(Amount::ZERO));
985	}
986
987	#[test]
988	fn test_ppm_expiry_fee_lagging_tip_pays_at_least_ours() {
989		let table = vec![
990			PpmExpiryFeeEntry { expiry_blocks_threshold: 0, ppm: PpmFeeRate::ZERO },
991			PpmExpiryFeeEntry { expiry_blocks_threshold: 1_008, ppm: PpmFeeRate(2_000) },
992			PpmExpiryFeeEntry { expiry_blocks_threshold: 2_016, ppm: PpmFeeRate(4_000) },
993		];
994
995		// A party one block behind charges threshold-straddling VTXOs at the next entry.
996		// The single rounding on the total keeps its fee monotone in the entry rates, so
997		// it always covers our own calculation.
998		let ours = vec![
999			VtxoFeeInfo { amount: Amount::from_sat(100), expiry_blocks: 2_015 },
1000			VtxoFeeInfo { amount: Amount::from_sat(900), expiry_blocks: 1_500 },
1001		];
1002		let theirs = vec![
1003			VtxoFeeInfo { amount: Amount::from_sat(100), expiry_blocks: 2_016 },
1004			VtxoFeeInfo { amount: Amount::from_sat(900), expiry_blocks: 1_501 },
1005		];
1006		// ceil((100 * 2,000 + 900 * 2,000) / 1,000,000) = ceil(2.0) = 2
1007		let ours = calc_ppm_expiry_fee(None, &table, ours).unwrap();
1008		assert_eq!(ours, Amount::from_sat(2));
1009		// ceil((100 * 4,000 + 900 * 2,000) / 1,000,000) = ceil(2.2) = 3
1010		let theirs = calc_ppm_expiry_fee(None, &table, theirs).unwrap();
1011		assert_eq!(theirs, Amount::from_sat(3));
1012		assert!(theirs >= ours);
1013	}
1014
1015	/// A chargeable amount below the VTXO sum leaves one VTXO partially charged,
1016	/// so the fee must not depend on the order the VTXOs come in: the client
1017	/// selects them soonest-expiring first, while the server charges them in the
1018	/// order they arrived on the wire.
1019	#[test]
1020	fn test_ppm_expiry_fee_ignores_vtxo_order() {
1021		// Take the given slice and generate every single order permutation so we can validate that
1022		// the result remains consistent regardless of order.
1023		fn permutations(vtxos: &[VtxoFeeInfo]) -> Vec<Vec<VtxoFeeInfo>> {
1024			if vtxos.len() <= 1 {
1025				return vec![vtxos.to_vec()];
1026			}
1027			let mut out = Vec::new();
1028			for i in 0..vtxos.len() {
1029				let mut rest = vtxos.to_vec();
1030				let head = rest.remove(i);
1031				for mut p in permutations(&rest) {
1032					p.insert(0, head);
1033					out.push(p);
1034				}
1035			}
1036			out
1037		}
1038
1039		let ppm_expiry_table = vec![
1040			PpmExpiryFeeEntry { expiry_blocks_threshold: 0, ppm: PpmFeeRate(2_000) },
1041			PpmExpiryFeeEntry { expiry_blocks_threshold: 1_008, ppm: PpmFeeRate(4_000) },
1042			PpmExpiryFeeEntry { expiry_blocks_threshold: 2_016, ppm: PpmFeeRate(5_000) },
1043		];
1044		// A send-onchain of 208,246 sats out of five VTXOs worth 209,705, spanning
1045		// all three brackets.
1046		let vtxos = vec![
1047			VtxoFeeInfo { amount: Amount::from_sat(27_422), expiry_blocks: 454 },
1048			VtxoFeeInfo { amount: Amount::from_sat(102_408), expiry_blocks: 455 },
1049			VtxoFeeInfo { amount: Amount::from_sat(68_456), expiry_blocks: 1_122 },
1050			VtxoFeeInfo { amount: Amount::from_sat(1_320), expiry_blocks: 2_046 },
1051			VtxoFeeInfo { amount: Amount::from_sat(10_099), expiry_blocks: 2_395 },
1052		];
1053
1054		// Soonest-expiring first: 27,422 and 102,408 at 2,000 ppm, 68,456 at 4,000
1055		// ppm, then 1,320 and 8,640 of the last VTXO at 5,000 ppm, leaving 1,459
1056		// sats uncharged. 583.284 sats, rounded up.
1057		let chargeable = Some(Amount::from_sat(208_246));
1058		let expected = Amount::from_sat(584);
1059
1060		let orders = permutations(&vtxos);
1061		assert_eq!(orders.len(), 120);
1062		for order in orders {
1063			assert_eq!(
1064				calc_ppm_expiry_fee(chargeable, &ppm_expiry_table, order.clone()),
1065				Some(expected),
1066				"fee changed for order {:?}", order.iter().map(|v| v.amount).collect::<Vec<_>>(),
1067			);
1068		}
1069	}
1070}