|
| 1 | +// This file is Copyright its original authors, visible in version control history. |
| 2 | +// |
| 3 | +// This file is licensed under the Apache License, Version 2.0 <LICENSE-APACHE or |
| 4 | +// http://www.apache.org/licenses/LICENSE-2.0> or the MIT license <LICENSE-MIT or |
| 5 | +// http://opensource.org/licenses/MIT>, at your option. You may not use this file except in |
| 6 | +// accordance with one or both of these licenses. |
| 7 | + |
| 8 | +//! Fee policy for the LSPS4 forwarding skim. |
| 9 | +//! |
| 10 | +//! The LSP skims a forwarding fee from every JIT-channel HTLC. This module carries the policy |
| 11 | +//! describing *what* to skim for a given peer and resolves it to a concrete msat amount via the |
| 12 | +//! single function [`resolve_skim`]. |
| 13 | +//! |
| 14 | +//! The initial version only ever constructs [`FeePolicy::Flat`], and the only tier the service |
| 15 | +//! resolves is [`FeeTier::Standard`], so for any realistically-sized HTLC the skim matches the |
| 16 | +//! previous hard-coded 2%. The richer arms exist so later milestones (per-peer policies, zero-fee |
| 17 | +//! grants) are purely additive. |
| 18 | +
|
| 19 | +use lightning::impl_writeable_tlv_based_enum; |
| 20 | + |
| 21 | +/// The rate at which a peer's forwarded HTLCs are skimmed. |
| 22 | +#[derive(Clone, Debug, PartialEq, Eq)] |
| 23 | +pub enum FeeTier { |
| 24 | + /// Skim at the LSP's configured proportional rate (`forwarding_fee_proportional_millionths`). |
| 25 | + Standard, |
| 26 | + /// Never skim. Used for grant recipients whose funding we do not take a cut of. |
| 27 | + ZeroFee, |
| 28 | + /// Skim at an explicit rate: `base_msat` plus `ppm` proportional millionths. |
| 29 | + Custom { |
| 30 | + /// Proportional rate in millionths applied to the HTLC amount. |
| 31 | + ppm: u64, |
| 32 | + /// Flat fee in millisatoshis added on top of the proportional component. |
| 33 | + base_msat: u64, |
| 34 | + }, |
| 35 | +} |
| 36 | + |
| 37 | +/// The discount applied to a peer. v1 only constructs [`FeePolicy::Flat`]; richer arms |
| 38 | +/// (time-limited, volume-capped, ...) are reserved as future tags so the wire format stays |
| 39 | +/// additive. |
| 40 | +#[derive(Clone, Debug, PartialEq, Eq)] |
| 41 | +pub enum FeePolicy { |
| 42 | + /// A flat policy that applies the same [`FeeTier`] to every HTLC. |
| 43 | + Flat(FeeTier), |
| 44 | +} |
| 45 | + |
| 46 | +impl_writeable_tlv_based_enum!(FeeTier, |
| 47 | + (0, Standard) => {}, |
| 48 | + (2, ZeroFee) => {}, |
| 49 | + (4, Custom) => { |
| 50 | + (0, ppm, required), |
| 51 | + (2, base_msat, required), |
| 52 | + }, |
| 53 | +); |
| 54 | + |
| 55 | +impl_writeable_tlv_based_enum!(FeePolicy, |
| 56 | + {0, Flat} => (), |
| 57 | +); |
| 58 | + |
| 59 | +/// Resolve a [`FeePolicy`] to the msat amount to skim from a single HTLC. |
| 60 | +/// |
| 61 | +/// `standard_ppm` is the LSP's configured proportional rate, used only by [`FeeTier::Standard`]. |
| 62 | +/// |
| 63 | +/// The skim is waived in exactly one case: when it would consume the entire HTLC. A zero-value |
| 64 | +/// forward is rejected by the channel (`channel.rs` force-closes on a 0-msat `update_add_htlc`), |
| 65 | +/// so skimming the whole amount would break the forward; that is the only reason we ever waive. |
| 66 | +/// The proportional component is computed in 128-bit precision, so a very large HTLC is skimmed |
| 67 | +/// correctly rather than (as the previous `u64` arithmetic did) overflowing and forwarding the |
| 68 | +/// whole amount for free. |
| 69 | +pub(crate) fn resolve_skim(policy: &FeePolicy, htlc_amount_msat: u64, standard_ppm: u64) -> u64 { |
| 70 | + let fee_msat = match policy { |
| 71 | + FeePolicy::Flat(FeeTier::ZeroFee) => 0, |
| 72 | + FeePolicy::Flat(FeeTier::Standard) => proportional_fee_msat(htlc_amount_msat, standard_ppm), |
| 73 | + FeePolicy::Flat(FeeTier::Custom { ppm, base_msat }) => { |
| 74 | + base_msat.saturating_add(proportional_fee_msat(htlc_amount_msat, *ppm)) |
| 75 | + }, |
| 76 | + }; |
| 77 | + |
| 78 | + if fee_msat >= htlc_amount_msat { |
| 79 | + 0 |
| 80 | + } else { |
| 81 | + fee_msat |
| 82 | + } |
| 83 | +} |
| 84 | + |
| 85 | +/// `amount_msat * ppm / 1_000_000`, rounded up, computed in 128-bit so it can't overflow for any |
| 86 | +/// `u64` inputs. Saturates to `u64::MAX`, which the caller reads as "skims the whole HTLC". |
| 87 | +fn proportional_fee_msat(amount_msat: u64, ppm: u64) -> u64 { |
| 88 | + // `+ 999_999` before the integer divide is ceiling division: adding `denominator - 1` rounds |
| 89 | + // the result up, so a sub-msat fee skims 1 rather than truncating to 0 (never under-skim). |
| 90 | + let scaled = (amount_msat as u128) * (ppm as u128) + 999_999; |
| 91 | + u64::try_from(scaled / 1_000_000).unwrap_or(u64::MAX) |
| 92 | +} |
| 93 | + |
| 94 | +#[cfg(test)] |
| 95 | +mod tests { |
| 96 | + use super::*; |
| 97 | + use crate::lsps4::utils::compute_forward_fee; |
| 98 | + use lightning::util::ser::{Readable, Writeable}; |
| 99 | + |
| 100 | + /// The legacy inline computation the service used before `resolve_skim` existed, kept here as |
| 101 | + /// the oracle the `Standard` tier must match for any HTLC small enough not to overflow its |
| 102 | + /// `u64` arithmetic. |
| 103 | + fn legacy_standard_skim(amount: u64, ppm: u64) -> u64 { |
| 104 | + match compute_forward_fee(amount, ppm) { |
| 105 | + Some(fee) => { |
| 106 | + let fee = core::cmp::min(fee, amount); |
| 107 | + if amount.saturating_sub(fee) == 0 && fee > 0 { |
| 108 | + 0 |
| 109 | + } else { |
| 110 | + fee |
| 111 | + } |
| 112 | + }, |
| 113 | + None => 0, |
| 114 | + } |
| 115 | + } |
| 116 | + |
| 117 | + #[test] |
| 118 | + fn standard_matches_legacy_across_sizes() { |
| 119 | + let ppm = 20_000; // 2% |
| 120 | + // All sizes below the u64 overflow threshold (~9.2e14 msat at 2%), where the new 128-bit |
| 121 | + // math and the legacy u64 math agree exactly. |
| 122 | + for amount in [0u64, 1, 999, 1_000, 50_000, 1_000_000, 100_000_000, 1_000_000_000_000] { |
| 123 | + let policy = FeePolicy::Flat(FeeTier::Standard); |
| 124 | + assert_eq!( |
| 125 | + resolve_skim(&policy, amount, ppm), |
| 126 | + legacy_standard_skim(amount, ppm), |
| 127 | + "mismatch at amount={amount}" |
| 128 | + ); |
| 129 | + } |
| 130 | + } |
| 131 | + |
| 132 | + #[test] |
| 133 | + fn large_htlc_skims_instead_of_forwarding_free() { |
| 134 | + // 1e18 msat * 20_000 ppm overflows u64, so the legacy code skimmed nothing and forwarded |
| 135 | + // the whole HTLC for free. The 128-bit math skims the correct 2% instead. |
| 136 | + let amount = 1_000_000_000_000_000_000u64; |
| 137 | + assert_eq!(legacy_standard_skim(amount, 20_000), 0); |
| 138 | + let policy = FeePolicy::Flat(FeeTier::Standard); |
| 139 | + assert_eq!(resolve_skim(&policy, amount, 20_000), 20_000_000_000_000_000); |
| 140 | + } |
| 141 | + |
| 142 | + #[test] |
| 143 | + fn zero_fee_never_skims() { |
| 144 | + let policy = FeePolicy::Flat(FeeTier::ZeroFee); |
| 145 | + for amount in [0u64, 1, 1_000, u64::MAX] { |
| 146 | + assert_eq!(resolve_skim(&policy, amount, 1_000_000), 0); |
| 147 | + } |
| 148 | + } |
| 149 | + |
| 150 | + #[test] |
| 151 | + fn custom_adds_base_and_proportional() { |
| 152 | + // 1% proportional plus a flat 100 msat base. |
| 153 | + let policy = FeePolicy::Flat(FeeTier::Custom { ppm: 10_000, base_msat: 100 }); |
| 154 | + // 10_000 ppm of 1_000_000 = 10_000, plus 100 base = 10_100. |
| 155 | + assert_eq!(resolve_skim(&policy, 1_000_000, 0), 10_100); |
| 156 | + } |
| 157 | + |
| 158 | + #[test] |
| 159 | + fn custom_base_only_with_zero_ppm() { |
| 160 | + let policy = FeePolicy::Flat(FeeTier::Custom { ppm: 0, base_msat: 100 }); |
| 161 | + assert_eq!(resolve_skim(&policy, 1_000_000, 0), 100); |
| 162 | + } |
| 163 | + |
| 164 | + #[test] |
| 165 | + fn fee_at_or_above_amount_never_skims_whole_htlc() { |
| 166 | + // fee == amount: 1_000_000 ppm of 1_000 = 1_000 == amount -> 0. |
| 167 | + let policy = FeePolicy::Flat(FeeTier::Standard); |
| 168 | + assert_eq!(resolve_skim(&policy, 1_000, 1_000_000), 0); |
| 169 | + |
| 170 | + // fee > amount: 2_000_000 ppm of 1_000 = 2_000 > amount -> 0. |
| 171 | + assert_eq!(resolve_skim(&policy, 1_000, 2_000_000), 0); |
| 172 | + |
| 173 | + // Proportional component saturates to u64::MAX, which is >= the amount -> 0. |
| 174 | + assert_eq!(resolve_skim(&policy, u64::MAX, u64::MAX), 0); |
| 175 | + |
| 176 | + // A Custom base on its own large enough to swallow the HTLC -> 0. |
| 177 | + let policy = FeePolicy::Flat(FeeTier::Custom { ppm: 0, base_msat: u64::MAX }); |
| 178 | + assert_eq!(resolve_skim(&policy, 1_000, 0), 0); |
| 179 | + } |
| 180 | + |
| 181 | + fn round_trip<T: Readable + Writeable + PartialEq + core::fmt::Debug>(value: &T) { |
| 182 | + let bytes = value.encode(); |
| 183 | + let decoded: T = Readable::read(&mut &bytes[..]).unwrap(); |
| 184 | + assert_eq!(*value, decoded); |
| 185 | + } |
| 186 | + |
| 187 | + #[test] |
| 188 | + fn fee_tier_round_trips() { |
| 189 | + round_trip(&FeeTier::Standard); |
| 190 | + round_trip(&FeeTier::ZeroFee); |
| 191 | + round_trip(&FeeTier::Custom { ppm: 12_345, base_msat: 678 }); |
| 192 | + } |
| 193 | + |
| 194 | + #[test] |
| 195 | + fn fee_policy_round_trips() { |
| 196 | + round_trip(&FeePolicy::Flat(FeeTier::Standard)); |
| 197 | + round_trip(&FeePolicy::Flat(FeeTier::ZeroFee)); |
| 198 | + round_trip(&FeePolicy::Flat(FeeTier::Custom { ppm: 1, base_msat: 2 })); |
| 199 | + } |
| 200 | +} |
0 commit comments