Skip to content

Commit c0c0845

Browse files
committed
Add FeePolicy/FeeTier ADT and resolve_skim
Introduce the fee_policy module: a FeePolicy/FeeTier ADT and a single pure resolve_skim mapping a policy plus an HTLC amount to the msat to skim. Shared foundation for upcoming work that waives the JIT-channel skim for grant recipients; nothing wires it up yet. When the skim would eat the whole HTLC, resolve_skim waives it rather than clamping to amount - 1: a residual that small may fall below htlc_minimum_msat and be rejected anyway, so clamping would bank a fee on a payment that never settles. This only triggers for a rate >= 100% or an outsized Custom base; the standard 2% never reaches it. This is also where resolve_skim deliberately diverges from the code it replaces. The service computed the proportional fee in u64, so amount * ppm overflowed for very large HTLCs and skimmed nothing, forwarding the whole HTLC for free. resolve_skim multiplies in u128 and skims correctly. At the standard 2% the result is identical for any realistic HTLC; only the previously-overflowing range changes, from "free" to "charged". Tests pin the standard tier against the legacy computation for non-overflow sizes and document the large-HTLC case. The other arms are encoding-only. ZeroFee resolves to zero; Custom { ppm, base_msat } adds a flat base to the proportional component. Custom is kept though v1 never constructs it: it's free in the TLV and documents the shape of an explicit rate. Both enums serialize via impl_writeable_tlv_based_enum! with reserved type bytes (FeeTier: Standard=0, ZeroFee=2, Custom=4; FeePolicy: Flat=0) so future variants are additive. Flat is a length-prefixed tuple variant wrapping FeeTier, letting a later commit store a policy as a TLV field that defaults cleanly on old records.
1 parent aad7c22 commit c0c0845

2 files changed

Lines changed: 201 additions & 0 deletions

File tree

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
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+
}

lightning-liquidity/src/lsps4/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
1212
pub mod client;
1313
pub mod event;
14+
pub mod fee_policy;
1415
pub(crate) mod htlc_store;
1516
pub mod msgs;
1617
pub(crate) mod scid_store;

0 commit comments

Comments
 (0)