use std::cmp::PartialOrd;
use std::ops;
use bitcoin::{Amount, FeeRate, ScriptBuf, Weight};
use bitcoin_ext::{BlockHeight, P2TR_DUST};
use crate::Vtxo;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
pub struct FeeSchedule {
pub board: BoardFees,
pub offboard: OffboardFees,
pub refresh: RefreshFees,
pub lightning_receive: LightningReceiveFees,
pub lightning_send: LightningSendFees,
}
impl FeeSchedule {
pub fn validate(&self) -> Result<(), FeeScheduleValidationError> {
let tables = [
("lightning_send", &self.lightning_send.ppm_expiry_table),
("offboard", &self.offboard.ppm_expiry_table),
("refresh", &self.refresh.ppm_expiry_table),
];
for (name, ppm_expiry_table) in tables {
let mut prev_entry : Option<&PpmExpiryFeeEntry> = None;
for current in ppm_expiry_table {
if let Some(previous) = prev_entry {
if current.expiry_blocks_threshold < previous.expiry_blocks_threshold {
return Err(FeeScheduleValidationError::UnsortedPpmFeeTable {
name: name.to_string(),
current: current.expiry_blocks_threshold,
previous: previous.expiry_blocks_threshold,
})
}
if current.ppm < previous.ppm {
return Err(FeeScheduleValidationError::IncorrectPpmFeeCurve {
name: name.to_string(),
current: current.ppm.0,
previous: previous.ppm.0,
});
}
}
prev_entry = Some(current);
}
}
Ok(())
}
}
impl Default for FeeSchedule {
fn default() -> Self {
let table = vec![PpmExpiryFeeEntry { expiry_blocks_threshold: 0, ppm: PpmFeeRate::ZERO }];
Self {
board: BoardFees {
min_fee: Amount::ZERO,
base_fee: Amount::ZERO,
ppm: PpmFeeRate::ZERO,
},
offboard: OffboardFees {
base_fee: Amount::ZERO,
fixed_additional_vb: 0,
ppm_expiry_table: table.clone(),
},
refresh: RefreshFees {
base_fee: Amount::ZERO,
ppm_expiry_table: table.clone(),
},
lightning_receive: LightningReceiveFees {
base_fee: Amount::ZERO,
ppm: PpmFeeRate::ZERO,
},
lightning_send: LightningSendFees {
min_fee: Amount::ZERO,
base_fee: Amount::ZERO,
ppm_expiry_table: table.clone(),
},
}
}
}
#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq, Hash)]
pub enum FeeScheduleValidationError {
#[error("{name} ppm expiry table must be sorted by expiry threshold in ascending order of expiry. {previous} is higher than {current}.")]
UnsortedPpmFeeTable { name: String, current: u32, previous: u32 },
#[error("{name} ppm expiry table fee curve must be in ascending order. {previous} is higher than {current}.")]
IncorrectPpmFeeCurve { name: String, current: u64, previous: u64 },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
pub struct BoardFees {
#[serde(rename = "min_fee_sat", with = "bitcoin::amount::serde::as_sat")]
pub min_fee: Amount,
#[serde(rename = "base_fee_sat", with = "bitcoin::amount::serde::as_sat")]
pub base_fee: Amount,
#[serde(rename = "ppm")]
pub ppm: PpmFeeRate,
}
impl BoardFees {
pub fn calculate(&self, amount: Amount) -> Option<Amount> {
let fee = self.ppm.checked_mul(amount)?.checked_add(self.base_fee)?;
Some(fee.max(self.min_fee))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
pub struct OffboardFees {
#[serde(rename = "base_fee_sat", with = "bitcoin::amount::serde::as_sat")]
pub base_fee: Amount,
pub fixed_additional_vb: u64,
pub ppm_expiry_table: Vec<PpmExpiryFeeEntry>,
}
impl OffboardFees {
pub fn calculate(
&self,
destination: &ScriptBuf,
amount: Amount,
fee_rate: FeeRate,
vtxos: impl IntoIterator<Item = VtxoFeeInfo>,
) -> Option<Amount> {
let weight_fee = self.fixed_additional_vb.checked_add(destination.as_script().len() as u64)
.and_then(Weight::from_vb)
.and_then(|w| fee_rate.checked_mul_by_weight(w))?;
let ppm_fee = calc_ppm_expiry_fee(Some(amount), &self.ppm_expiry_table, vtxos)?;
self.base_fee.checked_add(weight_fee)?.checked_add(ppm_fee)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
pub struct RefreshFees {
#[serde(rename = "base_fee_sat", with = "bitcoin::amount::serde::as_sat")]
pub base_fee: Amount,
pub ppm_expiry_table: Vec<PpmExpiryFeeEntry>,
}
impl RefreshFees {
pub fn calculate(
&self,
vtxos: impl IntoIterator<Item = VtxoFeeInfo>,
) -> Option<Amount> {
self.base_fee.checked_add(self.calculate_no_base_fee(vtxos)?)
}
pub fn calculate_no_base_fee(
&self,
vtxos: impl IntoIterator<Item = VtxoFeeInfo>,
) -> Option<Amount> {
calc_ppm_expiry_fee(None, &self.ppm_expiry_table, vtxos)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
pub struct LightningReceiveFees {
#[serde(rename = "base_fee_sat", with = "bitcoin::amount::serde::as_sat")]
pub base_fee: Amount,
pub ppm: PpmFeeRate,
}
impl LightningReceiveFees {
pub fn calculate(&self, amount: Amount) -> Option<Amount> {
self.base_fee.checked_add(self.ppm.checked_mul(amount)?)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
pub struct LightningSendFees {
#[serde(rename = "min_fee_sat", with = "bitcoin::amount::serde::as_sat")]
pub min_fee: Amount,
#[serde(rename = "base_fee_sat", with = "bitcoin::amount::serde::as_sat")]
pub base_fee: Amount,
pub ppm_expiry_table: Vec<PpmExpiryFeeEntry>,
}
impl LightningSendFees {
pub fn calculate(
&self,
amount: Amount,
vtxos: impl IntoIterator<Item = VtxoFeeInfo>,
) -> Option<Amount> {
let ppm = calc_ppm_expiry_fee(Some(amount), &self.ppm_expiry_table, vtxos)?;
Some(self.base_fee.checked_add(ppm)?.max(self.min_fee))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
pub struct VtxoFeeInfo {
pub amount: Amount,
pub expiry_blocks: u32,
}
impl VtxoFeeInfo {
pub fn from_vtxo_and_tip<G>(vtxo: &Vtxo<G>, tip: BlockHeight) -> Self {
Self {
amount: vtxo.amount(),
expiry_blocks: vtxo.expiry_height().saturating_sub(tip),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Deserialize, Serialize)]
pub struct PpmFeeRate(pub u64);
impl PpmFeeRate {
pub const ZERO: PpmFeeRate = PpmFeeRate(0);
pub const ONE_PERCENT: PpmFeeRate = PpmFeeRate(10_000);
pub fn checked_mul(self, other: Amount) -> Option<Amount> {
let numerator = other.to_sat().checked_mul(self.0)?;
Some(Amount::from_sat(numerator / 1_000_000))
}
}
impl ops::Mul<PpmFeeRate> for Amount {
type Output = Amount;
fn mul(self, ppm: PpmFeeRate) -> Self::Output {
let numerator = self.to_sat().saturating_mul(ppm.0);
Amount::from_sat(numerator / 1_000_000)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
pub struct PpmExpiryFeeEntry {
pub expiry_blocks_threshold: u32,
pub ppm: PpmFeeRate,
}
#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq, Hash)]
pub enum FeeValidationError {
#[error("Fee ({fee}) exceeds amount ({amount})")]
FeeExceedsAmount { amount: Amount, fee: Amount },
#[error("Amount after fee ({amount_after_fee}) is below dust limit ({P2TR_DUST}). Amount: {amount}, Fee: {fee}")]
AmountAfterFeeBelowDust {
amount: Amount,
fee: Amount,
amount_after_fee: Amount,
},
}
pub fn validate_and_subtract_fee(
amount: Amount,
fee: Amount,
) -> Result<Amount, FeeValidationError> {
let amount_after_fee = amount.checked_sub(fee)
.ok_or(FeeValidationError::FeeExceedsAmount { amount, fee })?;
if amount_after_fee == Amount::ZERO {
Err(FeeValidationError::FeeExceedsAmount { amount, fee })
} else {
Ok(amount_after_fee)
}
}
pub fn validate_and_subtract_fee_min_dust(
amount: Amount,
fee: Amount,
) -> Result<Amount, FeeValidationError> {
let amount_after_fee = amount.checked_sub(fee)
.ok_or(FeeValidationError::FeeExceedsAmount { amount, fee })?;
if amount_after_fee < P2TR_DUST {
return Err(FeeValidationError::AmountAfterFeeBelowDust {
amount,
fee,
amount_after_fee,
});
}
Ok(amount_after_fee)
}
pub fn calc_ppm_expiry_fee(
fee_chargeable_amount: Option<Amount>,
ppm_expiry_table: &Vec<PpmExpiryFeeEntry>,
vtxos: impl IntoIterator<Item = VtxoFeeInfo>,
) -> Option<Amount> {
let mut total_fee = Amount::ZERO;
let mut remaining = fee_chargeable_amount;
for v in vtxos {
let fee_chargeable_amount = if let Some(ref mut remaining) = remaining {
let amount = v.amount.min(*remaining);
*remaining -= amount;
amount
} else {
v.amount
};
let entry = ppm_expiry_table
.iter()
.rev()
.find(|entry| v.expiry_blocks >= entry.expiry_blocks_threshold);
if let Some(entry) = entry {
total_fee = total_fee.checked_add(entry.ppm.checked_mul(fee_chargeable_amount)?)?;
}
}
Some(total_fee)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_board_fees() {
let mut fees = BoardFees {
min_fee: Amount::ZERO,
base_fee: Amount::from_sat(100),
ppm: PpmFeeRate(1_000), };
let amount = Amount::from_sat(10_000);
let fee = fees.calculate(amount).unwrap();
assert_eq!(fee, Amount::from_sat(110));
fees.min_fee = Amount::from_sat(330);
let amount = Amount::from_sat(10_000);
let fee = fees.calculate(amount).unwrap();
assert_eq!(fee, Amount::from_sat(330));
}
#[test]
fn test_offboard_fees_with_single_vtxo() {
let fees = OffboardFees {
base_fee: Amount::from_sat(200),
fixed_additional_vb: 100,
ppm_expiry_table: vec![
PpmExpiryFeeEntry { expiry_blocks_threshold: 100, ppm: PpmFeeRate(1_000) },
PpmExpiryFeeEntry { expiry_blocks_threshold: 500, ppm: PpmFeeRate(2_000) },
PpmExpiryFeeEntry { expiry_blocks_threshold: 1_000, ppm: PpmFeeRate(3_000) },
],
};
let script_str = "6a0474657374"; let destination = ScriptBuf::from_hex(script_str)
.expect("Failed to parse OP_RETURN script hex string");
let fee_rate = FeeRate::from_sat_per_vb_unchecked(10);
let amount = Amount::from_sat(100_000);
let vtxo = VtxoFeeInfo { amount, expiry_blocks: 50 };
let fee = fees.calculate(&destination, amount, fee_rate, vec![vtxo]).unwrap();
assert_eq!(fee, Amount::from_sat(1_260));
let vtxo = VtxoFeeInfo { amount, expiry_blocks: 150 };
let fee = fees.calculate(&destination, amount, fee_rate, vec![vtxo]).unwrap();
assert_eq!(fee, Amount::from_sat(1_360));
let vtxo = VtxoFeeInfo { amount, expiry_blocks: 750 };
let fee = fees.calculate(&destination, amount, fee_rate, vec![vtxo]).unwrap();
assert_eq!(fee, Amount::from_sat(1_460));
let vtxo = VtxoFeeInfo { amount, expiry_blocks: 2_000 };
let fee = fees.calculate(&destination, amount, fee_rate, vec![vtxo]).unwrap();
assert_eq!(fee, Amount::from_sat(1_560));
}
#[test]
fn test_offboard_fees_with_multiple_vtxos() {
let fees = OffboardFees {
base_fee: Amount::from_sat(200),
fixed_additional_vb: 100,
ppm_expiry_table: vec![
PpmExpiryFeeEntry { expiry_blocks_threshold: 100, ppm: PpmFeeRate(1_000) },
PpmExpiryFeeEntry { expiry_blocks_threshold: 500, ppm: PpmFeeRate(2_000) },
],
};
let script_str = "6a0474657374"; let destination = ScriptBuf::from_hex(script_str)
.expect("Failed to parse OP_RETURN script hex string");
let fee_rate = FeeRate::from_sat_per_vb_unchecked(10);
let vtxos = vec![
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 }, ];
let amount_to_send = Amount::from_sat(100_000);
let fee = fees.calculate(&destination, amount_to_send, fee_rate, vtxos).unwrap();
assert_eq!(fee, Amount::from_sat(1_350));
}
#[test]
fn test_offboard_fees_with_no_fee_rate() {
let fees = OffboardFees {
base_fee: Amount::from_sat(200),
fixed_additional_vb: 100,
ppm_expiry_table: vec![
PpmExpiryFeeEntry { expiry_blocks_threshold: 1, ppm: PpmFeeRate(1_000) },
],
};
let script_str = "6a0474657374"; let destination = ScriptBuf::from_hex(script_str)
.expect("Failed to parse OP_RETURN script hex string");
let fee_rate = FeeRate::from_sat_per_vb_unchecked(0);
let vtxos = vec![
VtxoFeeInfo { amount: Amount::from_sat(200_000), expiry_blocks: 50 }, ];
let amount_to_send = Amount::from_sat(100_000);
let fee = fees.calculate(&destination, amount_to_send, fee_rate, vtxos).unwrap();
assert_eq!(fee, Amount::from_sat(300));
}
#[test]
fn test_offboard_fees_with_no_additional_vb() {
let fees = OffboardFees {
base_fee: Amount::from_sat(200),
fixed_additional_vb: 0,
ppm_expiry_table: vec![
PpmExpiryFeeEntry { expiry_blocks_threshold: 1, ppm: PpmFeeRate(1_000) },
],
};
let script_str = "6a0474657374"; let destination = ScriptBuf::from_hex(script_str)
.expect("Failed to parse OP_RETURN script hex string");
let fee_rate = FeeRate::from_sat_per_vb_unchecked(10);
let vtxos = vec![
VtxoFeeInfo { amount: Amount::from_sat(200_000), expiry_blocks: 50 }, ];
let amount_to_send = Amount::from_sat(100_000);
let fee = fees.calculate(&destination, amount_to_send, fee_rate, vtxos).unwrap();
assert_eq!(fee, Amount::from_sat(360));
}
#[test]
fn test_refresh_fees_with_single_vtxo() {
let fees = RefreshFees {
base_fee: Amount::from_sat(150),
ppm_expiry_table: vec![
PpmExpiryFeeEntry { expiry_blocks_threshold: 200, ppm: PpmFeeRate(500) },
PpmExpiryFeeEntry { expiry_blocks_threshold: 600, ppm: PpmFeeRate(1_500) },
],
};
let amount = Amount::from_sat(200_000);
let vtxo = VtxoFeeInfo { amount, expiry_blocks: 400 };
let fee = fees.calculate(vec![vtxo]).unwrap();
assert_eq!(fee, Amount::from_sat(250));
let vtxo = VtxoFeeInfo { amount, expiry_blocks: 800 };
let fee = fees.calculate(vec![vtxo]).unwrap();
assert_eq!(fee, Amount::from_sat(450));
}
#[test]
fn test_refresh_fees_with_multiple_vtxos() {
let fees = RefreshFees {
base_fee: Amount::from_sat(50),
ppm_expiry_table: vec![
PpmExpiryFeeEntry { expiry_blocks_threshold: 200, ppm: PpmFeeRate(500) },
PpmExpiryFeeEntry { expiry_blocks_threshold: 600, ppm: PpmFeeRate(1_500) },
],
};
let vtxos = vec![
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 }, ];
let fee = fees.calculate(vtxos).unwrap();
assert_eq!(fee, Amount::from_sat(220));
}
#[test]
fn test_lightning_receive_fees() {
let fees = LightningReceiveFees {
base_fee: Amount::from_sat(100),
ppm: PpmFeeRate(2_000), };
let amount = Amount::from_sat(10_000);
let fee = fees.calculate(amount).unwrap();
assert_eq!(fee, Amount::from_sat(120));
}
#[test]
fn test_lightning_send_fees_with_single_vtxo() {
let mut fees = LightningSendFees {
min_fee: Amount::from_sat(10),
base_fee: Amount::from_sat(75),
ppm_expiry_table: vec![
PpmExpiryFeeEntry { expiry_blocks_threshold: 50, ppm: PpmFeeRate(250) },
PpmExpiryFeeEntry { expiry_blocks_threshold: 100, ppm: PpmFeeRate(750) },
],
};
let amount = Amount::from_sat(1_000_000);
let vtxo = VtxoFeeInfo { amount, expiry_blocks: 75 };
let fee = fees.calculate(amount, vec![vtxo]).unwrap();
assert_eq!(fee, Amount::from_sat(325));
let vtxo = VtxoFeeInfo { amount, expiry_blocks: 150 };
let fee = fees.calculate(amount, vec![vtxo]).unwrap();
assert_eq!(fee, Amount::from_sat(825));
fees.min_fee = Amount::from_sat(330);
let vtxo = VtxoFeeInfo { amount: Amount::from_sat(1_000), expiry_blocks: 150 };
let fee = fees.calculate(amount, vec![vtxo]).unwrap();
assert_eq!(fee, Amount::from_sat(330));
}
#[test]
fn test_lightning_send_fees_with_multiple_vtxos() {
let fees = LightningSendFees {
min_fee: Amount::from_sat(10),
base_fee: Amount::from_sat(25),
ppm_expiry_table: vec![
PpmExpiryFeeEntry { expiry_blocks_threshold: 50, ppm: PpmFeeRate(250) },
PpmExpiryFeeEntry { expiry_blocks_threshold: 100, ppm: PpmFeeRate(750) },
PpmExpiryFeeEntry { expiry_blocks_threshold: 200, ppm: PpmFeeRate(1_500) },
],
};
let vtxos = vec![
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 }, ];
let amount_to_send = Amount::from_sat(1_000_000);
let fee = fees.calculate(amount_to_send, vtxos).unwrap();
assert_eq!(fee, Amount::from_sat(650));
}
}