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#[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 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 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 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 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
102pub struct BoardFees {
103 #[serde(rename = "min_fee_sat", with = "bitcoin::amount::serde::as_sat")]
105 pub min_fee: Amount,
106 #[serde(rename = "base_fee_sat", with = "bitcoin::amount::serde::as_sat")]
108 pub base_fee: Amount,
109 #[serde(rename = "ppm")]
111 pub ppm: PpmFeeRate,
112}
113
114impl BoardFees {
115 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#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
127pub struct OffboardFees {
128 #[serde(rename = "base_fee_sat", with = "bitcoin::amount::serde::as_sat")]
130 pub base_fee: Amount,
131
132 pub fixed_additional_vb: u64,
138
139 pub ppm_expiry_table: Vec<PpmExpiryFeeEntry>,
142}
143
144impl OffboardFees {
145 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#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
166pub struct RefreshFees {
167 #[serde(rename = "base_fee_sat", with = "bitcoin::amount::serde::as_sat")]
169 pub base_fee: Amount,
170 pub ppm_expiry_table: Vec<PpmExpiryFeeEntry>,
173}
174
175impl RefreshFees {
176 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
200pub struct LightningReceiveFees {
201 #[serde(rename = "base_fee_sat", with = "bitcoin::amount::serde::as_sat")]
203 pub base_fee: Amount,
204 pub ppm: PpmFeeRate,
206}
207
208impl LightningReceiveFees {
209 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#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
220pub struct LightningSendFees {
221 #[serde(rename = "min_fee_sat", with = "bitcoin::amount::serde::as_sat")]
223 pub min_fee: Amount,
224 #[serde(rename = "base_fee_sat", with = "bitcoin::amount::serde::as_sat")]
226 pub base_fee: Amount,
227 pub ppm_expiry_table: Vec<PpmExpiryFeeEntry>,
230}
231
232impl LightningSendFees {
233 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
249pub struct VtxoFeeInfo {
250 pub amount: Amount,
252 pub expiry_blocks: u32,
254}
255
256impl VtxoFeeInfo {
257 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 pub const ZERO: PpmFeeRate = PpmFeeRate(0);
272 pub const ONE_PERCENT: PpmFeeRate = PpmFeeRate(10_000);
274
275}
276
277#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
284pub struct PpmFee(u128);
285
286impl PpmFee {
287 pub const ZERO: PpmFee = PpmFee(0);
289
290 pub fn to_sat_ceil(self) -> Option<u64> {
293 u64::try_from(self.0.div_ceil(1_000_000)).ok()
294 }
295
296 pub fn to_amount_ceil(self) -> Option<Amount> {
299 Some(Amount::from_sat(self.to_sat_ceil()?))
300 }
301
302 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
333pub struct PpmExpiryFeeEntry {
334 pub expiry_blocks_threshold: u32,
339 pub ppm: PpmFeeRate,
341}
342
343#[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
358pub 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
402pub 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 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
465pub 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 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 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 let mut total_fee = PpmFee::ZERO;
532 let mut remaining = fee_chargeable_amount;
533 for (amount, ppm) in entry_totals {
534 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), };
560
561 let amount = Amount::from_sat(10_000);
563 let fee = fees.calculate(amount).unwrap();
564 assert_eq!(fee, Amount::from_sat(110));
566
567 fees.min_fee = Amount::from_sat(330);
569 let amount = Amount::from_sat(10_000);
570 let fee = fees.calculate(amount).unwrap();
571 assert_eq!(fee, Amount::from_sat(330));
573
574 fees.min_fee = Amount::ZERO;
576 let amount = Amount::from_sat(10_500);
577 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"; 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 let vtxo = VtxoFeeInfo { amount, expiry_blocks: 50 };
601 let fee = fees.calculate(&destination, amount, fee_rate, vec![vtxo]).unwrap();
602 assert_eq!(fee, Amount::from_sat(1_260));
604
605 let vtxo = VtxoFeeInfo { amount, expiry_blocks: 150 };
607 let fee = fees.calculate(&destination, amount, fee_rate, vec![vtxo]).unwrap();
608 assert_eq!(fee, Amount::from_sat(1_360));
610
611 let vtxo = VtxoFeeInfo { amount, expiry_blocks: 750 };
613 let fee = fees.calculate(&destination, amount, fee_rate, vec![vtxo]).unwrap();
614 assert_eq!(fee, Amount::from_sat(1_460));
616
617 let vtxo = VtxoFeeInfo { amount, expiry_blocks: 2_000 };
619 let fee = fees.calculate(&destination, amount, fee_rate, vec![vtxo]).unwrap();
620 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"; 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 let vtxos = vec![
642 VtxoFeeInfo { amount: Amount::from_sat(30_000), expiry_blocks: 50 }, VtxoFeeInfo { amount: Amount::from_sat(50_000), expiry_blocks: 150 }, VtxoFeeInfo { amount: Amount::from_sat(40_000), expiry_blocks: 600 }, ];
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 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"; 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 }, ];
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 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"; 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 }, ];
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 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 let vtxo = VtxoFeeInfo { amount, expiry_blocks: 400 };
719 let fee = fees.calculate(vec![vtxo]).unwrap();
720 assert_eq!(fee, Amount::from_sat(250));
722
723 let vtxo = VtxoFeeInfo { amount, expiry_blocks: 800 };
725 let fee = fees.calculate(vec![vtxo]).unwrap();
726 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 let vtxos = vec![
742 VtxoFeeInfo { amount: Amount::from_sat(70_000), expiry_blocks: 100 }, VtxoFeeInfo { amount: Amount::from_sat(100_000), expiry_blocks: 300 }, VtxoFeeInfo { amount: Amount::from_sat(80_000), expiry_blocks: 700 }, ];
746
747 let fee = fees.calculate(vtxos).unwrap();
748 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), };
762
763 let amount = Amount::from_sat(10_000);
764 let fee = fees.calculate(amount).unwrap();
765 assert_eq!(fee, Amount::from_sat(120));
767
768 let amount = Amount::from_sat(10_400);
770 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 let vtxo = VtxoFeeInfo { amount, expiry_blocks: 75 };
789 let fee = fees.calculate(amount, vec![vtxo]).unwrap();
790 assert_eq!(fee, Amount::from_sat(325));
792
793 let vtxo = VtxoFeeInfo { amount, expiry_blocks: 150 };
795 let fee = fees.calculate(amount, vec![vtxo]).unwrap();
796 assert_eq!(fee, Amount::from_sat(825));
798
799 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 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 let vtxos = vec![
822 VtxoFeeInfo { amount: Amount::from_sat(400_000), expiry_blocks: 75 }, VtxoFeeInfo { amount: Amount::from_sat(500_000), expiry_blocks: 150 }, VtxoFeeInfo { amount: Amount::from_sat(600_000), expiry_blocks: 250 }, ];
826
827 let amount_to_send = Amount::from_sat(1_000_000);
828 let fee = fees.calculate(amount_to_send, vtxos).unwrap();
829 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 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 assert_eq!(fee, Some(Amount::from_sat(66)));
850
851 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 assert_eq!(fee, Some(Amount::from_sat(9)));
860
861 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 assert_eq!(fee, Some(Amount::from_sat(3)));
871
872 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 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 let ours = calc_ppm_expiry_fee(None, &table, ours).unwrap();
899 assert_eq!(ours, Amount::from_sat(2));
900 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 #[test]
911 fn test_ppm_expiry_fee_ignores_vtxo_order() {
912 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 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 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}