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