1use std::cmp::PartialOrd;
2use std::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(
562 fee_chargeable_amount: Option<Amount>,
563 ppm_expiry_table: &Vec<PpmExpiryFeeEntry>,
564 vtxos: impl IntoIterator<Item = VtxoFeeInfo>,
565) -> Option<Amount> {
566 let mut total_fee = PpmFee::ZERO;
569 let mut remaining = fee_chargeable_amount;
570 for v in vtxos {
571 let fee_chargeable_amount = if let Some(ref mut remaining) = remaining {
574 let amount = v.amount.min(*remaining);
575 *remaining -= amount;
576 amount
577 } else {
578 v.amount
579 };
580
581 let entry = ppm_expiry_table
583 .iter()
584 .rev()
585 .find(|entry| v.expiry_blocks >= entry.expiry_blocks_threshold);
586
587 if let Some(entry) = entry {
589 total_fee = total_fee.checked_add(fee_chargeable_amount * entry.ppm)?;
590 }
591 }
592 total_fee.to_amount_ceil()
593}
594
595#[deprecated(note = "only for protocol versions <= 3")]
598pub fn calc_ppm_expiry_fee_legacy(
599 fee_chargeable_amount: Option<Amount>,
600 ppm_expiry_table: &Vec<PpmExpiryFeeEntry>,
601 vtxos: impl IntoIterator<Item = VtxoFeeInfo>,
602) -> Option<Amount> {
603 let mut total_fee = Amount::ZERO;
604 let mut remaining = fee_chargeable_amount;
605 for v in vtxos {
606 let fee_chargeable_amount = if let Some(ref mut remaining) = remaining {
609 let amount = v.amount.min(*remaining);
610 *remaining -= amount;
611 amount
612 } else {
613 v.amount
614 };
615
616 let entry = ppm_expiry_table
618 .iter()
619 .rev()
620 .find(|entry| v.expiry_blocks >= entry.expiry_blocks_threshold);
621
622 if let Some(entry) = entry {
624 let numerator = fee_chargeable_amount.to_sat().checked_mul(entry.ppm.0)?;
625 total_fee = total_fee.checked_add(Amount::from_sat(numerator / 1_000_000))?;
626 }
627 }
628 Some(total_fee)
629}
630
631#[cfg(test)]
632mod tests {
633 use super::*;
634
635 #[test]
636 fn test_board_fees() {
637 let mut fees = BoardFees {
638 min_fee: Amount::ZERO,
639 base_fee: Amount::from_sat(100),
640 ppm: PpmFeeRate(1_000), };
642
643 let amount = Amount::from_sat(10_000);
645 let fee = fees.calculate(amount).unwrap();
646 assert_eq!(fee, Amount::from_sat(110));
648
649 fees.min_fee = Amount::from_sat(330);
651 let amount = Amount::from_sat(10_000);
652 let fee = fees.calculate(amount).unwrap();
653 assert_eq!(fee, Amount::from_sat(330));
655
656 fees.min_fee = Amount::ZERO;
658 let amount = Amount::from_sat(10_500);
659 assert_eq!(fees.calculate(amount), Some(Amount::from_sat(111)));
661 #[allow(deprecated)]
662 let fee = fees.calculate_legacy(amount);
663 assert_eq!(fee, Some(Amount::from_sat(110)));
665 }
666
667 #[test]
668 fn test_offboard_fees_with_single_vtxo() {
669 let fees = OffboardFees {
670 base_fee: Amount::from_sat(200),
671 fixed_additional_vb: 100,
672 ppm_expiry_table: vec![
673 PpmExpiryFeeEntry { expiry_blocks_threshold: 100, ppm: PpmFeeRate(1_000) },
674 PpmExpiryFeeEntry { expiry_blocks_threshold: 500, ppm: PpmFeeRate(2_000) },
675 PpmExpiryFeeEntry { expiry_blocks_threshold: 1_000, ppm: PpmFeeRate(3_000) },
676 ],
677 };
678
679 let script_str = "6a0474657374"; let destination = ScriptBuf::from_hex(script_str)
681 .expect("Failed to parse OP_RETURN script hex string");
682 let fee_rate = FeeRate::from_sat_per_vb_u32(10);
683 let amount = Amount::from_sat(100_000);
684
685 let vtxo = VtxoFeeInfo { amount, expiry_blocks: 50 };
687 let fee = fees.calculate(&destination, amount, fee_rate, vec![vtxo]).unwrap();
688 assert_eq!(fee, Amount::from_sat(1_260));
690
691 let vtxo = VtxoFeeInfo { amount, expiry_blocks: 150 };
693 let fee = fees.calculate(&destination, amount, fee_rate, vec![vtxo]).unwrap();
694 assert_eq!(fee, Amount::from_sat(1_360));
696
697 let vtxo = VtxoFeeInfo { amount, expiry_blocks: 750 };
699 let fee = fees.calculate(&destination, amount, fee_rate, vec![vtxo]).unwrap();
700 assert_eq!(fee, Amount::from_sat(1_460));
702
703 let vtxo = VtxoFeeInfo { amount, expiry_blocks: 2_000 };
705 let fee = fees.calculate(&destination, amount, fee_rate, vec![vtxo]).unwrap();
706 assert_eq!(fee, Amount::from_sat(1_560));
708 }
709
710 #[test]
711 fn test_offboard_fees_with_multiple_vtxos() {
712 let fees = OffboardFees {
713 base_fee: Amount::from_sat(200),
714 fixed_additional_vb: 100,
715 ppm_expiry_table: vec![
716 PpmExpiryFeeEntry { expiry_blocks_threshold: 100, ppm: PpmFeeRate(1_000) },
717 PpmExpiryFeeEntry { expiry_blocks_threshold: 500, ppm: PpmFeeRate(2_000) },
718 ],
719 };
720
721 let script_str = "6a0474657374"; let destination = ScriptBuf::from_hex(script_str)
723 .expect("Failed to parse OP_RETURN script hex string");
724 let fee_rate = FeeRate::from_sat_per_vb_u32(10);
725 let vtxos = vec![
728 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 }, ];
732
733 let amount_to_send = Amount::from_sat(100_000);
734 let fee = fees.calculate(&destination, amount_to_send, fee_rate, vtxos).unwrap();
735 assert_eq!(fee, Amount::from_sat(1_350));
741 }
742
743 #[test]
744 fn test_offboard_fees_with_no_fee_rate() {
745 let fees = OffboardFees {
746 base_fee: Amount::from_sat(200),
747 fixed_additional_vb: 100,
748 ppm_expiry_table: vec![
749 PpmExpiryFeeEntry { expiry_blocks_threshold: 1, ppm: PpmFeeRate(1_000) },
750 ],
751 };
752
753 let script_str = "6a0474657374"; let destination = ScriptBuf::from_hex(script_str)
755 .expect("Failed to parse OP_RETURN script hex string");
756 let fee_rate = FeeRate::from_sat_per_vb_u32(0);
757 let vtxos = vec![
758 VtxoFeeInfo { amount: Amount::from_sat(200_000), expiry_blocks: 50 }, ];
760
761 let amount_to_send = Amount::from_sat(100_000);
762 let fee = fees.calculate(&destination, amount_to_send, fee_rate, vtxos).unwrap();
763 assert_eq!(fee, Amount::from_sat(300));
765 }
766
767 #[test]
768 fn test_offboard_fees_with_no_additional_vb() {
769 let fees = OffboardFees {
770 base_fee: Amount::from_sat(200),
771 fixed_additional_vb: 0,
772 ppm_expiry_table: vec![
773 PpmExpiryFeeEntry { expiry_blocks_threshold: 1, ppm: PpmFeeRate(1_000) },
774 ],
775 };
776
777 let script_str = "6a0474657374"; let destination = ScriptBuf::from_hex(script_str)
779 .expect("Failed to parse OP_RETURN script hex string");
780 let fee_rate = FeeRate::from_sat_per_vb_u32(10);
781 let vtxos = vec![
782 VtxoFeeInfo { amount: Amount::from_sat(200_000), expiry_blocks: 50 }, ];
784
785 let amount_to_send = Amount::from_sat(100_000);
786 let fee = fees.calculate(&destination, amount_to_send, fee_rate, vtxos).unwrap();
787 assert_eq!(fee, Amount::from_sat(360));
789 }
790
791 #[test]
792 fn test_refresh_fees_with_single_vtxo() {
793 let fees = RefreshFees {
794 base_fee: Amount::from_sat(150),
795 ppm_expiry_table: vec![
796 PpmExpiryFeeEntry { expiry_blocks_threshold: 200, ppm: PpmFeeRate(500) },
797 PpmExpiryFeeEntry { expiry_blocks_threshold: 600, ppm: PpmFeeRate(1_500) },
798 ],
799 };
800
801 let amount = Amount::from_sat(200_000);
802
803 let vtxo = VtxoFeeInfo { amount, expiry_blocks: 400 };
805 let fee = fees.calculate(vec![vtxo]).unwrap();
806 assert_eq!(fee, Amount::from_sat(250));
808
809 let vtxo = VtxoFeeInfo { amount, expiry_blocks: 800 };
811 let fee = fees.calculate(vec![vtxo]).unwrap();
812 assert_eq!(fee, Amount::from_sat(450));
814 }
815
816 #[test]
817 fn test_refresh_fees_with_multiple_vtxos() {
818 let fees = RefreshFees {
819 base_fee: Amount::from_sat(50),
820 ppm_expiry_table: vec![
821 PpmExpiryFeeEntry { expiry_blocks_threshold: 200, ppm: PpmFeeRate(500) },
822 PpmExpiryFeeEntry { expiry_blocks_threshold: 600, ppm: PpmFeeRate(1_500) },
823 ],
824 };
825
826 let vtxos = vec![
828 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 }, ];
832
833 let fee = fees.calculate(vtxos).unwrap();
834 assert_eq!(fee, Amount::from_sat(220));
840 }
841
842 #[test]
843 fn test_lightning_receive_fees() {
844 let fees = LightningReceiveFees {
845 base_fee: Amount::from_sat(100),
846 ppm: PpmFeeRate(2_000), };
848
849 let amount = Amount::from_sat(10_000);
850 let fee = fees.calculate(amount).unwrap();
851 assert_eq!(fee, Amount::from_sat(120));
853
854 let amount = Amount::from_sat(10_400);
856 assert_eq!(fees.calculate(amount), Some(Amount::from_sat(121)));
858 #[allow(deprecated)]
859 let fee = fees.calculate_legacy(amount);
860 assert_eq!(fee, Some(Amount::from_sat(120)));
862 }
863
864 #[test]
865 fn test_lightning_send_fees_with_single_vtxo() {
866 let mut fees = LightningSendFees {
867 min_fee: Amount::from_sat(10),
868 base_fee: Amount::from_sat(75),
869 ppm_expiry_table: vec![
870 PpmExpiryFeeEntry { expiry_blocks_threshold: 50, ppm: PpmFeeRate(250) },
871 PpmExpiryFeeEntry { expiry_blocks_threshold: 100, ppm: PpmFeeRate(750) },
872 ],
873 };
874
875 let amount = Amount::from_sat(1_000_000);
876
877 let vtxo = VtxoFeeInfo { amount, expiry_blocks: 75 };
879 let fee = fees.calculate(amount, vec![vtxo]).unwrap();
880 assert_eq!(fee, Amount::from_sat(325));
882
883 let vtxo = VtxoFeeInfo { amount, expiry_blocks: 150 };
885 let fee = fees.calculate(amount, vec![vtxo]).unwrap();
886 assert_eq!(fee, Amount::from_sat(825));
888
889 fees.min_fee = Amount::from_sat(330);
891 let vtxo = VtxoFeeInfo { amount: Amount::from_sat(1_000), expiry_blocks: 150 };
892 let fee = fees.calculate(amount, vec![vtxo]).unwrap();
893 assert_eq!(fee, Amount::from_sat(330));
895 }
896
897 #[test]
898 fn test_lightning_send_fees_with_multiple_vtxos() {
899 let fees = LightningSendFees {
900 min_fee: Amount::from_sat(10),
901 base_fee: Amount::from_sat(25),
902 ppm_expiry_table: vec![
903 PpmExpiryFeeEntry { expiry_blocks_threshold: 50, ppm: PpmFeeRate(250) },
904 PpmExpiryFeeEntry { expiry_blocks_threshold: 100, ppm: PpmFeeRate(750) },
905 PpmExpiryFeeEntry { expiry_blocks_threshold: 200, ppm: PpmFeeRate(1_500) },
906 ],
907 };
908
909 let vtxos = vec![
912 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 }, ];
916
917 let amount_to_send = Amount::from_sat(1_000_000);
918 let fee = fees.calculate(amount_to_send, vtxos).unwrap();
919 assert_eq!(fee, Amount::from_sat(650));
925 }
926
927 #[test]
928 #[allow(deprecated)]
929 fn test_ppm_expiry_fee_totals() {
930 let table = vec![
931 PpmExpiryFeeEntry { expiry_blocks_threshold: 1_008, ppm: PpmFeeRate(2_000) },
932 PpmExpiryFeeEntry { expiry_blocks_threshold: 2_016, ppm: PpmFeeRate(4_000) },
933 ];
934
935 let vtxos = vec![VtxoFeeInfo { amount: Amount::from_sat(330), expiry_blocks: 1_500 }; 100];
937 let fee = calc_ppm_expiry_fee_legacy(None, &table, vtxos.clone());
938 assert_eq!(fee, Some(Amount::ZERO));
940 let fee = calc_ppm_expiry_fee(None, &table, vtxos);
941 assert_eq!(fee, Some(Amount::from_sat(66)));
943
944 let vtxos = vec![
946 VtxoFeeInfo { amount: Amount::from_sat(900), expiry_blocks: 1_100 },
947 VtxoFeeInfo { amount: Amount::from_sat(900), expiry_blocks: 1_200 },
948 VtxoFeeInfo { amount: Amount::from_sat(1_300), expiry_blocks: 2_500 },
949 ];
950 let fee = calc_ppm_expiry_fee_legacy(None, &table, vtxos.clone());
951 assert_eq!(fee, Some(Amount::from_sat(7)));
953 let fee = calc_ppm_expiry_fee(None, &table, vtxos);
954 assert_eq!(fee, Some(Amount::from_sat(9)));
956
957 let vtxos = vec![
959 VtxoFeeInfo { amount: Amount::from_sat(900), expiry_blocks: 1_100 },
960 VtxoFeeInfo { amount: Amount::from_sat(900), expiry_blocks: 1_200 },
961 VtxoFeeInfo { amount: Amount::from_sat(900), expiry_blocks: 2_500 },
962 ];
963 let cap = Some(Amount::from_sat(1_500));
964 let fee = calc_ppm_expiry_fee_legacy(cap, &table, vtxos.clone());
965 assert_eq!(fee, Some(Amount::from_sat(2)));
967 let fee = calc_ppm_expiry_fee(cap, &table, vtxos);
968 assert_eq!(fee, Some(Amount::from_sat(3)));
970
971 let vtxos = vec![VtxoFeeInfo { amount: Amount::from_sat(100_000), expiry_blocks: 500 }; 10];
973 let fee = calc_ppm_expiry_fee_legacy(None, &table, vtxos.clone());
974 assert_eq!(fee, Some(Amount::ZERO));
975 let fee = calc_ppm_expiry_fee(None, &table, vtxos);
976 assert_eq!(fee, Some(Amount::ZERO));
977 }
978
979 #[test]
980 fn test_ppm_expiry_fee_lagging_tip_pays_at_least_ours() {
981 let table = vec![
982 PpmExpiryFeeEntry { expiry_blocks_threshold: 0, ppm: PpmFeeRate::ZERO },
983 PpmExpiryFeeEntry { expiry_blocks_threshold: 1_008, ppm: PpmFeeRate(2_000) },
984 PpmExpiryFeeEntry { expiry_blocks_threshold: 2_016, ppm: PpmFeeRate(4_000) },
985 ];
986
987 let ours = vec![
991 VtxoFeeInfo { amount: Amount::from_sat(100), expiry_blocks: 2_015 },
992 VtxoFeeInfo { amount: Amount::from_sat(900), expiry_blocks: 1_500 },
993 ];
994 let theirs = vec![
995 VtxoFeeInfo { amount: Amount::from_sat(100), expiry_blocks: 2_016 },
996 VtxoFeeInfo { amount: Amount::from_sat(900), expiry_blocks: 1_501 },
997 ];
998 let ours = calc_ppm_expiry_fee(None, &table, ours).unwrap();
1000 assert_eq!(ours, Amount::from_sat(2));
1001 let theirs = calc_ppm_expiry_fee(None, &table, theirs).unwrap();
1003 assert_eq!(theirs, Amount::from_sat(3));
1004 assert!(theirs >= ours);
1005 }
1006}