Skip to main content

ark/
fees.rs

1use std::cmp::PartialOrd;
2use std::ops;
3
4use bitcoin::{Amount, FeeRate, ScriptBuf, Weight};
5
6use bitcoin_ext::{BlockHeight, P2TR_DUST};
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 = self.ppm.checked_mul(amount)?.checked_add(self.base_fee)?;
120		Some(fee.max(self.min_fee))
121	}
122}
123
124/// Fees for offboarding from the ark.
125#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
126pub struct OffboardFees {
127	/// A fee applied to every transaction regardless of value.
128	#[serde(rename = "base_fee_sat", with = "bitcoin::amount::serde::as_sat")]
129	pub base_fee: Amount,
130
131	/// Fixed number of virtual bytes charged offboard on top of the output size.
132	///
133	/// The fee for an offboard will be this value, plus the offboard output virtual size,
134	/// multiplied with the offboard fee rate, plus the `base_fee`, and plus the additional fee
135	/// calculated with the `ppm_expiry_table`.
136	pub fixed_additional_vb: u64,
137
138	/// A table mapping how soon a VTXO will expire to a PPM (parts per million) fee rate.
139	/// The table should be sorted by each `expiry_blocks_threshold` value in ascending order.
140	pub ppm_expiry_table: Vec<PpmExpiryFeeEntry>,
141}
142
143impl OffboardFees {
144	/// Returns the fee charged for the user to make an offboard given the fee rate.
145	///
146	/// Returns `None` in the calculation overflows because of insane destinations or fee rates.
147	pub fn calculate(
148		&self,
149		destination: &ScriptBuf,
150		amount: Amount,
151		fee_rate: FeeRate,
152		vtxos: impl IntoIterator<Item = VtxoFeeInfo>,
153	) -> Option<Amount> {
154		let weight_fee = self.fixed_additional_vb.checked_add(destination.as_script().len() as u64)
155			.and_then(Weight::from_vb)
156			.and_then(|w| fee_rate.checked_mul_by_weight(w))?;
157		let ppm_fee = calc_ppm_expiry_fee(Some(amount), &self.ppm_expiry_table, vtxos)?;
158		self.base_fee.checked_add(weight_fee)?.checked_add(ppm_fee)
159	}
160}
161
162/// Fees for refresh operations.
163#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
164pub struct RefreshFees {
165	/// A fee applied to every transaction regardless of value.
166	#[serde(rename = "base_fee_sat", with = "bitcoin::amount::serde::as_sat")]
167	pub base_fee: Amount,
168	/// A table mapping how soon a VTXO will expire to a PPM (parts per million) fee rate.
169	/// The table should be sorted by each `expiry_blocks_threshold` value in ascending order.
170	pub ppm_expiry_table: Vec<PpmExpiryFeeEntry>,
171}
172
173impl RefreshFees {
174	/// Calculate the total fee for a refresh operation.
175	///
176	/// Returns `None` if an overflow occurs.
177	pub fn calculate(
178		&self,
179		vtxos: impl IntoIterator<Item = VtxoFeeInfo>,
180	) -> Option<Amount> {
181		self.base_fee.checked_add(self.calculate_no_base_fee(vtxos)?)
182	}
183
184	/// Calculate the fee for a refresh operation, excluding the base fee.
185	///
186	/// Returns `None` if an overflow occurs.
187	pub fn calculate_no_base_fee(
188		&self,
189		vtxos: impl IntoIterator<Item = VtxoFeeInfo>,
190	) -> Option<Amount> {
191		calc_ppm_expiry_fee(None, &self.ppm_expiry_table, vtxos)
192	}
193}
194
195/// Fees for lightning receive operations.
196#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
197pub struct LightningReceiveFees {
198	/// A fee applied to every transaction regardless of value.
199	#[serde(rename = "base_fee_sat", with = "bitcoin::amount::serde::as_sat")]
200	pub base_fee: Amount,
201	/// PPM (parts per million) fee rate to apply based on the value of the transaction.
202	pub ppm: PpmFeeRate,
203}
204
205impl LightningReceiveFees {
206	/// Calculate the total fee for a lightning receive operation.
207	///
208	/// Returns `None` if an overflow occurs.
209	pub fn calculate(&self, amount: Amount) -> Option<Amount> {
210		self.base_fee.checked_add(self.ppm.checked_mul(amount)?)
211	}
212}
213
214/// Fees for lightning send operations.
215#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
216pub struct LightningSendFees {
217	/// Minimum fee to charge.
218	#[serde(rename = "min_fee_sat", with = "bitcoin::amount::serde::as_sat")]
219	pub min_fee: Amount,
220	/// A fee applied to every transaction regardless of value.
221	#[serde(rename = "base_fee_sat", with = "bitcoin::amount::serde::as_sat")]
222	pub base_fee: Amount,
223	/// A table mapping how soon a VTXO will expire to a PPM (parts per million) fee rate.
224	/// The table should be sorted by each `expiry_blocks_threshold` value in ascending order.
225	pub ppm_expiry_table: Vec<PpmExpiryFeeEntry>,
226}
227
228impl LightningSendFees {
229	/// Calculate the total fee for a lightning send operation.
230	///
231	/// Returns `None` if an overflow occurs.
232	pub fn calculate(
233		&self,
234		amount: Amount,
235		vtxos: impl IntoIterator<Item = VtxoFeeInfo>,
236	) -> Option<Amount> {
237		let ppm = calc_ppm_expiry_fee(Some(amount), &self.ppm_expiry_table, vtxos)?;
238		Some(self.base_fee.checked_add(ppm)?.max(self.min_fee))
239	}
240}
241
242/// A very basic struct to hold information for use in calculating the fees of transactions.
243#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
244pub struct VtxoFeeInfo {
245	/// The total amount of the VTXO.
246	pub amount: Amount,
247	/// Number of blocks until expiry.
248	pub expiry_blocks: u32,
249}
250
251impl VtxoFeeInfo {
252	/// Constructs a [VtxoFeeInfo] instance from the given [Vtxo] and tip [BlockHeight]
253	pub fn from_vtxo_and_tip<G>(vtxo: &Vtxo<G>, tip: BlockHeight) -> Self {
254		Self {
255			amount: vtxo.amount(),
256			expiry_blocks: vtxo.expiry_height().saturating_sub(tip),
257		}
258	}
259}
260
261#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Deserialize, Serialize)]
262pub struct PpmFeeRate(pub u64);
263
264impl PpmFeeRate {
265	/// The zero amount.
266	pub const ZERO: PpmFeeRate = PpmFeeRate(0);
267	/// Represents a fee rate of 1%.
268	pub const ONE_PERCENT: PpmFeeRate = PpmFeeRate(10_000);
269
270	/// Multiplies the given amount by this fee rate. Returns `None` if the result overflows.
271	pub fn checked_mul(self, other: Amount) -> Option<Amount> {
272		let numerator = other.to_sat().checked_mul(self.0)?;
273		Some(Amount::from_sat(numerator / 1_000_000))
274	}
275}
276
277impl ops::Mul<PpmFeeRate> for Amount {
278	type Output = Amount;
279
280	/// Calculates a fee value for the current amount using a parts-per-million (PPM) rate.
281	///
282	/// # Returns
283	///
284	/// Returns the calculated fee as an `Amount`. The result is truncated using integer division,
285	/// and any overflow is capped with u64::MAX.
286	///
287	/// # Example
288	///
289	/// ```rust
290	/// use ark::fees::PpmFeeRate;
291	/// use bitcoin::Amount;
292	///
293	/// let fee_chargeable_amount = Amount::from_sat(10_000);
294	/// let ppm = PpmFeeRate(5_000); // 0.5%
295	/// let fee = fee_chargeable_amount * ppm;
296	/// assert_eq!(fee, Amount::from_sat(50)); // 10,000 * 5,000 / 1,000,000 = 50
297	/// ```
298	fn mul(self, ppm: PpmFeeRate) -> Self::Output {
299		let numerator = self.to_sat().saturating_mul(ppm.0);
300		Amount::from_sat(numerator / 1_000_000)
301	}
302}
303
304/// Entry in a table to calculate the PPM (parts per million) fee rate of a transaction based on how
305/// new a VTXO is.
306#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
307pub struct PpmExpiryFeeEntry {
308	/// A threshold for the number of blocks until a VTXO expires for the `ppm` amount to apply.
309	/// As an example, if this value is set to 50 and a VTXO expires in 60 blocks, this
310	/// [PpmExpiryFeeEntry] will be used to calculate the fee unless another entry exists with an
311	/// `expiry_blocks_threshold` with a value between 51 and 60 (inclusive).
312	pub expiry_blocks_threshold: u32,
313	/// PPM (parts per million) fee rate to apply for this expiry period.
314	pub ppm: PpmFeeRate,
315}
316
317/// Error types for fee validation.
318#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq, Hash)]
319pub enum FeeValidationError {
320	#[error("Fee ({fee}) exceeds amount ({amount})")]
321	FeeExceedsAmount { amount: Amount, fee: Amount },
322
323	#[error("Amount after fee ({amount_after_fee}) is below dust limit ({P2TR_DUST}). Amount: {amount}, Fee: {fee}")]
324	AmountAfterFeeBelowDust {
325		amount: Amount,
326		fee: Amount,
327		amount_after_fee: Amount,
328	},
329}
330
331/// Validates fee amounts and calculates the resulting amount after fee.
332///
333/// This function ensures two critical conditions are met:
334/// 1. Fee doesn't exceed the original amount (prevents overflow)
335/// 2. Amount after fee is > zero
336///
337/// # Returns
338/// * `Ok(Amount)` - The amount after subtracting the fee
339/// * `Err(FeeValidationError)` - If any validation condition fails
340///
341/// # Example
342/// ```
343/// use ark::fees::{validate_and_subtract_fee, FeeValidationError};
344/// use bitcoin::Amount;
345///
346/// let amount = Amount::from_sat(10_000);
347/// let fee = Amount::from_sat(100);
348/// let result = validate_and_subtract_fee(amount, fee);
349/// assert_eq!(result.unwrap(), Amount::from_sat(9_900));
350///
351/// let amount = Amount::from_sat(10_000);
352/// let fee = Amount::from_sat(10_000);
353/// let result = validate_and_subtract_fee(amount, fee);
354/// assert_eq!(result.unwrap_err(), FeeValidationError::FeeExceedsAmount { amount, fee });
355///
356/// let amount = Amount::from_sat(10_000);
357/// let fee = Amount::from_sat(11_000);
358/// let result = validate_and_subtract_fee(amount, fee);
359/// assert_eq!(result.unwrap_err(), FeeValidationError::FeeExceedsAmount { amount, fee });
360/// ```
361pub fn validate_and_subtract_fee(
362	amount: Amount,
363	fee: Amount,
364) -> Result<Amount, FeeValidationError> {
365	let amount_after_fee = amount.checked_sub(fee)
366		.ok_or(FeeValidationError::FeeExceedsAmount { amount, fee })?;
367
368	if amount_after_fee == Amount::ZERO {
369		Err(FeeValidationError::FeeExceedsAmount { amount, fee })
370	} else {
371		Ok(amount_after_fee)
372	}
373}
374
375/// Validates fee amounts and calculates the resulting amount after fee.
376///
377/// This function ensures two critical conditions are met:
378/// 1. Fee doesn't exceed the original amount (prevents overflow)
379/// 2. Amount after fee is >= P2TR_DUST (ensures economically viable output)
380///
381/// # Returns
382/// * `Ok(Amount)` - The amount after subtracting the fee
383/// * `Err(FeeValidationError)` - If any validation condition fails
384///
385/// # Example
386/// ```
387/// use ark::fees::{validate_and_subtract_fee_min_dust, FeeValidationError};
388/// use bitcoin::Amount;
389/// use bitcoin_ext::P2TR_DUST;
390///
391/// let amount = Amount::from_sat(10_000);
392/// let fee = Amount::from_sat(100);
393/// let result = validate_and_subtract_fee_min_dust(amount, fee);
394/// assert_eq!(result.unwrap(), Amount::from_sat(9_900));
395///
396/// let amount = Amount::from_sat(10_000);
397/// let fee = Amount::from_sat(9_670);
398/// let result = validate_and_subtract_fee_min_dust(amount, fee);
399/// assert_eq!(result.unwrap(), P2TR_DUST);
400///
401/// let amount = Amount::from_sat(10_000);
402/// let fee = Amount::from_sat(11_000);
403/// let result = validate_and_subtract_fee_min_dust(amount, fee);
404/// assert_eq!(result.unwrap_err(), FeeValidationError::FeeExceedsAmount { amount, fee });
405///
406/// let amount = Amount::from_sat(10_000);
407/// let fee = Amount::from_sat(10_000);
408/// let result = validate_and_subtract_fee_min_dust(amount, fee);
409/// assert_eq!(result.unwrap_err(), FeeValidationError::AmountAfterFeeBelowDust {
410/// 	amount,
411/// 	fee,
412/// 	amount_after_fee: amount - fee,
413/// });
414/// ```
415pub fn validate_and_subtract_fee_min_dust(
416	amount: Amount,
417	fee: Amount,
418) -> Result<Amount, FeeValidationError> {
419	let amount_after_fee = amount.checked_sub(fee)
420		.ok_or(FeeValidationError::FeeExceedsAmount { amount, fee })?;
421
422	// amount - fee must be >= P2TR_DUST
423	if amount_after_fee < P2TR_DUST {
424		return Err(FeeValidationError::AmountAfterFeeBelowDust {
425			amount,
426			fee,
427			amount_after_fee,
428		});
429	}
430
431	Ok(amount_after_fee)
432}
433
434/// Calculates the total fee based on the provided fee-chargeable amount, a table of PPM
435/// (Parts Per Million) expiry-based fee rates, and an iterable list of VTXO information.
436///
437/// # Parameters
438///
439/// * `fee_chargeable_amount` - An optional total amount from which the fee is chargeable. If
440///   specified, this amount determines the maximum amount to be used for fee calculations across
441///   all VTXOs. The value decreases as portions of it are consumed for each VTXOs fee calculation.
442///   If `None`, each VTXOs full amount is considered chargeable.
443///
444/// * `ppm_expiry_table` - Each entry contains an expiry threshold and a corresponding PPM fee. This
445///   table is assumed to be sorted in ascending order of `expiry_blocks_threshold` for correct
446///   behavior.
447///
448/// * `vtxos` - An iterable input of `VtxoFeeInfo`, where each element contains the amount and
449///   the number of blocks until the VTXO expires, which is relevant for fee calculation.
450///
451/// # Returns
452///
453/// Returns an `Amount` representing the total calculated fee based on the provided inputs. `None`
454/// if an overflow occurs.
455///
456/// # Example Usage
457///
458/// ```rust
459/// use ark::fees::{PpmExpiryFeeEntry, PpmFeeRate, VtxoFeeInfo, calc_ppm_expiry_fee};
460/// use bitcoin::Amount;
461///
462/// let fee_chargeable_amount = Some(Amount::from_sat(15_000));
463/// let ppm_expiry_table = vec![
464///     PpmExpiryFeeEntry { expiry_blocks_threshold: 10, ppm: PpmFeeRate::ONE_PERCENT },
465///     PpmExpiryFeeEntry { expiry_blocks_threshold: 20, ppm: PpmFeeRate(50_000) }, // 5%
466/// ];
467/// let vtxos = vec![
468///     VtxoFeeInfo { amount: Amount::from_sat(5_000), expiry_blocks: 2 },
469///     VtxoFeeInfo { amount: Amount::from_sat(3_000), expiry_blocks: 12 },
470///     VtxoFeeInfo { amount: Amount::from_sat(7_000), expiry_blocks: 22 },
471/// ];
472///
473/// let total_fee = calc_ppm_expiry_fee(fee_chargeable_amount, &ppm_expiry_table, vtxos);
474/// assert_eq!(total_fee, Some(Amount::from_sat(380))); // 5,000 * 0% + 3,000 * 1% + 7,000 * 5% = 380
475/// ```
476pub fn calc_ppm_expiry_fee(
477	fee_chargeable_amount: Option<Amount>,
478	ppm_expiry_table: &Vec<PpmExpiryFeeEntry>,
479	vtxos: impl IntoIterator<Item = VtxoFeeInfo>,
480) -> Option<Amount> {
481	let mut total_fee = Amount::ZERO;
482	let mut remaining = fee_chargeable_amount;
483	for v in vtxos {
484		// If we were given a total amount, we should only account for that amount, else we should
485		// assume every VTXO will be fully spent.
486		let fee_chargeable_amount = if let Some(ref mut remaining) = remaining {
487			let amount = v.amount.min(*remaining);
488			*remaining -= amount;
489			amount
490		} else {
491			v.amount
492		};
493
494		// We assume the table is sorted by expiry_blocks_threshold in ascending order
495		let entry = ppm_expiry_table
496			.iter()
497			.rev()
498			.find(|entry| v.expiry_blocks >= entry.expiry_blocks_threshold);
499
500		// If we can't find an entry that is suitable, we assume no fee is necessary
501		if let Some(entry) = entry {
502			total_fee = total_fee.checked_add(entry.ppm.checked_mul(fee_chargeable_amount)?)?;
503		}
504	}
505	Some(total_fee)
506}
507
508#[cfg(test)]
509mod tests {
510	use super::*;
511
512	#[test]
513	fn test_board_fees() {
514		let mut fees = BoardFees {
515			min_fee: Amount::ZERO,
516			base_fee: Amount::from_sat(100),
517			ppm: PpmFeeRate(1_000), // 0.1%
518		};
519
520		// Test with 10,000 sats
521		let amount = Amount::from_sat(10_000);
522		let fee = fees.calculate(amount).unwrap();
523		// base (100) + (10,000 * 1,000) / 1,000,000 = 100 + 10 = 110
524		assert_eq!(fee, Amount::from_sat(110));
525
526		// Test with 10,000 sats and min fee
527		fees.min_fee = Amount::from_sat(330);
528		let amount = Amount::from_sat(10_000);
529		let fee = fees.calculate(amount).unwrap();
530		// base (100) + (10,000 * 1,000) / 1,000,000 = 100 + 10 = MAX(110, 330) = 330
531		assert_eq!(fee, Amount::from_sat(330));
532	}
533
534	#[test]
535	fn test_offboard_fees_with_single_vtxo() {
536		let fees = OffboardFees {
537			base_fee: Amount::from_sat(200),
538			fixed_additional_vb: 100,
539			ppm_expiry_table: vec![
540				PpmExpiryFeeEntry { expiry_blocks_threshold: 100, ppm: PpmFeeRate(1_000) },
541				PpmExpiryFeeEntry { expiry_blocks_threshold: 500, ppm: PpmFeeRate(2_000) },
542				PpmExpiryFeeEntry { expiry_blocks_threshold: 1_000, ppm: PpmFeeRate(3_000) },
543			],
544		};
545
546		let script_str = "6a0474657374"; // OP_RETURN, push 4 bytes with the string "test"
547		let destination = ScriptBuf::from_hex(script_str)
548			.expect("Failed to parse OP_RETURN script hex string");
549		let fee_rate = FeeRate::from_sat_per_vb_unchecked(10);
550		let amount = Amount::from_sat(100_000);
551
552		// Test with expiry < 100 blocks (should use 0 ppm)
553		let vtxo = VtxoFeeInfo { amount, expiry_blocks: 50 };
554		let fee = fees.calculate(&destination, amount, fee_rate, vec![vtxo]).unwrap();
555		// base (200) + ((100,000 * 0) / 1,000,000) + ((6 + 100) * 10) = 200 + 0 + 1,060 = 1,260
556		assert_eq!(fee, Amount::from_sat(1_260));
557
558		// Test with expiry = 150 blocks (should use 1,000 ppm)
559		let vtxo = VtxoFeeInfo { amount, expiry_blocks: 150 };
560		let fee = fees.calculate(&destination, amount, fee_rate, vec![vtxo]).unwrap();
561		// base (200) + ((100,000 * 1,000) / 1,000,000) + ((6 + 100) * 10) = 200 + 100 + 1,060 = 1,360
562		assert_eq!(fee, Amount::from_sat(1_360));
563
564		// Test with expiry = 750 blocks (should use 2,000 ppm)
565		let vtxo = VtxoFeeInfo { amount, expiry_blocks: 750 };
566		let fee = fees.calculate(&destination, amount, fee_rate, vec![vtxo]).unwrap();
567		// base (200) + ((100,000 * 2,000) / 1,000,000) + ((6 + 100) * 10) = 200 + 200 + 1,060 = 1,460
568		assert_eq!(fee, Amount::from_sat(1_460));
569
570		// Test with expiry = 2,000 blocks (should use 3,000 ppm)
571		let vtxo = VtxoFeeInfo { amount, expiry_blocks: 2_000 };
572		let fee = fees.calculate(&destination, amount, fee_rate, vec![vtxo]).unwrap();
573		// base (200) + ((100,000 * 3,000) / 1,000,000) + ((6 + 100) * 10) = 200 + 300 + 1,060 = 1,560
574		assert_eq!(fee, Amount::from_sat(1_560));
575	}
576
577	#[test]
578	fn test_offboard_fees_with_multiple_vtxos() {
579		let fees = OffboardFees {
580			base_fee: Amount::from_sat(200),
581			fixed_additional_vb: 100,
582			ppm_expiry_table: vec![
583				PpmExpiryFeeEntry { expiry_blocks_threshold: 100, ppm: PpmFeeRate(1_000) },
584				PpmExpiryFeeEntry { expiry_blocks_threshold: 500, ppm: PpmFeeRate(2_000) },
585			],
586		};
587
588		let script_str = "6a0474657374"; // OP_RETURN, push 4 bytes with the string "test"
589		let destination = ScriptBuf::from_hex(script_str)
590			.expect("Failed to parse OP_RETURN script hex string");
591		let fee_rate = FeeRate::from_sat_per_vb_unchecked(10);
592		// Test with multiple VTXOs where total VTXO value exceeds amount being sent
593		// VTXOs total 120,000 but we're only sending 100,000
594		let vtxos = vec![
595			VtxoFeeInfo { amount: Amount::from_sat(30_000), expiry_blocks: 50 },  // 0 ppm (< 100)
596			VtxoFeeInfo { amount: Amount::from_sat(50_000), expiry_blocks: 150 }, // 1,000 ppm
597			VtxoFeeInfo { amount: Amount::from_sat(40_000), expiry_blocks: 600 }, // 2,000 ppm
598		];
599
600		let amount_to_send = Amount::from_sat(100_000);
601		let fee = fees.calculate(&destination, amount_to_send, fee_rate, vtxos).unwrap();
602		// We consume VTXOs in order until we have enough:
603		// - First VTXO: 30,000 at 0 ppm -> fee = 30,000 * 0 / 1,000,000 = 0
604		// - Second VTXO: 50,000 at 1,000 ppm -> fee = 50,000 * 1,000 / 1,000,000 = 50
605		// - Third VTXO: Only need 20,000 at 2,000 ppm -> fee = 20,000 * 2,000 / 1,000,000 = 40
606		// Total: base (200) + (0 + 50 + 40) + ((6 + 100) * 10) = 200 + 90 + 1,060 = 1,350
607		assert_eq!(fee, Amount::from_sat(1_350));
608	}
609
610	#[test]
611	fn test_offboard_fees_with_no_fee_rate() {
612		let fees = OffboardFees {
613			base_fee: Amount::from_sat(200),
614			fixed_additional_vb: 100,
615			ppm_expiry_table: vec![
616				PpmExpiryFeeEntry { expiry_blocks_threshold: 1, ppm: PpmFeeRate(1_000) },
617			],
618		};
619
620		let script_str = "6a0474657374"; // OP_RETURN, push 4 bytes with the string "test"
621		let destination = ScriptBuf::from_hex(script_str)
622			.expect("Failed to parse OP_RETURN script hex string");
623		let fee_rate = FeeRate::from_sat_per_vb_unchecked(0);
624		let vtxos = vec![
625			VtxoFeeInfo { amount: Amount::from_sat(200_000), expiry_blocks: 50 },  // 1,000 ppm (> 1)
626		];
627
628		let amount_to_send = Amount::from_sat(100_000);
629		let fee = fees.calculate(&destination, amount_to_send, fee_rate, vtxos).unwrap();
630		// base (200) + ((100,000 * 1,000) / 1,000,000) + ((6 + 100) * 0) = 200 + 100 + 0 = 300
631		assert_eq!(fee, Amount::from_sat(300));
632	}
633
634	#[test]
635	fn test_offboard_fees_with_no_additional_vb() {
636		let fees = OffboardFees {
637			base_fee: Amount::from_sat(200),
638			fixed_additional_vb: 0,
639			ppm_expiry_table: vec![
640				PpmExpiryFeeEntry { expiry_blocks_threshold: 1, ppm: PpmFeeRate(1_000) },
641			],
642		};
643
644		let script_str = "6a0474657374"; // OP_RETURN, push 4 bytes with the string "test"
645		let destination = ScriptBuf::from_hex(script_str)
646			.expect("Failed to parse OP_RETURN script hex string");
647		let fee_rate = FeeRate::from_sat_per_vb_unchecked(10);
648		let vtxos = vec![
649			VtxoFeeInfo { amount: Amount::from_sat(200_000), expiry_blocks: 50 },  // 1,000 ppm (> 1)
650		];
651
652		let amount_to_send = Amount::from_sat(100_000);
653		let fee = fees.calculate(&destination, amount_to_send, fee_rate, vtxos).unwrap();
654		// base (200) + ((100,000 * 1,000) / 1,000,000) + ((6 + 0) * 10) = 200 + 100 + 60 = 360
655		assert_eq!(fee, Amount::from_sat(360));
656	}
657
658	#[test]
659	fn test_refresh_fees_with_single_vtxo() {
660		let fees = RefreshFees {
661			base_fee: Amount::from_sat(150),
662			ppm_expiry_table: vec![
663				PpmExpiryFeeEntry { expiry_blocks_threshold: 200, ppm: PpmFeeRate(500) },
664				PpmExpiryFeeEntry { expiry_blocks_threshold: 600, ppm: PpmFeeRate(1_500) },
665			],
666		};
667
668		let amount = Amount::from_sat(200_000);
669
670		// Test with expiry = 400 blocks (should use 500 ppm)
671		let vtxo = VtxoFeeInfo { amount, expiry_blocks: 400 };
672		let fee = fees.calculate(vec![vtxo]).unwrap();
673		// base (150) + (200,000 * 500) / 1,000,000 = 150 + 100 = 250
674		assert_eq!(fee, Amount::from_sat(250));
675
676		// Test with expiry = 800 blocks (should use 1,500 ppm)
677		let vtxo = VtxoFeeInfo { amount, expiry_blocks: 800 };
678		let fee = fees.calculate(vec![vtxo]).unwrap();
679		// base (150) + (200,000 * 1,500) / 1,000,000 = 150 + 300 = 450
680		assert_eq!(fee, Amount::from_sat(450));
681	}
682
683	#[test]
684	fn test_refresh_fees_with_multiple_vtxos() {
685		let fees = RefreshFees {
686			base_fee: Amount::from_sat(50),
687			ppm_expiry_table: vec![
688				PpmExpiryFeeEntry { expiry_blocks_threshold: 200, ppm: PpmFeeRate(500) },
689				PpmExpiryFeeEntry { expiry_blocks_threshold: 600, ppm: PpmFeeRate(1_500) },
690			],
691		};
692
693		// Test with multiple VTXOs
694		let vtxos = vec![
695			VtxoFeeInfo { amount: Amount::from_sat(70_000), expiry_blocks: 100 },  // 0 ppm (< 200)
696			VtxoFeeInfo { amount: Amount::from_sat(100_000), expiry_blocks: 300 }, // 500 ppm
697			VtxoFeeInfo { amount: Amount::from_sat(80_000), expiry_blocks: 700 },  // 1,500 ppm
698		];
699
700		let fee = fees.calculate(vtxos).unwrap();
701		// We consume VTXOs in order until we have enough:
702		// - First VTXO: 70,000 at 0 ppm -> fee = 70,000 * 0 / 1,000,000 = 0
703		// - Second VTXO: 100,000 at 500 ppm -> fee = 100,000 * 500 / 1,000,000 = 50
704		// - Third VTXO: 80,000 at 1,500 ppm -> fee = 80,000 * 1,500 / 1,000,000 = 120
705		// Total: base (50) + 0 + 50 + 120 = 220
706		assert_eq!(fee, Amount::from_sat(220));
707	}
708
709	#[test]
710	fn test_lightning_receive_fees() {
711		let fees = LightningReceiveFees {
712			base_fee: Amount::from_sat(100),
713			ppm: PpmFeeRate(2_000), // 0.2%
714		};
715
716		let amount = Amount::from_sat(10_000);
717		let fee = fees.calculate(amount).unwrap();
718		// base (100) + (10,000 * 2,000) / 1,000,000 = 100 + 20 = 120
719		assert_eq!(fee, Amount::from_sat(120));
720	}
721
722	#[test]
723	fn test_lightning_send_fees_with_single_vtxo() {
724		let mut fees = LightningSendFees {
725			min_fee: Amount::from_sat(10),
726			base_fee: Amount::from_sat(75),
727			ppm_expiry_table: vec![
728				PpmExpiryFeeEntry { expiry_blocks_threshold: 50, ppm: PpmFeeRate(250) },
729				PpmExpiryFeeEntry { expiry_blocks_threshold: 100, ppm: PpmFeeRate(750) },
730			],
731		};
732
733		let amount = Amount::from_sat(1_000_000);
734
735		// Test with expiry = 75 blocks (should use 250 ppm)
736		let vtxo = VtxoFeeInfo { amount, expiry_blocks: 75 };
737		let fee = fees.calculate(amount, vec![vtxo]).unwrap();
738		// base (75) + (1,000,000 * 250) / 1,000,000 = 75 + 250 = 325
739		assert_eq!(fee, Amount::from_sat(325));
740
741		// Test with expiry = 150 blocks (should use 750 ppm)
742		let vtxo = VtxoFeeInfo { amount, expiry_blocks: 150 };
743		let fee = fees.calculate(amount, vec![vtxo]).unwrap();
744		// base (75) + (1,000,000 * 750) / 1,000,000 = 75 + 750 = 825
745		assert_eq!(fee, Amount::from_sat(825));
746
747		// Test with 1,000 sats and min fee
748		fees.min_fee = Amount::from_sat(330);
749		let vtxo = VtxoFeeInfo { amount: Amount::from_sat(1_000), expiry_blocks: 150 };
750		let fee = fees.calculate(amount, vec![vtxo]).unwrap();
751		// base (75) + (1,000 * 750) / 1,000,000 = 75 + 0 = MAX(75, 330) = 330
752		assert_eq!(fee, Amount::from_sat(330));
753	}
754
755	#[test]
756	fn test_lightning_send_fees_with_multiple_vtxos() {
757		let fees = LightningSendFees {
758			min_fee: Amount::from_sat(10),
759			base_fee: Amount::from_sat(25),
760			ppm_expiry_table: vec![
761				PpmExpiryFeeEntry { expiry_blocks_threshold: 50, ppm: PpmFeeRate(250) },
762				PpmExpiryFeeEntry { expiry_blocks_threshold: 100, ppm: PpmFeeRate(750) },
763				PpmExpiryFeeEntry { expiry_blocks_threshold: 200, ppm: PpmFeeRate(1_500) },
764			],
765		};
766
767		// Test with multiple VTXOs where the total VTXO value exceeds the amount being paid.
768		// The VTXOs total 1,500,000 sats but we're only sending 1,000,000.
769		let vtxos = vec![
770			VtxoFeeInfo { amount: Amount::from_sat(400_000), expiry_blocks: 75 },  // 250 ppm
771			VtxoFeeInfo { amount: Amount::from_sat(500_000), expiry_blocks: 150 }, // 750 ppm
772			VtxoFeeInfo { amount: Amount::from_sat(600_000), expiry_blocks: 250 }, // 1,500 ppm
773		];
774
775		let amount_to_send = Amount::from_sat(1_000_000);
776		let fee = fees.calculate(amount_to_send, vtxos).unwrap();
777		// We consume VTXOs in order until we have enough:
778		// - First VTXO: 400,000 at 250 ppm -> fee = 400,000 * 250 / 1,000,000 = 100
779		// - Second VTXO: 500,000 at 750 ppm -> fee = 500,000 * 750 / 1,000,000 = 375
780		// - Third VTXO: only need 100,000 at 1,500 ppm -> fee = 100,000 * 1,500 / 1,000,000 = 150
781		// Total: base (25) + 100 + 375 + 150 = 650
782		assert_eq!(fee, Amount::from_sat(650));
783	}
784}