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 #[deprecated(note = "only for protocol versions <= 3")]
125 pub fn calculate_legacy(&self, amount: Amount) -> Option<Amount> {
126 let numerator = amount.to_sat().checked_mul(self.ppm.0)?;
127 let fee = Amount::from_sat(numerator / 1_000_000).checked_add(self.base_fee)?;
128 Some(fee.max(self.min_fee))
129 }
130}
131
132#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
134pub struct OffboardFees {
135 #[serde(rename = "base_fee_sat", with = "bitcoin::amount::serde::as_sat")]
137 pub base_fee: Amount,
138
139 pub fixed_additional_vb: u64,
145
146 pub ppm_expiry_table: Vec<PpmExpiryFeeEntry>,
149}
150
151impl OffboardFees {
152 pub fn calculate(
156 &self,
157 destination: &ScriptBuf,
158 amount: Amount,
159 fee_rate: FeeRate,
160 vtxos: impl IntoIterator<Item = VtxoFeeInfo>,
161 ) -> Option<Amount> {
162 let weight_fee = self.fixed_additional_vb.checked_add(destination.as_script().len() as u64)
163 .and_then(Weight::from_vb)
164 .and_then(|w| fee_rate.checked_mul_by_weight(w))?;
165 let ppm_fee = calc_ppm_expiry_fee(Some(amount), &self.ppm_expiry_table, vtxos)?;
166 self.base_fee.checked_add(weight_fee)?.checked_add(ppm_fee)
167 }
168
169 #[deprecated(note = "only for protocol versions <= 3")]
172 #[allow(deprecated)]
173 pub fn calculate_legacy(
174 &self,
175 destination: &ScriptBuf,
176 amount: Amount,
177 fee_rate: FeeRate,
178 vtxos: impl IntoIterator<Item = VtxoFeeInfo>,
179 ) -> Option<Amount> {
180 let weight_fee = self.fixed_additional_vb.checked_add(destination.as_script().len() as u64)
181 .and_then(Weight::from_vb)
182 .and_then(|w| fee_rate.checked_mul_by_weight(w))?;
183 let ppm_fee = calc_ppm_expiry_fee_legacy(Some(amount), &self.ppm_expiry_table, vtxos)?;
184 self.base_fee.checked_add(weight_fee)?.checked_add(ppm_fee)
185 }
186}
187
188#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
190pub struct RefreshFees {
191 #[serde(rename = "base_fee_sat", with = "bitcoin::amount::serde::as_sat")]
193 pub base_fee: Amount,
194 pub ppm_expiry_table: Vec<PpmExpiryFeeEntry>,
197}
198
199impl RefreshFees {
200 pub fn calculate(
204 &self,
205 vtxos: impl IntoIterator<Item = VtxoFeeInfo>,
206 ) -> Option<Amount> {
207 self.base_fee.checked_add(self.calculate_no_base_fee(vtxos)?)
208 }
209
210 pub fn calculate_no_base_fee(
214 &self,
215 vtxos: impl IntoIterator<Item = VtxoFeeInfo>,
216 ) -> Option<Amount> {
217 calc_ppm_expiry_fee(None, &self.ppm_expiry_table, vtxos)
218 }
219
220 #[deprecated(note = "only for protocol versions <= 3")]
223 #[allow(deprecated)]
224 pub fn calculate_legacy(
225 &self,
226 vtxos: impl IntoIterator<Item = VtxoFeeInfo>,
227 ) -> Option<Amount> {
228 let fee = calc_ppm_expiry_fee_legacy(None, &self.ppm_expiry_table, vtxos)?;
229 self.base_fee.checked_add(fee)
230 }
231}
232
233#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
235pub struct LightningReceiveFees {
236 #[serde(rename = "base_fee_sat", with = "bitcoin::amount::serde::as_sat")]
238 pub base_fee: Amount,
239 pub ppm: PpmFeeRate,
241}
242
243impl LightningReceiveFees {
244 pub fn calculate(&self, amount: Amount) -> Option<Amount> {
248 self.base_fee.checked_add((amount * self.ppm).to_amount_ceil()?)
249 }
250
251 #[deprecated(note = "only for protocol versions <= 3")]
254 pub fn calculate_legacy(&self, amount: Amount) -> Option<Amount> {
255 let numerator = amount.to_sat().checked_mul(self.ppm.0)?;
256 self.base_fee.checked_add(Amount::from_sat(numerator / 1_000_000))
257 }
258}
259
260#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
262pub struct LightningSendFees {
263 #[serde(rename = "min_fee_sat", with = "bitcoin::amount::serde::as_sat")]
265 pub min_fee: Amount,
266 #[serde(rename = "base_fee_sat", with = "bitcoin::amount::serde::as_sat")]
268 pub base_fee: Amount,
269 pub ppm_expiry_table: Vec<PpmExpiryFeeEntry>,
272}
273
274impl LightningSendFees {
275 pub fn calculate(
279 &self,
280 amount: Amount,
281 vtxos: impl IntoIterator<Item = VtxoFeeInfo>,
282 ) -> Option<Amount> {
283 let ppm = calc_ppm_expiry_fee(Some(amount), &self.ppm_expiry_table, vtxos)?;
284 Some(self.base_fee.checked_add(ppm)?.max(self.min_fee))
285 }
286
287 #[deprecated(note = "only for protocol versions <= 3")]
290 #[allow(deprecated)]
291 pub fn calculate_legacy(
292 &self,
293 amount: Amount,
294 vtxos: impl IntoIterator<Item = VtxoFeeInfo>,
295 ) -> Option<Amount> {
296 let ppm = calc_ppm_expiry_fee_legacy(Some(amount), &self.ppm_expiry_table, vtxos)?;
297 Some(self.base_fee.checked_add(ppm)?.max(self.min_fee))
298 }
299}
300
301#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
303pub struct VtxoFeeInfo {
304 pub amount: Amount,
306 pub expiry_blocks: u32,
308}
309
310impl VtxoFeeInfo {
311 pub fn from_vtxo_and_tip<G>(vtxo: &Vtxo<G>, tip: BlockHeight) -> Self {
313 Self {
314 amount: vtxo.amount(),
315 expiry_blocks: vtxo.expiry_height().saturating_sub(tip),
316 }
317 }
318}
319
320#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Deserialize, Serialize)]
321pub struct PpmFeeRate(pub u64);
322
323impl PpmFeeRate {
324 pub const ZERO: PpmFeeRate = PpmFeeRate(0);
326 pub const ONE_PERCENT: PpmFeeRate = PpmFeeRate(10_000);
328
329}
330
331#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
338pub struct PpmFee(u128);
339
340impl PpmFee {
341 pub const ZERO: PpmFee = PpmFee(0);
343
344 pub fn to_sat_ceil(self) -> Option<u64> {
347 u64::try_from(self.0.div_ceil(1_000_000)).ok()
348 }
349
350 pub fn to_amount_ceil(self) -> Option<Amount> {
353 Some(Amount::from_sat(self.to_sat_ceil()?))
354 }
355
356 pub fn checked_add(self, other: PpmFee) -> Option<PpmFee> {
358 Some(PpmFee(self.0.checked_add(other.0)?))
359 }
360}
361
362impl ops::Mul<PpmFeeRate> for Amount {
363 type Output = PpmFee;
364
365 fn mul(self, ppm: PpmFeeRate) -> Self::Output {
379 PpmFee((self.to_sat() as u128).checked_mul(ppm.0 as u128)
380 .expect("widening u64 * u64 to u128 is exact and cannot overflow"))
381 }
382}
383
384#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
387pub struct PpmExpiryFeeEntry {
388 pub expiry_blocks_threshold: u32,
393 pub ppm: PpmFeeRate,
395}
396
397#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq, Hash)]
399pub enum FeeValidationError {
400 #[error("Fee ({fee}) exceeds amount ({amount})")]
401 FeeExceedsAmount { amount: Amount, fee: Amount },
402
403 #[error("Amount after fee ({amount_after_fee}) is below dust limit ({dust}). Amount: {amount}, Fee: {fee}")]
404 AmountAfterFeeBelowDust {
405 amount: Amount,
406 fee: Amount,
407 dust: Amount,
408 amount_after_fee: Amount,
409 },
410}
411
412pub fn validate_and_subtract_fee(
443 amount: Amount,
444 fee: Amount,
445) -> Result<Amount, FeeValidationError> {
446 let amount_after_fee = amount.checked_sub(fee)
447 .ok_or(FeeValidationError::FeeExceedsAmount { amount, fee })?;
448
449 if amount_after_fee == Amount::ZERO {
450 Err(FeeValidationError::FeeExceedsAmount { amount, fee })
451 } else {
452 Ok(amount_after_fee)
453 }
454}
455
456pub fn validate_and_subtract_fee_min_dust(
499 amount: Amount,
500 fee: Amount,
501 dust: Amount,
502) -> Result<Amount, FeeValidationError> {
503 let amount_after_fee = amount.checked_sub(fee)
504 .ok_or(FeeValidationError::FeeExceedsAmount { amount, fee })?;
505
506 if amount_after_fee < dust {
508 return Err(FeeValidationError::AmountAfterFeeBelowDust {
509 amount,
510 fee,
511 dust,
512 amount_after_fee,
513 });
514 }
515
516 Ok(amount_after_fee)
517}
518
519pub fn calc_ppm_expiry_fee(
563 fee_chargeable_amount: Option<Amount>,
564 ppm_expiry_table: &Vec<PpmExpiryFeeEntry>,
565 vtxos: impl IntoIterator<Item = VtxoFeeInfo>,
566) -> Option<Amount> {
567 let mut entry_totals = iter::once((Amount::ZERO, PpmFeeRate::ZERO))
572 .chain(ppm_expiry_table.iter().map(|entry| (Amount::ZERO, entry.ppm)))
573 .collect::<Vec<(Amount, PpmFeeRate)>>();
574 for v in vtxos {
575 let i = ppm_expiry_table
577 .iter()
578 .rposition(|entry| v.expiry_blocks >= entry.expiry_blocks_threshold)
579 .map_or(0, |i| i.saturating_add(1));
580 entry_totals[i].0 = entry_totals[i].0.checked_add(v.amount)?;
581 }
582
583 let mut total_fee = PpmFee::ZERO;
586 let mut remaining = fee_chargeable_amount;
587 for (amount, ppm) in entry_totals {
588 let fee_chargeable_amount = if let Some(ref mut remaining) = remaining {
591 let amount = amount.min(*remaining);
592 *remaining -= amount;
593 amount
594 } else {
595 amount
596 };
597
598 total_fee = total_fee.checked_add(fee_chargeable_amount * ppm)?;
599 }
600 total_fee.to_amount_ceil()
601}
602
603#[deprecated(note = "only for protocol versions <= 3")]
606pub fn calc_ppm_expiry_fee_legacy(
607 fee_chargeable_amount: Option<Amount>,
608 ppm_expiry_table: &Vec<PpmExpiryFeeEntry>,
609 vtxos: impl IntoIterator<Item = VtxoFeeInfo>,
610) -> Option<Amount> {
611 let mut total_fee = Amount::ZERO;
612 let mut remaining = fee_chargeable_amount;
613 for v in vtxos {
614 let fee_chargeable_amount = if let Some(ref mut remaining) = remaining {
617 let amount = v.amount.min(*remaining);
618 *remaining -= amount;
619 amount
620 } else {
621 v.amount
622 };
623
624 let entry = ppm_expiry_table
626 .iter()
627 .rev()
628 .find(|entry| v.expiry_blocks >= entry.expiry_blocks_threshold);
629
630 if let Some(entry) = entry {
632 let numerator = fee_chargeable_amount.to_sat().checked_mul(entry.ppm.0)?;
633 total_fee = total_fee.checked_add(Amount::from_sat(numerator / 1_000_000))?;
634 }
635 }
636 Some(total_fee)
637}
638
639#[cfg(test)]
640mod tests {
641 use super::*;
642
643 #[test]
644 fn test_board_fees() {
645 let mut fees = BoardFees {
646 min_fee: Amount::ZERO,
647 base_fee: Amount::from_sat(100),
648 ppm: PpmFeeRate(1_000), };
650
651 let amount = Amount::from_sat(10_000);
653 let fee = fees.calculate(amount).unwrap();
654 assert_eq!(fee, Amount::from_sat(110));
656
657 fees.min_fee = Amount::from_sat(330);
659 let amount = Amount::from_sat(10_000);
660 let fee = fees.calculate(amount).unwrap();
661 assert_eq!(fee, Amount::from_sat(330));
663
664 fees.min_fee = Amount::ZERO;
666 let amount = Amount::from_sat(10_500);
667 assert_eq!(fees.calculate(amount), Some(Amount::from_sat(111)));
669 #[allow(deprecated)]
670 let fee = fees.calculate_legacy(amount);
671 assert_eq!(fee, Some(Amount::from_sat(110)));
673 }
674
675 #[test]
676 fn test_offboard_fees_with_single_vtxo() {
677 let fees = OffboardFees {
678 base_fee: Amount::from_sat(200),
679 fixed_additional_vb: 100,
680 ppm_expiry_table: vec![
681 PpmExpiryFeeEntry { expiry_blocks_threshold: 100, ppm: PpmFeeRate(1_000) },
682 PpmExpiryFeeEntry { expiry_blocks_threshold: 500, ppm: PpmFeeRate(2_000) },
683 PpmExpiryFeeEntry { expiry_blocks_threshold: 1_000, ppm: PpmFeeRate(3_000) },
684 ],
685 };
686
687 let script_str = "6a0474657374"; let destination = ScriptBuf::from_hex(script_str)
689 .expect("Failed to parse OP_RETURN script hex string");
690 let fee_rate = FeeRate::from_sat_per_vb_u32(10);
691 let amount = Amount::from_sat(100_000);
692
693 let vtxo = VtxoFeeInfo { amount, expiry_blocks: 50 };
695 let fee = fees.calculate(&destination, amount, fee_rate, vec![vtxo]).unwrap();
696 assert_eq!(fee, Amount::from_sat(1_260));
698
699 let vtxo = VtxoFeeInfo { amount, expiry_blocks: 150 };
701 let fee = fees.calculate(&destination, amount, fee_rate, vec![vtxo]).unwrap();
702 assert_eq!(fee, Amount::from_sat(1_360));
704
705 let vtxo = VtxoFeeInfo { amount, expiry_blocks: 750 };
707 let fee = fees.calculate(&destination, amount, fee_rate, vec![vtxo]).unwrap();
708 assert_eq!(fee, Amount::from_sat(1_460));
710
711 let vtxo = VtxoFeeInfo { amount, expiry_blocks: 2_000 };
713 let fee = fees.calculate(&destination, amount, fee_rate, vec![vtxo]).unwrap();
714 assert_eq!(fee, Amount::from_sat(1_560));
716 }
717
718 #[test]
719 fn test_offboard_fees_with_multiple_vtxos() {
720 let fees = OffboardFees {
721 base_fee: Amount::from_sat(200),
722 fixed_additional_vb: 100,
723 ppm_expiry_table: vec![
724 PpmExpiryFeeEntry { expiry_blocks_threshold: 100, ppm: PpmFeeRate(1_000) },
725 PpmExpiryFeeEntry { expiry_blocks_threshold: 500, ppm: PpmFeeRate(2_000) },
726 ],
727 };
728
729 let script_str = "6a0474657374"; let destination = ScriptBuf::from_hex(script_str)
731 .expect("Failed to parse OP_RETURN script hex string");
732 let fee_rate = FeeRate::from_sat_per_vb_u32(10);
733 let vtxos = vec![
736 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 }, ];
740
741 let amount_to_send = Amount::from_sat(100_000);
742 let fee = fees.calculate(&destination, amount_to_send, fee_rate, vtxos).unwrap();
743 assert_eq!(fee, Amount::from_sat(1_350));
749 }
750
751 #[test]
752 fn test_offboard_fees_with_no_fee_rate() {
753 let fees = OffboardFees {
754 base_fee: Amount::from_sat(200),
755 fixed_additional_vb: 100,
756 ppm_expiry_table: vec![
757 PpmExpiryFeeEntry { expiry_blocks_threshold: 1, ppm: PpmFeeRate(1_000) },
758 ],
759 };
760
761 let script_str = "6a0474657374"; let destination = ScriptBuf::from_hex(script_str)
763 .expect("Failed to parse OP_RETURN script hex string");
764 let fee_rate = FeeRate::from_sat_per_vb_u32(0);
765 let vtxos = vec![
766 VtxoFeeInfo { amount: Amount::from_sat(200_000), expiry_blocks: 50 }, ];
768
769 let amount_to_send = Amount::from_sat(100_000);
770 let fee = fees.calculate(&destination, amount_to_send, fee_rate, vtxos).unwrap();
771 assert_eq!(fee, Amount::from_sat(300));
773 }
774
775 #[test]
776 fn test_offboard_fees_with_no_additional_vb() {
777 let fees = OffboardFees {
778 base_fee: Amount::from_sat(200),
779 fixed_additional_vb: 0,
780 ppm_expiry_table: vec![
781 PpmExpiryFeeEntry { expiry_blocks_threshold: 1, ppm: PpmFeeRate(1_000) },
782 ],
783 };
784
785 let script_str = "6a0474657374"; let destination = ScriptBuf::from_hex(script_str)
787 .expect("Failed to parse OP_RETURN script hex string");
788 let fee_rate = FeeRate::from_sat_per_vb_u32(10);
789 let vtxos = vec![
790 VtxoFeeInfo { amount: Amount::from_sat(200_000), expiry_blocks: 50 }, ];
792
793 let amount_to_send = Amount::from_sat(100_000);
794 let fee = fees.calculate(&destination, amount_to_send, fee_rate, vtxos).unwrap();
795 assert_eq!(fee, Amount::from_sat(360));
797 }
798
799 #[test]
800 fn test_refresh_fees_with_single_vtxo() {
801 let fees = RefreshFees {
802 base_fee: Amount::from_sat(150),
803 ppm_expiry_table: vec![
804 PpmExpiryFeeEntry { expiry_blocks_threshold: 200, ppm: PpmFeeRate(500) },
805 PpmExpiryFeeEntry { expiry_blocks_threshold: 600, ppm: PpmFeeRate(1_500) },
806 ],
807 };
808
809 let amount = Amount::from_sat(200_000);
810
811 let vtxo = VtxoFeeInfo { amount, expiry_blocks: 400 };
813 let fee = fees.calculate(vec![vtxo]).unwrap();
814 assert_eq!(fee, Amount::from_sat(250));
816
817 let vtxo = VtxoFeeInfo { amount, expiry_blocks: 800 };
819 let fee = fees.calculate(vec![vtxo]).unwrap();
820 assert_eq!(fee, Amount::from_sat(450));
822 }
823
824 #[test]
825 fn test_refresh_fees_with_multiple_vtxos() {
826 let fees = RefreshFees {
827 base_fee: Amount::from_sat(50),
828 ppm_expiry_table: vec![
829 PpmExpiryFeeEntry { expiry_blocks_threshold: 200, ppm: PpmFeeRate(500) },
830 PpmExpiryFeeEntry { expiry_blocks_threshold: 600, ppm: PpmFeeRate(1_500) },
831 ],
832 };
833
834 let vtxos = vec![
836 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 }, ];
840
841 let fee = fees.calculate(vtxos).unwrap();
842 assert_eq!(fee, Amount::from_sat(220));
848 }
849
850 #[test]
851 fn test_lightning_receive_fees() {
852 let fees = LightningReceiveFees {
853 base_fee: Amount::from_sat(100),
854 ppm: PpmFeeRate(2_000), };
856
857 let amount = Amount::from_sat(10_000);
858 let fee = fees.calculate(amount).unwrap();
859 assert_eq!(fee, Amount::from_sat(120));
861
862 let amount = Amount::from_sat(10_400);
864 assert_eq!(fees.calculate(amount), Some(Amount::from_sat(121)));
866 #[allow(deprecated)]
867 let fee = fees.calculate_legacy(amount);
868 assert_eq!(fee, Some(Amount::from_sat(120)));
870 }
871
872 #[test]
873 fn test_lightning_send_fees_with_single_vtxo() {
874 let mut fees = LightningSendFees {
875 min_fee: Amount::from_sat(10),
876 base_fee: Amount::from_sat(75),
877 ppm_expiry_table: vec![
878 PpmExpiryFeeEntry { expiry_blocks_threshold: 50, ppm: PpmFeeRate(250) },
879 PpmExpiryFeeEntry { expiry_blocks_threshold: 100, ppm: PpmFeeRate(750) },
880 ],
881 };
882
883 let amount = Amount::from_sat(1_000_000);
884
885 let vtxo = VtxoFeeInfo { amount, expiry_blocks: 75 };
887 let fee = fees.calculate(amount, vec![vtxo]).unwrap();
888 assert_eq!(fee, Amount::from_sat(325));
890
891 let vtxo = VtxoFeeInfo { amount, expiry_blocks: 150 };
893 let fee = fees.calculate(amount, vec![vtxo]).unwrap();
894 assert_eq!(fee, Amount::from_sat(825));
896
897 fees.min_fee = Amount::from_sat(330);
899 let vtxo = VtxoFeeInfo { amount: Amount::from_sat(1_000), expiry_blocks: 150 };
900 let fee = fees.calculate(amount, vec![vtxo]).unwrap();
901 assert_eq!(fee, Amount::from_sat(330));
903 }
904
905 #[test]
906 fn test_lightning_send_fees_with_multiple_vtxos() {
907 let fees = LightningSendFees {
908 min_fee: Amount::from_sat(10),
909 base_fee: Amount::from_sat(25),
910 ppm_expiry_table: vec![
911 PpmExpiryFeeEntry { expiry_blocks_threshold: 50, ppm: PpmFeeRate(250) },
912 PpmExpiryFeeEntry { expiry_blocks_threshold: 100, ppm: PpmFeeRate(750) },
913 PpmExpiryFeeEntry { expiry_blocks_threshold: 200, ppm: PpmFeeRate(1_500) },
914 ],
915 };
916
917 let vtxos = vec![
920 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 }, ];
924
925 let amount_to_send = Amount::from_sat(1_000_000);
926 let fee = fees.calculate(amount_to_send, vtxos).unwrap();
927 assert_eq!(fee, Amount::from_sat(650));
933 }
934
935 #[test]
936 #[allow(deprecated)]
937 fn test_ppm_expiry_fee_totals() {
938 let table = vec![
939 PpmExpiryFeeEntry { expiry_blocks_threshold: 1_008, ppm: PpmFeeRate(2_000) },
940 PpmExpiryFeeEntry { expiry_blocks_threshold: 2_016, ppm: PpmFeeRate(4_000) },
941 ];
942
943 let vtxos = vec![VtxoFeeInfo { amount: Amount::from_sat(330), expiry_blocks: 1_500 }; 100];
945 let fee = calc_ppm_expiry_fee_legacy(None, &table, vtxos.clone());
946 assert_eq!(fee, Some(Amount::ZERO));
948 let fee = calc_ppm_expiry_fee(None, &table, vtxos);
949 assert_eq!(fee, Some(Amount::from_sat(66)));
951
952 let vtxos = vec![
954 VtxoFeeInfo { amount: Amount::from_sat(900), expiry_blocks: 1_100 },
955 VtxoFeeInfo { amount: Amount::from_sat(900), expiry_blocks: 1_200 },
956 VtxoFeeInfo { amount: Amount::from_sat(1_300), expiry_blocks: 2_500 },
957 ];
958 let fee = calc_ppm_expiry_fee_legacy(None, &table, vtxos.clone());
959 assert_eq!(fee, Some(Amount::from_sat(7)));
961 let fee = calc_ppm_expiry_fee(None, &table, vtxos);
962 assert_eq!(fee, Some(Amount::from_sat(9)));
964
965 let vtxos = vec![
967 VtxoFeeInfo { amount: Amount::from_sat(900), expiry_blocks: 1_100 },
968 VtxoFeeInfo { amount: Amount::from_sat(900), expiry_blocks: 1_200 },
969 VtxoFeeInfo { amount: Amount::from_sat(900), expiry_blocks: 2_500 },
970 ];
971 let cap = Some(Amount::from_sat(1_500));
972 let fee = calc_ppm_expiry_fee_legacy(cap, &table, vtxos.clone());
973 assert_eq!(fee, Some(Amount::from_sat(2)));
975 let fee = calc_ppm_expiry_fee(cap, &table, vtxos);
976 assert_eq!(fee, Some(Amount::from_sat(3)));
978
979 let vtxos = vec![VtxoFeeInfo { amount: Amount::from_sat(100_000), expiry_blocks: 500 }; 10];
981 let fee = calc_ppm_expiry_fee_legacy(None, &table, vtxos.clone());
982 assert_eq!(fee, Some(Amount::ZERO));
983 let fee = calc_ppm_expiry_fee(None, &table, vtxos);
984 assert_eq!(fee, Some(Amount::ZERO));
985 }
986
987 #[test]
988 fn test_ppm_expiry_fee_lagging_tip_pays_at_least_ours() {
989 let table = vec![
990 PpmExpiryFeeEntry { expiry_blocks_threshold: 0, ppm: PpmFeeRate::ZERO },
991 PpmExpiryFeeEntry { expiry_blocks_threshold: 1_008, ppm: PpmFeeRate(2_000) },
992 PpmExpiryFeeEntry { expiry_blocks_threshold: 2_016, ppm: PpmFeeRate(4_000) },
993 ];
994
995 let ours = vec![
999 VtxoFeeInfo { amount: Amount::from_sat(100), expiry_blocks: 2_015 },
1000 VtxoFeeInfo { amount: Amount::from_sat(900), expiry_blocks: 1_500 },
1001 ];
1002 let theirs = vec![
1003 VtxoFeeInfo { amount: Amount::from_sat(100), expiry_blocks: 2_016 },
1004 VtxoFeeInfo { amount: Amount::from_sat(900), expiry_blocks: 1_501 },
1005 ];
1006 let ours = calc_ppm_expiry_fee(None, &table, ours).unwrap();
1008 assert_eq!(ours, Amount::from_sat(2));
1009 let theirs = calc_ppm_expiry_fee(None, &table, theirs).unwrap();
1011 assert_eq!(theirs, Amount::from_sat(3));
1012 assert!(theirs >= ours);
1013 }
1014
1015 #[test]
1020 fn test_ppm_expiry_fee_ignores_vtxo_order() {
1021 fn permutations(vtxos: &[VtxoFeeInfo]) -> Vec<Vec<VtxoFeeInfo>> {
1024 if vtxos.len() <= 1 {
1025 return vec![vtxos.to_vec()];
1026 }
1027 let mut out = Vec::new();
1028 for i in 0..vtxos.len() {
1029 let mut rest = vtxos.to_vec();
1030 let head = rest.remove(i);
1031 for mut p in permutations(&rest) {
1032 p.insert(0, head);
1033 out.push(p);
1034 }
1035 }
1036 out
1037 }
1038
1039 let ppm_expiry_table = vec![
1040 PpmExpiryFeeEntry { expiry_blocks_threshold: 0, ppm: PpmFeeRate(2_000) },
1041 PpmExpiryFeeEntry { expiry_blocks_threshold: 1_008, ppm: PpmFeeRate(4_000) },
1042 PpmExpiryFeeEntry { expiry_blocks_threshold: 2_016, ppm: PpmFeeRate(5_000) },
1043 ];
1044 let vtxos = vec![
1047 VtxoFeeInfo { amount: Amount::from_sat(27_422), expiry_blocks: 454 },
1048 VtxoFeeInfo { amount: Amount::from_sat(102_408), expiry_blocks: 455 },
1049 VtxoFeeInfo { amount: Amount::from_sat(68_456), expiry_blocks: 1_122 },
1050 VtxoFeeInfo { amount: Amount::from_sat(1_320), expiry_blocks: 2_046 },
1051 VtxoFeeInfo { amount: Amount::from_sat(10_099), expiry_blocks: 2_395 },
1052 ];
1053
1054 let chargeable = Some(Amount::from_sat(208_246));
1058 let expected = Amount::from_sat(584);
1059
1060 let orders = permutations(&vtxos);
1061 assert_eq!(orders.len(), 120);
1062 for order in orders {
1063 assert_eq!(
1064 calc_ppm_expiry_fee(chargeable, &ppm_expiry_table, order.clone()),
1065 Some(expected),
1066 "fee changed for order {:?}", order.iter().map(|v| v.amount).collect::<Vec<_>>(),
1067 );
1068 }
1069 }
1070}