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#[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 = self.ppm.checked_mul(amount)?.checked_add(self.base_fee)?;
120 Some(fee.max(self.min_fee))
121 }
122}
123
124#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
126pub struct OffboardFees {
127 #[serde(rename = "base_fee_sat", with = "bitcoin::amount::serde::as_sat")]
129 pub base_fee: Amount,
130
131 pub fixed_additional_vb: u64,
137
138 pub ppm_expiry_table: Vec<PpmExpiryFeeEntry>,
141}
142
143impl OffboardFees {
144 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#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
164pub struct RefreshFees {
165 #[serde(rename = "base_fee_sat", with = "bitcoin::amount::serde::as_sat")]
167 pub base_fee: Amount,
168 pub ppm_expiry_table: Vec<PpmExpiryFeeEntry>,
171}
172
173impl RefreshFees {
174 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
197pub struct LightningReceiveFees {
198 #[serde(rename = "base_fee_sat", with = "bitcoin::amount::serde::as_sat")]
200 pub base_fee: Amount,
201 pub ppm: PpmFeeRate,
203}
204
205impl LightningReceiveFees {
206 pub fn calculate(&self, amount: Amount) -> Option<Amount> {
210 self.base_fee.checked_add(self.ppm.checked_mul(amount)?)
211 }
212}
213
214#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
216pub struct LightningSendFees {
217 #[serde(rename = "min_fee_sat", with = "bitcoin::amount::serde::as_sat")]
219 pub min_fee: Amount,
220 #[serde(rename = "base_fee_sat", with = "bitcoin::amount::serde::as_sat")]
222 pub base_fee: Amount,
223 pub ppm_expiry_table: Vec<PpmExpiryFeeEntry>,
226}
227
228impl LightningSendFees {
229 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
244pub struct VtxoFeeInfo {
245 pub amount: Amount,
247 pub expiry_blocks: u32,
249}
250
251impl VtxoFeeInfo {
252 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 pub const ZERO: PpmFeeRate = PpmFeeRate(0);
267 pub const ONE_PERCENT: PpmFeeRate = PpmFeeRate(10_000);
269
270 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
307pub struct PpmExpiryFeeEntry {
308 pub expiry_blocks_threshold: u32,
313 pub ppm: PpmFeeRate,
315}
316
317#[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
331pub 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
375pub 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 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
434pub 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 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 let entry = ppm_expiry_table
496 .iter()
497 .rev()
498 .find(|entry| v.expiry_blocks >= entry.expiry_blocks_threshold);
499
500 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), };
519
520 let amount = Amount::from_sat(10_000);
522 let fee = fees.calculate(amount).unwrap();
523 assert_eq!(fee, Amount::from_sat(110));
525
526 fees.min_fee = Amount::from_sat(330);
528 let amount = Amount::from_sat(10_000);
529 let fee = fees.calculate(amount).unwrap();
530 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"; 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 let vtxo = VtxoFeeInfo { amount, expiry_blocks: 50 };
554 let fee = fees.calculate(&destination, amount, fee_rate, vec![vtxo]).unwrap();
555 assert_eq!(fee, Amount::from_sat(1_260));
557
558 let vtxo = VtxoFeeInfo { amount, expiry_blocks: 150 };
560 let fee = fees.calculate(&destination, amount, fee_rate, vec![vtxo]).unwrap();
561 assert_eq!(fee, Amount::from_sat(1_360));
563
564 let vtxo = VtxoFeeInfo { amount, expiry_blocks: 750 };
566 let fee = fees.calculate(&destination, amount, fee_rate, vec![vtxo]).unwrap();
567 assert_eq!(fee, Amount::from_sat(1_460));
569
570 let vtxo = VtxoFeeInfo { amount, expiry_blocks: 2_000 };
572 let fee = fees.calculate(&destination, amount, fee_rate, vec![vtxo]).unwrap();
573 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"; 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 let vtxos = vec![
595 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 }, ];
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 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"; 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 }, ];
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 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"; 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 }, ];
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 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 let vtxo = VtxoFeeInfo { amount, expiry_blocks: 400 };
672 let fee = fees.calculate(vec![vtxo]).unwrap();
673 assert_eq!(fee, Amount::from_sat(250));
675
676 let vtxo = VtxoFeeInfo { amount, expiry_blocks: 800 };
678 let fee = fees.calculate(vec![vtxo]).unwrap();
679 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 let vtxos = vec![
695 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 }, ];
699
700 let fee = fees.calculate(vtxos).unwrap();
701 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), };
715
716 let amount = Amount::from_sat(10_000);
717 let fee = fees.calculate(amount).unwrap();
718 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 let vtxo = VtxoFeeInfo { amount, expiry_blocks: 75 };
737 let fee = fees.calculate(amount, vec![vtxo]).unwrap();
738 assert_eq!(fee, Amount::from_sat(325));
740
741 let vtxo = VtxoFeeInfo { amount, expiry_blocks: 150 };
743 let fee = fees.calculate(amount, vec![vtxo]).unwrap();
744 assert_eq!(fee, Amount::from_sat(825));
746
747 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 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 let vtxos = vec![
770 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 }, ];
774
775 let amount_to_send = Amount::from_sat(1_000_000);
776 let fee = fees.calculate(amount_to_send, vtxos).unwrap();
777 assert_eq!(fee, Amount::from_sat(650));
783 }
784}