Skip to content

Commit a234a10

Browse files
authored
Merge pull request #24 from moneydevkit/austin_mdk-979_fee-policy
Fee policy in the LSP
2 parents 79235c7 + c2279f8 commit a234a10

4 files changed

Lines changed: 268 additions & 20 deletions

File tree

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
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 zero in two distinct cases. [`FeeTier::ZeroFee`] never skims, by design. Any other
64+
/// tier is additionally forced to zero when its fee would consume the entire HTLC: a zero-value
65+
/// forward is rejected by the channel (`channel.rs` force-closes on a 0-msat `update_add_htlc`), so
66+
/// skimming the whole amount would break the forward; dropping to zero forwards it intact instead.
67+
/// The proportional component is computed in 128-bit precision, so a very large HTLC is skimmed
68+
/// correctly rather than (as the previous `u64` arithmetic did) overflowing and forwarding the
69+
/// whole amount for free.
70+
pub(crate) fn resolve_skim(policy: &FeePolicy, htlc_amount_msat: u64, standard_ppm: u64) -> u64 {
71+
let fee_msat = match policy {
72+
FeePolicy::Flat(FeeTier::ZeroFee) => 0,
73+
FeePolicy::Flat(FeeTier::Standard) => proportional_fee_msat(htlc_amount_msat, standard_ppm),
74+
FeePolicy::Flat(FeeTier::Custom { ppm, base_msat }) => {
75+
base_msat.saturating_add(proportional_fee_msat(htlc_amount_msat, *ppm))
76+
},
77+
};
78+
79+
if fee_msat >= htlc_amount_msat {
80+
0
81+
} else {
82+
fee_msat
83+
}
84+
}
85+
86+
/// `amount_msat * ppm / 1_000_000`, rounded up, computed in 128-bit so it can't overflow for any
87+
/// `u64` inputs. Saturates to `u64::MAX`, which the caller reads as "skims the whole HTLC".
88+
fn proportional_fee_msat(amount_msat: u64, ppm: u64) -> u64 {
89+
// `+ 999_999` before the integer divide is ceiling division: adding `denominator - 1` rounds
90+
// the result up, so a sub-msat fee skims 1 rather than truncating to 0 (never under-skim).
91+
let scaled = (amount_msat as u128) * (ppm as u128) + 999_999;
92+
u64::try_from(scaled / 1_000_000).unwrap_or(u64::MAX)
93+
}
94+
95+
#[cfg(test)]
96+
mod tests {
97+
use super::*;
98+
use crate::lsps4::utils::compute_forward_fee;
99+
use lightning::util::ser::{Readable, Writeable};
100+
101+
/// The legacy inline computation the service used before `resolve_skim` existed, kept here as
102+
/// the oracle the `Standard` tier must match for any HTLC small enough not to overflow its
103+
/// `u64` arithmetic.
104+
fn legacy_standard_skim(amount: u64, ppm: u64) -> u64 {
105+
match compute_forward_fee(amount, ppm) {
106+
Some(fee) => {
107+
let fee = core::cmp::min(fee, amount);
108+
if amount.saturating_sub(fee) == 0 && fee > 0 {
109+
0
110+
} else {
111+
fee
112+
}
113+
},
114+
None => 0,
115+
}
116+
}
117+
118+
#[test]
119+
fn standard_matches_legacy_across_sizes() {
120+
let ppm = 20_000; // 2%
121+
// All sizes below the u64 overflow threshold (~9.2e14 msat at 2%), where the new 128-bit
122+
// math and the legacy u64 math agree exactly.
123+
for amount in [0u64, 1, 999, 1_000, 50_000, 1_000_000, 100_000_000, 1_000_000_000_000] {
124+
let policy = FeePolicy::Flat(FeeTier::Standard);
125+
assert_eq!(
126+
resolve_skim(&policy, amount, ppm),
127+
legacy_standard_skim(amount, ppm),
128+
"mismatch at amount={amount}"
129+
);
130+
}
131+
}
132+
133+
#[test]
134+
fn large_htlc_skims_instead_of_forwarding_free() {
135+
// 1e18 msat * 20_000 ppm overflows u64, so the legacy code skimmed nothing and forwarded
136+
// the whole HTLC for free. The 128-bit math skims the correct 2% instead.
137+
let amount = 1_000_000_000_000_000_000u64;
138+
assert_eq!(legacy_standard_skim(amount, 20_000), 0);
139+
let policy = FeePolicy::Flat(FeeTier::Standard);
140+
assert_eq!(resolve_skim(&policy, amount, 20_000), 20_000_000_000_000_000);
141+
}
142+
143+
#[test]
144+
fn zero_fee_never_skims() {
145+
let policy = FeePolicy::Flat(FeeTier::ZeroFee);
146+
for amount in [0u64, 1, 1_000, u64::MAX] {
147+
assert_eq!(resolve_skim(&policy, amount, 1_000_000), 0);
148+
}
149+
}
150+
151+
#[test]
152+
fn custom_adds_base_and_proportional() {
153+
// 1% proportional plus a flat 100 msat base.
154+
let policy = FeePolicy::Flat(FeeTier::Custom { ppm: 10_000, base_msat: 100 });
155+
// 10_000 ppm of 1_000_000 = 10_000, plus 100 base = 10_100.
156+
assert_eq!(resolve_skim(&policy, 1_000_000, 0), 10_100);
157+
}
158+
159+
#[test]
160+
fn custom_base_only_with_zero_ppm() {
161+
let policy = FeePolicy::Flat(FeeTier::Custom { ppm: 0, base_msat: 100 });
162+
assert_eq!(resolve_skim(&policy, 1_000_000, 0), 100);
163+
}
164+
165+
#[test]
166+
fn fee_at_or_above_amount_never_skims_whole_htlc() {
167+
// fee == amount: 1_000_000 ppm of 1_000 = 1_000 == amount -> 0.
168+
let policy = FeePolicy::Flat(FeeTier::Standard);
169+
assert_eq!(resolve_skim(&policy, 1_000, 1_000_000), 0);
170+
171+
// fee > amount: 2_000_000 ppm of 1_000 = 2_000 > amount -> 0.
172+
assert_eq!(resolve_skim(&policy, 1_000, 2_000_000), 0);
173+
174+
// Proportional component saturates to u64::MAX, which is >= the amount -> 0.
175+
assert_eq!(resolve_skim(&policy, u64::MAX, u64::MAX), 0);
176+
177+
// A Custom base on its own large enough to swallow the HTLC -> 0.
178+
let policy = FeePolicy::Flat(FeeTier::Custom { ppm: 0, base_msat: u64::MAX });
179+
assert_eq!(resolve_skim(&policy, 1_000, 0), 0);
180+
}
181+
182+
fn round_trip<T: Readable + Writeable + PartialEq + core::fmt::Debug>(value: &T) {
183+
let bytes = value.encode();
184+
let decoded: T = Readable::read(&mut &bytes[..]).unwrap();
185+
assert_eq!(*value, decoded);
186+
}
187+
188+
#[test]
189+
fn fee_tier_round_trips() {
190+
round_trip(&FeeTier::Standard);
191+
round_trip(&FeeTier::ZeroFee);
192+
round_trip(&FeeTier::Custom { ppm: 12_345, base_msat: 678 });
193+
}
194+
195+
#[test]
196+
fn fee_policy_round_trips() {
197+
round_trip(&FeePolicy::Flat(FeeTier::Standard));
198+
round_trip(&FeePolicy::Flat(FeeTier::ZeroFee));
199+
round_trip(&FeePolicy::Flat(FeeTier::Custom { ppm: 1, base_msat: 2 }));
200+
}
201+
}

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;

lightning-liquidity/src/lsps4/scid_store.rs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ use std::ops::Deref;
1919
use crate::sync::RwLock;
2020

2121

22+
use crate::lsps4::fee_policy::{FeePolicy, FeeTier};
2223
use crate::lsps4::utils;
2324

2425
/// The Intercepted HTLC store information will be persisted under this key.
@@ -31,6 +32,7 @@ pub(crate) const INTERCEPT_SCID_STORE_PERSISTENCE_SECONDARY_NAMESPACE: &str = ""
3132
pub struct ScidWithPeer {
3233
scid: u64,
3334
peer_id: PublicKey,
35+
policy: FeePolicy,
3436
}
3537

3638
impl ScidWithPeer {
@@ -40,6 +42,7 @@ impl ScidWithPeer {
4042
Self {
4143
scid,
4244
peer_id,
45+
policy: FeePolicy::Flat(FeeTier::Standard),
4346
}
4447
}
4548

@@ -54,11 +57,16 @@ impl ScidWithPeer {
5457
pub fn peer_id(&self) -> PublicKey {
5558
self.peer_id
5659
}
60+
61+
pub fn policy(&self) -> &FeePolicy {
62+
&self.policy
63+
}
5764
}
5865

5966
impl_writeable_tlv_based!(ScidWithPeer, {
6067
(0, scid, required),
6168
(2, peer_id, required),
69+
(4, policy, (default_value, FeePolicy::Flat(FeeTier::Standard))),
6270
});
6371

6472
pub struct ScidStore<L: Deref, KV: Deref + Clone>
@@ -204,4 +212,51 @@ where L::Target: Logger, KV::Target: KVStoreSync {
204212
);
205213
result
206214
}
215+
}
216+
217+
#[cfg(test)]
218+
mod tests {
219+
use super::*;
220+
use lightning::impl_writeable_tlv_based;
221+
222+
/// A copy of the pre-policy `ScidWithPeer` layout (tlv 0/2 only) used to prove that records
223+
/// persisted before the `policy` field existed still decode, defaulting to `Flat(Standard)`.
224+
struct LegacyScidWithPeer {
225+
scid: u64,
226+
peer_id: PublicKey,
227+
}
228+
229+
impl_writeable_tlv_based!(LegacyScidWithPeer, {
230+
(0, scid, required),
231+
(2, peer_id, required),
232+
});
233+
234+
fn test_peer() -> PublicKey {
235+
// The secp256k1 generator point: a valid compressed public key.
236+
PublicKey::from_slice(&[
237+
0x02, 0x79, 0xBE, 0x66, 0x7E, 0xF9, 0xDC, 0xBB, 0xAC, 0x55, 0xA0, 0x62, 0x95, 0xCE,
238+
0x87, 0x0B, 0x07, 0x02, 0x9B, 0xFC, 0xDB, 0x2D, 0xCE, 0x28, 0xD9, 0x59, 0xF2, 0x81,
239+
0x5B, 0x16, 0xF8, 0x17, 0x98,
240+
])
241+
.unwrap()
242+
}
243+
244+
#[test]
245+
fn round_trips_with_policy() {
246+
let record = ScidWithPeer::new(42, test_peer());
247+
let bytes = record.encode();
248+
let decoded = ScidWithPeer::read(&mut &bytes[..]).unwrap();
249+
assert_eq!(record, decoded);
250+
assert_eq!(decoded.policy(), &FeePolicy::Flat(FeeTier::Standard));
251+
}
252+
253+
#[test]
254+
fn legacy_record_defaults_to_standard_policy() {
255+
let legacy = LegacyScidWithPeer { scid: 42, peer_id: test_peer() };
256+
let bytes = legacy.encode();
257+
let decoded = ScidWithPeer::read(&mut &bytes[..]).unwrap();
258+
assert_eq!(decoded.scid(), 42);
259+
assert_eq!(decoded.peer_id(), test_peer());
260+
assert_eq!(decoded.policy(), &FeePolicy::Flat(FeeTier::Standard));
261+
}
207262
}

lightning-liquidity/src/lsps4/service.rs

Lines changed: 11 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ use crate::lsps0::ser::{
1717
};
1818
use crate::lsps4::event::LSPS4ServiceEvent;
1919
use crate::lsps4::htlc_store::{HTLCStore, InterceptedHtlc};
20+
use crate::lsps4::fee_policy::{resolve_skim, FeePolicy, FeeTier};
2021
use crate::lsps4::scid_store::ScidStore;
21-
use crate::lsps4::utils::compute_forward_fee;
2222
use crate::message_queue::MessageQueue;
2323
use crate::prelude::hash_map::Entry;
2424
use crate::prelude::{new_hash_map, HashMap};
@@ -740,33 +740,24 @@ where
740740
}
741741

742742
let htlc_id = htlc.id();
743-
let mut fee_msat = match crate::lsps4::utils::compute_forward_fee(
743+
let skimmed_fee_msat = resolve_skim(
744+
&FeePolicy::Flat(FeeTier::Standard),
744745
expected_outbound_msat,
745746
self.config.forwarding_fee_proportional_millionths,
746-
) {
747-
Some(fee) => core::cmp::min(fee, expected_outbound_msat),
748-
None => {
749-
log_error!(
750-
self.logger,
751-
"Overflow while computing skimmed fee for intercepted HTLC {:?}. Skipping skim.",
752-
htlc_id
753-
);
754-
0
755-
},
756-
};
757-
758-
let mut amount_to_forward_msat = expected_outbound_msat.saturating_sub(fee_msat);
759-
if amount_to_forward_msat == 0 && fee_msat > 0 {
747+
);
748+
if skimmed_fee_msat == 0 {
749+
// The policy here is always Flat(Standard), so a zero skim can only mean the
750+
// fee would have consumed the entire HTLC; it is never a ZeroFee waiver yet.
760751
log_error!(
761752
self.logger,
762-
"Skimmed fee equaled the entire HTLC amount for {:?}. Skipping skim.",
753+
"Standard skim would have consumed the entire HTLC {:?}; forwarding the full amount.",
763754
htlc_id
764755
);
765-
fee_msat = 0;
766-
amount_to_forward_msat = expected_outbound_msat;
767756
}
768757

769-
ComputedHtlc { htlc, amount_to_forward_msat, skimmed_fee_msat: fee_msat }
758+
let amount_to_forward_msat = expected_outbound_msat.saturating_sub(skimmed_fee_msat);
759+
760+
ComputedHtlc { htlc, amount_to_forward_msat, skimmed_fee_msat }
770761
})
771762
.collect();
772763

0 commit comments

Comments
 (0)