From f62ea518b46ed7495b2f9ee76a863f7cb59f500d Mon Sep 17 00:00:00 2001 From: amackillop Date: Tue, 9 Jun 2026 10:33:22 -0700 Subject: [PATCH 01/10] 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. --- lightning-liquidity/src/lsps4/fee_policy.rs | 200 ++++++++++++++++++++ lightning-liquidity/src/lsps4/mod.rs | 1 + 2 files changed, 201 insertions(+) create mode 100644 lightning-liquidity/src/lsps4/fee_policy.rs diff --git a/lightning-liquidity/src/lsps4/fee_policy.rs b/lightning-liquidity/src/lsps4/fee_policy.rs new file mode 100644 index 00000000000..76c0fb52f75 --- /dev/null +++ b/lightning-liquidity/src/lsps4/fee_policy.rs @@ -0,0 +1,200 @@ +// This file is Copyright its original authors, visible in version control history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license , at your option. You may not use this file except in +// accordance with one or both of these licenses. + +//! Fee policy for the LSPS4 forwarding skim. +//! +//! The LSP skims a forwarding fee from every JIT-channel HTLC. This module carries the policy +//! describing *what* to skim for a given peer and resolves it to a concrete msat amount via the +//! single function [`resolve_skim`]. +//! +//! The initial version only ever constructs [`FeePolicy::Flat`], and the only tier the service +//! resolves is [`FeeTier::Standard`], so for any realistically-sized HTLC the skim matches the +//! previous hard-coded 2%. The richer arms exist so later milestones (per-peer policies, zero-fee +//! grants) are purely additive. + +use lightning::impl_writeable_tlv_based_enum; + +/// The rate at which a peer's forwarded HTLCs are skimmed. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum FeeTier { + /// Skim at the LSP's configured proportional rate (`forwarding_fee_proportional_millionths`). + Standard, + /// Never skim. Used for grant recipients whose funding we do not take a cut of. + ZeroFee, + /// Skim at an explicit rate: `base_msat` plus `ppm` proportional millionths. + Custom { + /// Proportional rate in millionths applied to the HTLC amount. + ppm: u64, + /// Flat fee in millisatoshis added on top of the proportional component. + base_msat: u64, + }, +} + +/// The discount applied to a peer. v1 only constructs [`FeePolicy::Flat`]; richer arms +/// (time-limited, volume-capped, ...) are reserved as future tags so the wire format stays +/// additive. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum FeePolicy { + /// A flat policy that applies the same [`FeeTier`] to every HTLC. + Flat(FeeTier), +} + +impl_writeable_tlv_based_enum!(FeeTier, + (0, Standard) => {}, + (2, ZeroFee) => {}, + (4, Custom) => { + (0, ppm, required), + (2, base_msat, required), + }, +); + +impl_writeable_tlv_based_enum!(FeePolicy, + {0, Flat} => (), +); + +/// Resolve a [`FeePolicy`] to the msat amount to skim from a single HTLC. +/// +/// `standard_ppm` is the LSP's configured proportional rate, used only by [`FeeTier::Standard`]. +/// +/// The skim is waived in exactly one case: when it would consume the entire HTLC. A zero-value +/// forward is rejected by the channel (`channel.rs` force-closes on a 0-msat `update_add_htlc`), +/// so skimming the whole amount would break the forward; that is the only reason we ever waive. +/// The proportional component is computed in 128-bit precision, so a very large HTLC is skimmed +/// correctly rather than (as the previous `u64` arithmetic did) overflowing and forwarding the +/// whole amount for free. +pub(crate) fn resolve_skim(policy: &FeePolicy, htlc_amount_msat: u64, standard_ppm: u64) -> u64 { + let fee_msat = match policy { + FeePolicy::Flat(FeeTier::ZeroFee) => 0, + FeePolicy::Flat(FeeTier::Standard) => proportional_fee_msat(htlc_amount_msat, standard_ppm), + FeePolicy::Flat(FeeTier::Custom { ppm, base_msat }) => { + base_msat.saturating_add(proportional_fee_msat(htlc_amount_msat, *ppm)) + }, + }; + + if fee_msat >= htlc_amount_msat { + 0 + } else { + fee_msat + } +} + +/// `amount_msat * ppm / 1_000_000`, rounded up, computed in 128-bit so it can't overflow for any +/// `u64` inputs. Saturates to `u64::MAX`, which the caller reads as "skims the whole HTLC". +fn proportional_fee_msat(amount_msat: u64, ppm: u64) -> u64 { + // `+ 999_999` before the integer divide is ceiling division: adding `denominator - 1` rounds + // the result up, so a sub-msat fee skims 1 rather than truncating to 0 (never under-skim). + let scaled = (amount_msat as u128) * (ppm as u128) + 999_999; + u64::try_from(scaled / 1_000_000).unwrap_or(u64::MAX) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::lsps4::utils::compute_forward_fee; + use lightning::util::ser::{Readable, Writeable}; + + /// The legacy inline computation the service used before `resolve_skim` existed, kept here as + /// the oracle the `Standard` tier must match for any HTLC small enough not to overflow its + /// `u64` arithmetic. + fn legacy_standard_skim(amount: u64, ppm: u64) -> u64 { + match compute_forward_fee(amount, ppm) { + Some(fee) => { + let fee = core::cmp::min(fee, amount); + if amount.saturating_sub(fee) == 0 && fee > 0 { + 0 + } else { + fee + } + }, + None => 0, + } + } + + #[test] + fn standard_matches_legacy_across_sizes() { + let ppm = 20_000; // 2% + // All sizes below the u64 overflow threshold (~9.2e14 msat at 2%), where the new 128-bit + // math and the legacy u64 math agree exactly. + for amount in [0u64, 1, 999, 1_000, 50_000, 1_000_000, 100_000_000, 1_000_000_000_000] { + let policy = FeePolicy::Flat(FeeTier::Standard); + assert_eq!( + resolve_skim(&policy, amount, ppm), + legacy_standard_skim(amount, ppm), + "mismatch at amount={amount}" + ); + } + } + + #[test] + fn large_htlc_skims_instead_of_forwarding_free() { + // 1e18 msat * 20_000 ppm overflows u64, so the legacy code skimmed nothing and forwarded + // the whole HTLC for free. The 128-bit math skims the correct 2% instead. + let amount = 1_000_000_000_000_000_000u64; + assert_eq!(legacy_standard_skim(amount, 20_000), 0); + let policy = FeePolicy::Flat(FeeTier::Standard); + assert_eq!(resolve_skim(&policy, amount, 20_000), 20_000_000_000_000_000); + } + + #[test] + fn zero_fee_never_skims() { + let policy = FeePolicy::Flat(FeeTier::ZeroFee); + for amount in [0u64, 1, 1_000, u64::MAX] { + assert_eq!(resolve_skim(&policy, amount, 1_000_000), 0); + } + } + + #[test] + fn custom_adds_base_and_proportional() { + // 1% proportional plus a flat 100 msat base. + let policy = FeePolicy::Flat(FeeTier::Custom { ppm: 10_000, base_msat: 100 }); + // 10_000 ppm of 1_000_000 = 10_000, plus 100 base = 10_100. + assert_eq!(resolve_skim(&policy, 1_000_000, 0), 10_100); + } + + #[test] + fn custom_base_only_with_zero_ppm() { + let policy = FeePolicy::Flat(FeeTier::Custom { ppm: 0, base_msat: 100 }); + assert_eq!(resolve_skim(&policy, 1_000_000, 0), 100); + } + + #[test] + fn fee_at_or_above_amount_never_skims_whole_htlc() { + // fee == amount: 1_000_000 ppm of 1_000 = 1_000 == amount -> 0. + let policy = FeePolicy::Flat(FeeTier::Standard); + assert_eq!(resolve_skim(&policy, 1_000, 1_000_000), 0); + + // fee > amount: 2_000_000 ppm of 1_000 = 2_000 > amount -> 0. + assert_eq!(resolve_skim(&policy, 1_000, 2_000_000), 0); + + // Proportional component saturates to u64::MAX, which is >= the amount -> 0. + assert_eq!(resolve_skim(&policy, u64::MAX, u64::MAX), 0); + + // A Custom base on its own large enough to swallow the HTLC -> 0. + let policy = FeePolicy::Flat(FeeTier::Custom { ppm: 0, base_msat: u64::MAX }); + assert_eq!(resolve_skim(&policy, 1_000, 0), 0); + } + + fn round_trip(value: &T) { + let bytes = value.encode(); + let decoded: T = Readable::read(&mut &bytes[..]).unwrap(); + assert_eq!(*value, decoded); + } + + #[test] + fn fee_tier_round_trips() { + round_trip(&FeeTier::Standard); + round_trip(&FeeTier::ZeroFee); + round_trip(&FeeTier::Custom { ppm: 12_345, base_msat: 678 }); + } + + #[test] + fn fee_policy_round_trips() { + round_trip(&FeePolicy::Flat(FeeTier::Standard)); + round_trip(&FeePolicy::Flat(FeeTier::ZeroFee)); + round_trip(&FeePolicy::Flat(FeeTier::Custom { ppm: 1, base_msat: 2 })); + } +} diff --git a/lightning-liquidity/src/lsps4/mod.rs b/lightning-liquidity/src/lsps4/mod.rs index c881c24affa..2354369b3b8 100644 --- a/lightning-liquidity/src/lsps4/mod.rs +++ b/lightning-liquidity/src/lsps4/mod.rs @@ -11,6 +11,7 @@ pub mod client; pub mod event; +pub mod fee_policy; pub(crate) mod htlc_store; pub mod msgs; pub(crate) mod scid_store; From 7a44f201bb2d7f806f6b4bb6de56e904620b0a42 Mon Sep 17 00:00:00 2001 From: amackillop Date: Tue, 9 Jun 2026 10:49:28 -0700 Subject: [PATCH 02/10] Add defaulting policy field to ScidWithPeer Persist a FeePolicy per intercept SCID record so a later milestone can look up a peer's policy at the skim site. ScidWithPeer::new still hands every record Flat(Standard), so nothing changes yet. The field uses a length-delimited TLV with a default_value of Flat(Standard) at type 4. Records written before this field existed carry only types 0 and 2, so they read back as Standard with no migration. A test encodes a copy of the old two-field layout and decodes it through the new struct to pin that default. --- lightning-liquidity/src/lsps4/scid_store.rs | 55 +++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/lightning-liquidity/src/lsps4/scid_store.rs b/lightning-liquidity/src/lsps4/scid_store.rs index aeff112ae0a..de3afd2740f 100644 --- a/lightning-liquidity/src/lsps4/scid_store.rs +++ b/lightning-liquidity/src/lsps4/scid_store.rs @@ -19,6 +19,7 @@ use std::ops::Deref; use crate::sync::RwLock; +use crate::lsps4::fee_policy::{FeePolicy, FeeTier}; use crate::lsps4::utils; /// 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 = "" pub struct ScidWithPeer { scid: u64, peer_id: PublicKey, + policy: FeePolicy, } impl ScidWithPeer { @@ -40,6 +42,7 @@ impl ScidWithPeer { Self { scid, peer_id, + policy: FeePolicy::Flat(FeeTier::Standard), } } @@ -54,11 +57,16 @@ impl ScidWithPeer { pub fn peer_id(&self) -> PublicKey { self.peer_id } + + pub fn policy(&self) -> &FeePolicy { + &self.policy + } } impl_writeable_tlv_based!(ScidWithPeer, { (0, scid, required), (2, peer_id, required), + (4, policy, (default_value, FeePolicy::Flat(FeeTier::Standard))), }); pub struct ScidStore @@ -204,4 +212,51 @@ where L::Target: Logger, KV::Target: KVStoreSync { ); result } +} + +#[cfg(test)] +mod tests { + use super::*; + use lightning::impl_writeable_tlv_based; + + /// A copy of the pre-policy `ScidWithPeer` layout (tlv 0/2 only) used to prove that records + /// persisted before the `policy` field existed still decode, defaulting to `Flat(Standard)`. + struct LegacyScidWithPeer { + scid: u64, + peer_id: PublicKey, + } + + impl_writeable_tlv_based!(LegacyScidWithPeer, { + (0, scid, required), + (2, peer_id, required), + }); + + fn test_peer() -> PublicKey { + // The secp256k1 generator point: a valid compressed public key. + PublicKey::from_slice(&[ + 0x02, 0x79, 0xBE, 0x66, 0x7E, 0xF9, 0xDC, 0xBB, 0xAC, 0x55, 0xA0, 0x62, 0x95, 0xCE, + 0x87, 0x0B, 0x07, 0x02, 0x9B, 0xFC, 0xDB, 0x2D, 0xCE, 0x28, 0xD9, 0x59, 0xF2, 0x81, + 0x5B, 0x16, 0xF8, 0x17, 0x98, + ]) + .unwrap() + } + + #[test] + fn round_trips_with_policy() { + let record = ScidWithPeer::new(42, test_peer()); + let bytes = record.encode(); + let decoded = ScidWithPeer::read(&mut &bytes[..]).unwrap(); + assert_eq!(record, decoded); + assert_eq!(decoded.policy(), &FeePolicy::Flat(FeeTier::Standard)); + } + + #[test] + fn legacy_record_defaults_to_standard_policy() { + let legacy = LegacyScidWithPeer { scid: 42, peer_id: test_peer() }; + let bytes = legacy.encode(); + let decoded = ScidWithPeer::read(&mut &bytes[..]).unwrap(); + assert_eq!(decoded.scid(), 42); + assert_eq!(decoded.peer_id(), test_peer()); + assert_eq!(decoded.policy(), &FeePolicy::Flat(FeeTier::Standard)); + } } \ No newline at end of file From 679048594aee4ccf0c6f1fcd0d946ff9d6008509 Mon Sep 17 00:00:00 2001 From: amackillop Date: Tue, 9 Jun 2026 11:09:18 -0700 Subject: [PATCH 03/10] Route the skim site through resolve_skim Replace the inline compute_forward_fee block in calculate_htlc_actions_for_peer with a single resolve_skim call against a literal Flat(Standard) policy. This is the one place the LSP decides what to skim; routing it through the pure function is what later milestones need to swap the literal for a per-peer policy lookup. Not a strict no-op: it inherits the u128 overflow fix from resolve_skim, so a >9223-BTC HTLC is now skimmed 2% instead of overflowing and forwarding free. Every realistic HTLC is unchanged. The peer's stored policy is still ignored; every forward resolves Flat(Standard) until the lookup lands. The two old log lines (overflow, skim-ate-the-HTLC) collapse into one: resolve_skim can't overflow, so a zero skim on a non-zero HTLC can only mean the fee would have eaten the whole amount. --- lightning-liquidity/src/lsps4/fee_policy.rs | 7 +++-- lightning-liquidity/src/lsps4/service.rs | 31 ++++++++------------- 2 files changed, 15 insertions(+), 23 deletions(-) diff --git a/lightning-liquidity/src/lsps4/fee_policy.rs b/lightning-liquidity/src/lsps4/fee_policy.rs index 76c0fb52f75..0d5aad32576 100644 --- a/lightning-liquidity/src/lsps4/fee_policy.rs +++ b/lightning-liquidity/src/lsps4/fee_policy.rs @@ -60,9 +60,10 @@ impl_writeable_tlv_based_enum!(FeePolicy, /// /// `standard_ppm` is the LSP's configured proportional rate, used only by [`FeeTier::Standard`]. /// -/// The skim is waived in exactly one case: when it would consume the entire HTLC. A zero-value -/// forward is rejected by the channel (`channel.rs` force-closes on a 0-msat `update_add_htlc`), -/// so skimming the whole amount would break the forward; that is the only reason we ever waive. +/// The skim is zero in two distinct cases. [`FeeTier::ZeroFee`] never skims, by design. Any other +/// tier is additionally forced to zero when its fee would consume the entire HTLC: a zero-value +/// forward is rejected by the channel (`channel.rs` force-closes on a 0-msat `update_add_htlc`), so +/// skimming the whole amount would break the forward; dropping to zero forwards it intact instead. /// The proportional component is computed in 128-bit precision, so a very large HTLC is skimmed /// correctly rather than (as the previous `u64` arithmetic did) overflowing and forwarding the /// whole amount for free. diff --git a/lightning-liquidity/src/lsps4/service.rs b/lightning-liquidity/src/lsps4/service.rs index 21eb54f05a4..299c73c42c1 100644 --- a/lightning-liquidity/src/lsps4/service.rs +++ b/lightning-liquidity/src/lsps4/service.rs @@ -17,8 +17,8 @@ use crate::lsps0::ser::{ }; use crate::lsps4::event::LSPS4ServiceEvent; use crate::lsps4::htlc_store::{HTLCStore, InterceptedHtlc}; +use crate::lsps4::fee_policy::{resolve_skim, FeePolicy, FeeTier}; use crate::lsps4::scid_store::ScidStore; -use crate::lsps4::utils::compute_forward_fee; use crate::message_queue::MessageQueue; use crate::prelude::hash_map::Entry; use crate::prelude::{new_hash_map, HashMap}; @@ -424,33 +424,24 @@ where } let htlc_id = htlc.id(); - let mut fee_msat = match crate::lsps4::utils::compute_forward_fee( + let skimmed_fee_msat = resolve_skim( + &FeePolicy::Flat(FeeTier::Standard), expected_outbound_msat, self.config.forwarding_fee_proportional_millionths, - ) { - Some(fee) => core::cmp::min(fee, expected_outbound_msat), - None => { - log_error!( - self.logger, - "Overflow while computing skimmed fee for intercepted HTLC {:?}. Skipping skim.", - htlc_id - ); - 0 - }, - }; - - let mut amount_to_forward_msat = expected_outbound_msat.saturating_sub(fee_msat); - if amount_to_forward_msat == 0 && fee_msat > 0 { + ); + if skimmed_fee_msat == 0 { + // The policy here is always Flat(Standard), so a zero skim can only mean the + // fee would have consumed the entire HTLC; it is never a ZeroFee waiver yet. log_error!( self.logger, - "Skimmed fee equaled the entire HTLC amount for {:?}. Skipping skim.", + "Standard skim would have consumed the entire HTLC {:?}; forwarding the full amount.", htlc_id ); - fee_msat = 0; - amount_to_forward_msat = expected_outbound_msat; } - ComputedHtlc { htlc, amount_to_forward_msat, skimmed_fee_msat: fee_msat } + let amount_to_forward_msat = expected_outbound_msat.saturating_sub(skimmed_fee_msat); + + ComputedHtlc { htlc, amount_to_forward_msat, skimmed_fee_msat } }) .collect(); From 8120d15e9aba87c0ab5706d5ed5f563060cdc926 Mon Sep 17 00:00:00 2001 From: Austin Mackillop Date: Tue, 9 Jun 2026 16:09:40 -0400 Subject: [PATCH 04/10] Add dev shell flake and justfile for local checks (#23) Introduce a Nix dev shell and a justfile so work on this fork has a reproducible toolchain and a one-command local gate, mirroring the setup in the ldk-node repo. The toolchain is pinned to stable 1.90.0 via fenix rather than tracking latest. The version is bounded on both ends: clippy must be at least 1.87 so ci/check-lint.sh can resolve the lint names it allows (older clippy hard-errors on the unknown clippy::manual_is_multiple_of), but 1.92+ adds clippy::assertions_on_constants which fires on existing code in lightning/src/chain/channelmonitor.rs and would break -D warnings on the inherited tree. 1.90.0 sits in that window and matches what CI's stable resolves to today. fenix is used over rust-overlay because it pins an exact channel by hash, so the shell can't drift. just check only compiles and runs the workspace test suite. It deliberately skips clippy and rustfmt: this branch is not clean under either of CI's gates (pre-existing dead code in lsps2/lsps4 trips check-lint.sh's -D warnings, and the tree predates the pinned rustfmt), and reformatting or de-linting code we don't own is out of scope. --- .envrc | 1 + flake.lock | 100 +++++++++++++++++++++++++++++++++++++++++++++++++++++ flake.nix | 85 +++++++++++++++++++++++++++++++++++++++++++++ justfile | 14 ++++++++ 4 files changed, 200 insertions(+) create mode 100644 .envrc create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 justfile diff --git a/.envrc b/.envrc new file mode 100644 index 00000000000..3550a30f2de --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/flake.lock b/flake.lock new file mode 100644 index 00000000000..832c92f6e26 --- /dev/null +++ b/flake.lock @@ -0,0 +1,100 @@ +{ + "nodes": { + "fenix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ], + "rust-analyzer-src": "rust-analyzer-src" + }, + "locked": { + "lastModified": 1780999471, + "narHash": "sha256-rY4yNlSzDY3OoYBdgjwlpVKWY/oUeOb65709i/tJbns=", + "owner": "nix-community", + "repo": "fenix", + "rev": "db976d430cbb6b0d6b300b4e22cbb28462841af2", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "fenix", + "type": "github" + } + }, + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1764521362, + "narHash": "sha256-M101xMtWdF1eSD0xhiR8nG8CXRlHmv6V+VoY65Smwf4=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "871b9fd269ff6246794583ce4ee1031e1da71895", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "25.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "fenix": "fenix", + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "rust-analyzer-src": { + "flake": false, + "locked": { + "lastModified": 1780924205, + "narHash": "sha256-5s6nPSNU1Mo/Qy90XxCTNj6/rB70zgX8VqleTdLoESA=", + "owner": "rust-lang", + "repo": "rust-analyzer", + "rev": "4fde9dd9822df453dc6f20149b8e375efbbe66c0", + "type": "github" + }, + "original": { + "owner": "rust-lang", + "ref": "nightly", + "repo": "rust-analyzer", + "type": "github" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 00000000000..0f7a38e46cb --- /dev/null +++ b/flake.nix @@ -0,0 +1,85 @@ +{ + description = "Rust Lightning Development Environment"; + + inputs = { + # Nixpkgs channel. New channels are released every 6 months. + # See: https://github.com/NixOS/nixpkgs/tags + nixpkgs.url = "github:nixos/nixpkgs/25.11"; + + # This makes it easy for the flake to be multi-platform. + # See: https://github.com/numtide/flake-utils + flake-utils.url = "github:numtide/flake-utils"; + + # Provides pinned Rust toolchains. + # See: https://github.com/nix-community/fenix + fenix = { + url = "github:nix-community/fenix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + + outputs = + { + self, + nixpkgs, + flake-utils, + fenix, + }: + flake-utils.lib.eachDefaultSystem ( + system: + let + pkgs = import nixpkgs { inherit system; }; + + # Pinned stable toolchain. The version is deliberately bounded: + # * clippy must be >= 1.87 so `./ci/check-lint.sh` resolves the lints it + # `-A`s (e.g. `clippy::manual_is_multiple_of`); older clippy E0602s on the + # unknown lint name. + # * but NOT 1.92+, whose clippy grows `assertions_on_constants` (fires on + # `assert!(cfg!(fuzzing), ..)` in lightning/src/chain/channelmonitor.rs) + # and would break `-D warnings` on the pre-existing tree. + # 1.90.0 sits in that window and matches what CI's `stable` resolves to. + rust-toolchain = + (fenix.packages.${system}.toolchainOf { + channel = "1.90.0"; + sha256 = "sha256-SJwZ8g0zF2WrKDVmHrVG3pD2RGoQeo24MEXnNx5FyuI="; + }).withComponents + [ + "rustc" + "cargo" + "clippy" + "rustfmt" + "rust-src" # Needed for the rust-analyzer extension to work. + ]; + in + { + # The default development shell. Use `nix develop` or direnv to enter it. + devShells.default = pkgs.mkShell { + name = "rust-lightning-dev-shell"; + + packages = [ + rust-toolchain + ] + ++ (with pkgs; [ + just # Command runner + nodejs # JavaScript runtime, required for MCP tools + mold # Fast linker for Rust/C/C++ + pnpm # Package manager for JavaScript, required for MCP tools + stdenv.cc.cc.lib # C++ standard library for runtime + ]); + + env = { + # OpenSSL configuration for Nix + PKG_CONFIG_PATH = "${pkgs.openssl.dev}/lib/pkgconfig"; + # C++ standard library path for runtime + LD_LIBRARY_PATH = "${pkgs.stdenv.cc.cc.lib}/lib"; + }; + + shellHook = '' + echo "Rust Lightning dev shell" + rustc --version + cargo --version + ''; + }; + } + ); +} diff --git a/justfile b/justfile new file mode 100644 index 00000000000..ad4ad0626f5 --- /dev/null +++ b/justfile @@ -0,0 +1,14 @@ +default: + @just --list --unsorted + +# Run all code quality checks. TODO: Add format and linting when branch is clean +check: + RUSTFLAGS="--cfg=lsps1_service" cargo test --workspace + +# Run just the tests +test: + RUSTFLAGS="--cfg=lsps1_service" cargo test --workspace + +# Format all sources in place. +fmt: + cargo fmt --all From 4f88a5d93ce7621fc3f481a3ef0724ad7d900246 Mon Sep 17 00:00:00 2001 From: amackillop Date: Wed, 10 Jun 2026 09:22:21 -0700 Subject: [PATCH 05/10] Add signed fee-policy claim verification MDK-980 lets a registering node carry a signed grant for a non-standard FeePolicy that the LSP verifies locally before honouring. This is just the pure core: the claim ADT and the verifier. Nothing calls it yet, so behaviour is unchanged. A claim is a versioned TLV pair. ClaimPayload binds {scheme, node_id, policy}; SignedFeeClaim wraps the encoded payload as an opaque byte string plus a detached BIP340 signature over SHA256 of those bytes. Keeping the payload opaque means the verifier hashes exactly what was signed and never reproduces the payload's TLV layout to check the signature. TLV rather than a fixed concatenation keeps later schemes (more policy arms, an issuer key-id) additive: a new tag, not a re-issue. verify_claim takes a slice of issuer keys, not a single Option. An empty slice rejects every claim, which is how the feature stays inert until a key is configured. A non-empty slice accepts a signature from any one of the keys. That covers a no-key-id claim today and key rotation later, and per-key scoping can slot in without a breaking config change. The verifier borrows a caller-supplied secp context rather than building its own, so the verifier stays allocation free and context lifetime is the caller's call. The handler wiring that follows will own a long-lived verify context on the service and hand it in. The scheme byte is read and matched before the signature check because it selects which verification rules apply, so an unknown scheme is rejected before any crypto runs. The wire format is the contract MDK-981 (ldk-node) and MDK-982 (the TS minter) must reproduce byte-for-byte, so an in-tree test vector pins a known issuer key and claim hex. Drift in the TLV layout or the signing input fails that test instead of silently diverging across repos. The signing helper is test-only; the verifier does no I/O. --- lightning-liquidity/src/lsps4/claim.rs | 313 +++++++++++++++++++++++++ lightning-liquidity/src/lsps4/mod.rs | 1 + 2 files changed, 314 insertions(+) create mode 100644 lightning-liquidity/src/lsps4/claim.rs diff --git a/lightning-liquidity/src/lsps4/claim.rs b/lightning-liquidity/src/lsps4/claim.rs new file mode 100644 index 00000000000..609b635e410 --- /dev/null +++ b/lightning-liquidity/src/lsps4/claim.rs @@ -0,0 +1,313 @@ +// This file is Copyright its original authors, visible in version control history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license , at your option. You may not use this file except in +// accordance with one or both of these licenses. + +//! Signed fee-policy claims presented by a registering node. +//! +//! A node may carry a claim that grants it a non-standard [`FeePolicy`] (for example a zero-fee +//! grant for funding we do not skim). The claim is minted off-band by an issuer the LSP trusts and +//! presented verbatim in `register_node`; the LSP verifies it locally against its configured issuer +//! keys, with no network I/O. With no issuer key configured every claim is rejected, so every peer +//! falls back to `Flat(Standard)` and behaviour is identical to a node that never sees a claim. +//! +//! The wire format is versioned and TLV-encoded rather than a fixed concatenation, so later schemes +//! (more policy arms, an issuer key-id, per-key scope) are additive: a new tag, not a re-issue. +//! The signed bytes are the *opaque* encoded [`ClaimPayload`], carried as a byte string inside +//! [`SignedFeeClaim`]. The verifier hashes exactly those bytes, so it never has to reproduce the +//! payload's TLV layout to check the signature — it only re-reads the payload after the signature +//! has already covered it. + +use bitcoin::hashes::{sha256, Hash}; +use bitcoin::hex::FromHex; +use bitcoin::secp256k1::{schnorr, Message, PublicKey, Secp256k1, Verification, XOnlyPublicKey}; + +use lightning::impl_writeable_tlv_based; +use lightning::util::ser::{Readable, Writeable}; + +use std::vec::Vec; + +use crate::lsps4::fee_policy::FeePolicy; + +/// The only signature scheme this version understands: a BIP340 Schnorr signature over secp256k1. +const CLAIM_SCHEME_V1: u8 = 1; + +/// The signed half of a claim: the issuer commits to *this node* getting *this policy*. +/// +/// Encoded and hashed as an opaque byte string by [`SignedFeeClaim`]; the verifier re-reads it only +/// after the signature has been checked over those exact bytes. +#[derive(Clone, Debug, PartialEq, Eq)] +struct ClaimPayload { + /// Signature scheme tag; only [`CLAIM_SCHEME_V1`] is accepted. + scheme: u8, + /// The node the grant is bound to. Must equal the registering peer's id. + node_id: PublicKey, + /// The policy the issuer is granting. + policy: FeePolicy, +} + +impl_writeable_tlv_based!(ClaimPayload, { + (0, scheme, required), + (2, node_id, required), + (4, policy, required), +}); + +/// The wire container: an opaque [`ClaimPayload`] blob plus the issuer's detached signature over it. +#[derive(Clone, Debug, PartialEq, Eq)] +struct SignedFeeClaim { + /// The encoded [`ClaimPayload`]. Kept as bytes so the signature is verified over exactly what + /// was signed, independent of how this verifier would re-serialize the payload. + payload: Vec, + /// BIP340 Schnorr signature over `SHA256(payload)`. + sig: schnorr::Signature, +} + +impl_writeable_tlv_based!(SignedFeeClaim, { + (0, payload, required), + (2, sig, required), +}); + +/// Why a presented claim was not honoured. Every variant resolves the peer to `Flat(Standard)`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum ClaimError { + /// The hex, the outer container, or the inner payload failed to decode. + Malformed, + /// The payload's scheme tag is not one this version understands. + UnknownScheme(u8), + /// No configured issuer key verified the signature (an empty issuer set always lands here). + BadSignature, + /// The claim is valid but bound to a different node than the one presenting it. + NodeIdMismatch, +} + +/// Verify a hex-encoded [`SignedFeeClaim`] and return the granted [`FeePolicy`]. +/// +/// A claim is accepted when it decodes, carries [`CLAIM_SCHEME_V1`], is signed by *any* of +/// `issuer_pubkeys`, and is bound to `counterparty`. An empty `issuer_pubkeys` rejects every claim +/// ([`ClaimError::BadSignature`]), which is how the feature stays inert until a key is configured. +/// +/// `scheme` is read and matched *before* the signature because it selects the verification rules: +/// a future scheme could sign a different digest, so there is no single "verify first" step that +/// works across schemes. Only `scheme == 1` (BIP340 over `SHA256(payload)`) exists today. +/// +/// Every configured key is trusted equally — any one of them may grant any [`FeePolicy`]. That is +/// fine for a single issuer. Once a second, less-trusted issuer exists (e.g. a promo key that must +/// not be able to grant a permanent zero-fee), the trust set has to carry per-key scope checked +/// against the granted policy. That gap is deliberate, not forgotten; a keyed `&[(key, scope)]` +/// shape plus a payload key-id are additive when it lands. +pub(crate) fn verify_claim( + secp_ctx: &Secp256k1, fee_claim: &str, issuer_pubkeys: &[XOnlyPublicKey], + counterparty: &PublicKey, +) -> Result { + let bytes = >::from_hex(fee_claim).map_err(|_| ClaimError::Malformed)?; + let signed = SignedFeeClaim::read(&mut &bytes[..]).map_err(|_| ClaimError::Malformed)?; + let payload = + ClaimPayload::read(&mut &signed.payload[..]).map_err(|_| ClaimError::Malformed)?; + + if payload.scheme != CLAIM_SCHEME_V1 { + return Err(ClaimError::UnknownScheme(payload.scheme)); + } + + let digest = Message::from_digest(sha256::Hash::hash(&signed.payload).to_byte_array()); + let verified = + issuer_pubkeys.iter().any(|pk| secp_ctx.verify_schnorr(&signed.sig, &digest, pk).is_ok()); + if !verified { + return Err(ClaimError::BadSignature); + } + + if payload.node_id != *counterparty { + return Err(ClaimError::NodeIdMismatch); + } + + Ok(payload.policy) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::lsps4::fee_policy::FeeTier; + use crate::lsps4::utils; + use bitcoin::secp256k1::{Keypair, SecretKey, VerifyOnly}; + + /// A throwaway verification context for the test call sites. Production threads the service's + /// long-lived context instead. + fn verify_ctx() -> Secp256k1 { + Secp256k1::verification_only() + } + + /// Fixed issuer secret used to mint the in-tree test vector. Test-only; never a real key. + const ISSUER_SECRET: [u8; 32] = [0x42; 32]; + /// Fixed node secret whose public key the test vector binds the grant to. + const NODE_SECRET: [u8; 32] = [0x11; 32]; + + fn issuer_keypair() -> Keypair { + let secp = Secp256k1::new(); + Keypair::from_secret_key(&secp, &SecretKey::from_slice(&ISSUER_SECRET).unwrap()) + } + + fn issuer_xonly() -> XOnlyPublicKey { + issuer_keypair().x_only_public_key().0 + } + + fn node_id() -> PublicKey { + let secp = Secp256k1::new(); + PublicKey::from_secret_key(&secp, &SecretKey::from_slice(&NODE_SECRET).unwrap()) + } + + /// Mint a claim the way the off-band issuer is expected to: sign `SHA256(payload)` with a + /// deterministic (no-aux-rand) BIP340 signature so the resulting hex is a stable vector. + fn mint_claim(node_id: PublicKey, policy: FeePolicy, sk: &[u8; 32]) -> String { + let secp = Secp256k1::new(); + let keypair = Keypair::from_secret_key(&secp, &SecretKey::from_slice(sk).unwrap()); + let payload = ClaimPayload { scheme: CLAIM_SCHEME_V1, node_id, policy }.encode(); + let digest = Message::from_digest(sha256::Hash::hash(&payload).to_byte_array()); + let sig = secp.sign_schnorr_no_aux_rand(&digest, &keypair); + utils::to_string(&SignedFeeClaim { payload, sig }.encode()) + } + + #[test] + fn claim_payload_round_trips() { + let payload = ClaimPayload { + scheme: CLAIM_SCHEME_V1, + node_id: node_id(), + policy: FeePolicy::Flat(FeeTier::ZeroFee), + }; + let bytes = payload.encode(); + assert_eq!(payload, ClaimPayload::read(&mut &bytes[..]).unwrap()); + } + + #[test] + fn valid_claim_yields_granted_policy() { + let claim = mint_claim(node_id(), FeePolicy::Flat(FeeTier::ZeroFee), &ISSUER_SECRET); + assert_eq!( + verify_claim(&verify_ctx(), &claim, &[issuer_xonly()], &node_id()), + Ok(FeePolicy::Flat(FeeTier::ZeroFee)) + ); + } + + #[test] + fn empty_issuer_set_rejects() { + let claim = mint_claim(node_id(), FeePolicy::Flat(FeeTier::ZeroFee), &ISSUER_SECRET); + assert_eq!( + verify_claim(&verify_ctx(), &claim, &[], &node_id()), + Err(ClaimError::BadSignature) + ); + } + + #[test] + fn wrong_issuer_key_rejects() { + let claim = mint_claim(node_id(), FeePolicy::Flat(FeeTier::ZeroFee), &ISSUER_SECRET); + // An issuer key that did not sign this claim. + let secp = Secp256k1::new(); + let other = Keypair::from_secret_key(&secp, &SecretKey::from_slice(&[0x07; 32]).unwrap()) + .x_only_public_key() + .0; + assert_eq!( + verify_claim(&verify_ctx(), &claim, &[other], &node_id()), + Err(ClaimError::BadSignature) + ); + } + + #[test] + fn forged_signature_rejects() { + // Mint with the wrong secret, then check against the real issuer's key. + let claim = mint_claim(node_id(), FeePolicy::Flat(FeeTier::ZeroFee), &[0x07; 32]); + assert_eq!( + verify_claim(&verify_ctx(), &claim, &[issuer_xonly()], &node_id()), + Err(ClaimError::BadSignature) + ); + } + + #[test] + fn node_id_mismatch_rejects() { + let claim = mint_claim(node_id(), FeePolicy::Flat(FeeTier::ZeroFee), &ISSUER_SECRET); + // A different counterparty than the one the claim is bound to. + let secp = Secp256k1::new(); + let other = + PublicKey::from_secret_key(&secp, &SecretKey::from_slice(&[0x22; 32]).unwrap()); + assert_eq!( + verify_claim(&verify_ctx(), &claim, &[issuer_xonly()], &other), + Err(ClaimError::NodeIdMismatch) + ); + } + + #[test] + fn unknown_scheme_rejects() { + let secp = Secp256k1::new(); + let keypair = issuer_keypair(); + let payload = ClaimPayload { scheme: 2, node_id: node_id(), policy: FeePolicy::Flat(FeeTier::ZeroFee) } + .encode(); + let digest = Message::from_digest(sha256::Hash::hash(&payload).to_byte_array()); + let sig = secp.sign_schnorr_no_aux_rand(&digest, &keypair); + let claim = utils::to_string(&SignedFeeClaim { payload, sig }.encode()); + assert_eq!( + verify_claim(&verify_ctx(), &claim, &[issuer_xonly()], &node_id()), + Err(ClaimError::UnknownScheme(2)) + ); + } + + #[test] + fn malformed_hex_rejects() { + assert_eq!( + verify_claim(&verify_ctx(), "zzzz", &[issuer_xonly()], &node_id()), + Err(ClaimError::Malformed) + ); + } + + #[test] + fn malformed_tlv_rejects() { + // Valid hex, but not a decodable SignedFeeClaim. + let claim = utils::to_string(&[0xff, 0xff, 0xff]); + assert_eq!( + verify_claim(&verify_ctx(), &claim, &[issuer_xonly()], &node_id()), + Err(ClaimError::Malformed) + ); + } + + #[test] + fn multi_issuer_second_key_verifies() { + // The signing key sits second in the trust set, so this exercises `.any()` past index 0 — + // the multi-issuer / key-rotation path the config shape is built for. + let claim = mint_claim(node_id(), FeePolicy::Flat(FeeTier::ZeroFee), &ISSUER_SECRET); + let secp = Secp256k1::new(); + let other = Keypair::from_secret_key(&secp, &SecretKey::from_slice(&[0x07; 32]).unwrap()) + .x_only_public_key() + .0; + assert_eq!( + verify_claim(&verify_ctx(), &claim, &[other, issuer_xonly()], &node_id()), + Ok(FeePolicy::Flat(FeeTier::ZeroFee)) + ); + } + + #[test] + fn custom_policy_survives_verify() { + // Any signed FeePolicy comes back intact, not just ZeroFee. + let policy = FeePolicy::Flat(FeeTier::Custom { ppm: 1_234, base_msat: 56 }); + let claim = mint_claim(node_id(), policy.clone(), &ISSUER_SECRET); + assert_eq!( + verify_claim(&verify_ctx(), &claim, &[issuer_xonly()], &node_id()), + Ok(policy) + ); + } + + /// The cross-repo contract the client and the issuer must reproduce byte-for-byte. The issuer + /// secret, the bound node id, and a `ZeroFee` grant mint exactly this hex; pinning it here + /// catches any drift in the TLV byte layout or the signing input. + #[test] + fn in_tree_test_vector() { + const EXPECTED_CLAIM_HEX: &str = "73002f002d2c0001010221034f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa04040002020002408f3868ca716c39d580d8b54c8d852c22f0f1ea4a174ba13ad571e37fd182aa60e82a88c00225a3f61112804cf1e7c41bd39dbdc7fcb78e779b78423fff47d964"; + const EXPECTED_ISSUER_XONLY: &str = + "24653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c"; + + assert_eq!(utils::to_string(&issuer_xonly().serialize()), EXPECTED_ISSUER_XONLY); + + let claim = mint_claim(node_id(), FeePolicy::Flat(FeeTier::ZeroFee), &ISSUER_SECRET); + assert_eq!(claim, EXPECTED_CLAIM_HEX); + assert_eq!( + verify_claim(&verify_ctx(), &claim, &[issuer_xonly()], &node_id()), + Ok(FeePolicy::Flat(FeeTier::ZeroFee)) + ); + } +} diff --git a/lightning-liquidity/src/lsps4/mod.rs b/lightning-liquidity/src/lsps4/mod.rs index 2354369b3b8..5d16b18fba0 100644 --- a/lightning-liquidity/src/lsps4/mod.rs +++ b/lightning-liquidity/src/lsps4/mod.rs @@ -9,6 +9,7 @@ //! Implementation of LSPS4: JIT Channel Negotiation specification. +pub(crate) mod claim; pub mod client; pub mod event; pub mod fee_policy; From 199f57eae1c68128c8e41adb0feda454e6be3d13 Mon Sep 17 00:00:00 2001 From: amackillop Date: Wed, 10 Jun 2026 09:58:45 -0700 Subject: [PATCH 06/10] Carry an optional fee claim in register_node Adds an optional fee_claim string to RegisterNodeRequest so a node can present a signed grant when it registers. The field is hex of the SignedFeeClaim bytes from the previous commit. Nothing reads it yet; the service-side verify and persist land later. serde(default, skip_serializing_if = "Option::is_none") keeps the wire backward compatible both ways. An old client that sends {} still decodes to fee_claim: None through the existing params.unwrap_or(json!({})) path, and a node with no claim emits {} rather than an explicit null, so the request bytes match today exactly when no claim is set. The client constructs the request with fee_claim: None; only a node that has been granted a claim populates it, which is wired up in a later change. --- lightning-liquidity/src/lsps4/client.rs | 2 +- lightning-liquidity/src/lsps4/msgs.rs | 37 ++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/lightning-liquidity/src/lsps4/client.rs b/lightning-liquidity/src/lsps4/client.rs index 95dc914bd05..3925cb984d0 100644 --- a/lightning-liquidity/src/lsps4/client.rs +++ b/lightning-liquidity/src/lsps4/client.rs @@ -115,7 +115,7 @@ where } } - let request = LSPS4Request::RegisterNode(RegisterNodeRequest {}); + let request = LSPS4Request::RegisterNode(RegisterNodeRequest { fee_claim: None }); let msg = LSPS4Message::Request(request_id.clone(), request).into(); let mut message_queue_notifier = self.pending_messages.notifier(); message_queue_notifier.enqueue(&counterparty_node_id, msg); diff --git a/lightning-liquidity/src/lsps4/msgs.rs b/lightning-liquidity/src/lsps4/msgs.rs index 271787a793f..04a0f0ad91e 100644 --- a/lightning-liquidity/src/lsps4/msgs.rs +++ b/lightning-liquidity/src/lsps4/msgs.rs @@ -22,7 +22,13 @@ pub(crate) const LSPS4_REGISTER_NODE_METHOD_NAME: &str = "lsps4.register_node"; #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] /// A request made to an LSP to register a node. -pub struct RegisterNodeRequest {} +pub struct RegisterNodeRequest { + /// An optional signed claim, lowercase-hex encoded, granting this node a + /// non-standard fee policy. Absent or unverifiable claims leave the node on + /// the standard policy. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fee_claim: Option, +} /// A newtype that holds a `short_channel_id` in human readable format of BBBxTTTx000. #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] @@ -96,3 +102,32 @@ impl From for LSPSMessage { LSPSMessage::LSPS4(message) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::alloc::string::ToString; + + #[test] + fn register_node_request_with_claim_round_trips() { + let request = RegisterNodeRequest { fee_claim: Some("deadbeef".to_string()) }; + let json_str = r#"{"fee_claim":"deadbeef"}"#; + + assert_eq!(json_str, serde_json::json!(request).to_string()); + assert_eq!(request, serde_json::from_str(json_str).unwrap()); + } + + #[test] + fn register_node_request_without_claim_omits_field() { + let request = RegisterNodeRequest { fee_claim: None }; + + assert_eq!("{}", serde_json::json!(request).to_string()); + } + + #[test] + fn legacy_empty_object_decodes_to_no_claim() { + let request: RegisterNodeRequest = serde_json::from_str("{}").unwrap(); + + assert_eq!(request, RegisterNodeRequest { fee_claim: None }); + } +} From 6ab43471842ff77f4d9ba4bcd666611974a8d705 Mon Sep 17 00:00:00 2001 From: amackillop Date: Wed, 10 Jun 2026 10:17:28 -0700 Subject: [PATCH 07/10] Retain per-peer fee policy in the SCID store The persisted ScidWithPeer already carries a policy field, but the store only built peer<->scid lookups, so the granted policy was written to disk and then lost in memory. This adds a policy_by_peer map kept in lockstep with the existing two: populated on load from the decoded records, upserted on insert, dropped on remove. A get_policy accessor reads it back. ScidWithPeer::new now takes the policy explicitly rather than defaulting it, so there is one way to build the record and the policy is always named at the construction site. add_intercepted_scid passes Flat(Standard), the path a peer with no claim takes; the register handler will pass a verified grant. The on-disk default is unchanged: it comes from the TLV default_value, not the constructor. get_policy warns as dead code until the handler reads it. Keeping policy in its own map rather than handing out whole ScidWithPeer records keeps the skim site's read to a single lookup by node id, which is all it needs. --- lightning-liquidity/src/lsps4/scid_store.rs | 78 +++++++++++++++++---- 1 file changed, 65 insertions(+), 13 deletions(-) diff --git a/lightning-liquidity/src/lsps4/scid_store.rs b/lightning-liquidity/src/lsps4/scid_store.rs index de3afd2740f..eb84b8483c8 100644 --- a/lightning-liquidity/src/lsps4/scid_store.rs +++ b/lightning-liquidity/src/lsps4/scid_store.rs @@ -37,13 +37,9 @@ pub struct ScidWithPeer { impl ScidWithPeer { pub fn new( - scid: u64, peer_id: PublicKey, + scid: u64, peer_id: PublicKey, policy: FeePolicy, ) -> Self { - Self { - scid, - peer_id, - policy: FeePolicy::Flat(FeeTier::Standard), - } + Self { scid, peer_id, policy } } pub fn store_key(&self) -> String { @@ -73,6 +69,7 @@ pub struct ScidStore where L::Target: Logger, KV::Target: KVStoreSync { peer_by_scid: RwLock>, scid_by_peer: RwLock>, + policy_by_peer: RwLock>, kv_store: KV, logger: L } @@ -112,7 +109,11 @@ where L::Target: Logger, KV::Target: KVStoreSync { let scid_by_peer = RwLock::new(HashMap::from_iter(scids.iter().map(|obj| (obj.peer_id(), obj.scid())))); - Ok(Self { peer_by_scid, scid_by_peer, kv_store, logger }) + let policy_by_peer = RwLock::new(HashMap::from_iter( + scids.iter().map(|obj| (obj.peer_id(), obj.policy().clone())), + )); + + Ok(Self { peer_by_scid, scid_by_peer, policy_by_peer, kv_store, logger }) } pub(crate) fn insert(&self, scid: ScidWithPeer) -> Result { @@ -125,8 +126,10 @@ where L::Target: Logger, KV::Target: KVStoreSync { // Then insert into the maps let mut locked_peer_by_scid = self.peer_by_scid.write().unwrap(); let mut locked_scid_by_peer = self.scid_by_peer.write().unwrap(); + let mut locked_policy_by_peer = self.policy_by_peer.write().unwrap(); let updated = locked_peer_by_scid.insert(scid.scid(), scid.peer_id().clone()).is_some(); locked_scid_by_peer.insert(scid.peer_id().clone(), scid.scid()); + locked_policy_by_peer.insert(scid.peer_id().clone(), scid.policy().clone()); log_info!( self.logger, @@ -142,10 +145,12 @@ where L::Target: Logger, KV::Target: KVStoreSync { pub(crate) fn remove(&self, scid: u64) -> Result<(), io::Error> { let mut locked_peer_by_scid = self.peer_by_scid.write().unwrap(); let mut locked_scid_by_peer = self.scid_by_peer.write().unwrap(); + let mut locked_policy_by_peer = self.policy_by_peer.write().unwrap(); let removed = locked_peer_by_scid.remove(&scid); if let Some(peer_id) = removed { locked_scid_by_peer.remove(&peer_id); + locked_policy_by_peer.remove(&peer_id); let store_key = utils::to_string(&scid.to_be_bytes()); self.kv_store .remove(INTERCEPT_SCID_STORE_PERSISTENCE_PRIMARY_NAMESPACE, INTERCEPT_SCID_STORE_PERSISTENCE_SECONDARY_NAMESPACE, &store_key, false) @@ -182,10 +187,7 @@ where L::Target: Logger, KV::Target: KVStoreSync { pub fn add_intercepted_scid( &self, scid: u64, peer_id: PublicKey, ) -> Result { - let scid = ScidWithPeer::new( - scid, - peer_id, - ); + let scid = ScidWithPeer::new(scid, peer_id, FeePolicy::Flat(FeeTier::Standard)); self.insert(scid) } @@ -212,6 +214,10 @@ where L::Target: Logger, KV::Target: KVStoreSync { ); result } + + pub fn get_policy(&self, peer_id: &PublicKey) -> Option { + self.policy_by_peer.read().unwrap().get(peer_id).cloned() + } } #[cfg(test)] @@ -243,11 +249,11 @@ mod tests { #[test] fn round_trips_with_policy() { - let record = ScidWithPeer::new(42, test_peer()); + let record = ScidWithPeer::new(42, test_peer(), FeePolicy::Flat(FeeTier::ZeroFee)); let bytes = record.encode(); let decoded = ScidWithPeer::read(&mut &bytes[..]).unwrap(); assert_eq!(record, decoded); - assert_eq!(decoded.policy(), &FeePolicy::Flat(FeeTier::Standard)); + assert_eq!(decoded.policy(), &FeePolicy::Flat(FeeTier::ZeroFee)); } #[test] @@ -259,4 +265,50 @@ mod tests { assert_eq!(decoded.peer_id(), test_peer()); assert_eq!(decoded.policy(), &FeePolicy::Flat(FeeTier::Standard)); } + + use bitcoin::secp256k1::{Secp256k1, SecretKey}; + use lightning::util::test_utils::{TestLogger, TestStore}; + use std::sync::Arc; + + fn other_peer() -> PublicKey { + PublicKey::from_secret_key(&Secp256k1::new(), &SecretKey::from_slice(&[0x24; 32]).unwrap()) + } + + fn test_store() -> ScidStore, Arc> { + ScidStore::new(Arc::new(TestStore::new(false)), Arc::new(TestLogger::new())).unwrap() + } + + #[test] + fn insert_with_policy_then_get_policy_returns_it() { + let store = test_store(); + store + .insert(ScidWithPeer::new(42, test_peer(), FeePolicy::Flat(FeeTier::ZeroFee))) + .unwrap(); + + assert_eq!(store.get_policy(&test_peer()), Some(FeePolicy::Flat(FeeTier::ZeroFee))); + assert_eq!(store.get_policy(&other_peer()), None); + } + + #[test] + fn load_rebuilds_policy_map() { + let kv_store = Arc::new(TestStore::new(false)); + { + let store = + ScidStore::new(kv_store.clone(), Arc::new(TestLogger::new())).unwrap(); + store + .insert(ScidWithPeer::new(42, test_peer(), FeePolicy::Flat(FeeTier::ZeroFee))) + .unwrap(); + } + + let reloaded = ScidStore::new(kv_store, Arc::new(TestLogger::new())).unwrap(); + assert_eq!(reloaded.get_policy(&test_peer()), Some(FeePolicy::Flat(FeeTier::ZeroFee))); + } + + #[test] + fn default_record_resolves_to_standard_policy() { + let store = test_store(); + store.add_intercepted_scid(42, test_peer()).unwrap(); + + assert_eq!(store.get_policy(&test_peer()), Some(FeePolicy::Flat(FeeTier::Standard))); + } } \ No newline at end of file From d886fba8c332c0b3e32f5e3e4a48e18567cf85d1 Mon Sep 17 00:00:00 2001 From: amackillop Date: Wed, 10 Jun 2026 10:34:31 -0700 Subject: [PATCH 08/10] Verify fee claims and persist the grant on register Wires the claim verifier into register_node. The service now holds a set of trusted issuer keys and a long-lived verification context; when a peer registers, any fee_claim it presented is verified against those keys and the granted policy is persisted onto the peer's SCID record before the response is enqueued, so the policy is in place by the time the SCID is handed out. The feature is inert by default. An empty issuer_pubkeys set short-circuits before any crypto and resolves every peer to the standard policy, byte for byte identical to today. Population of the key set is the operator's job downstream; nothing in-tree sets it. A grant is only ever upserted, never downgraded. An absent or unverifiable claim returns None and leaves an existing record untouched, so a transient miss or a malformed claim can't wipe a live grant; a brand-new record falls back to standard. This deviates from a literal "else standard" on purpose: once a node has been granted zero-fee, a dropped claim on a later re-registration must not silently restore the 2% skim. The verifier borrows the service's context rather than allocating per call, and resolve_claim_policy logs and swallows verification failures rather than failing the registration: a bad claim should cost the node its discount, not its channel. add_intercepted_scid is removed. It only built a standard-policy record, which the new persist path now does directly with the resolved policy, so the old helper had no remaining caller. --- lightning-liquidity/src/lsps4/scid_store.rs | 9 +-- lightning-liquidity/src/lsps4/service.rs | 85 ++++++++++++++++----- 2 files changed, 69 insertions(+), 25 deletions(-) diff --git a/lightning-liquidity/src/lsps4/scid_store.rs b/lightning-liquidity/src/lsps4/scid_store.rs index eb84b8483c8..e3561c30927 100644 --- a/lightning-liquidity/src/lsps4/scid_store.rs +++ b/lightning-liquidity/src/lsps4/scid_store.rs @@ -184,13 +184,6 @@ where L::Target: Logger, KV::Target: KVStoreSync { Ok(()) } - pub fn add_intercepted_scid( - &self, scid: u64, peer_id: PublicKey, - ) -> Result { - let scid = ScidWithPeer::new(scid, peer_id, FeePolicy::Flat(FeeTier::Standard)); - self.insert(scid) - } - pub fn get_peer(&self, scid: u64) -> Option { use lightning::log_debug; let result = self.peer_by_scid.read().unwrap().get(&scid).cloned(); @@ -307,7 +300,7 @@ mod tests { #[test] fn default_record_resolves_to_standard_policy() { let store = test_store(); - store.add_intercepted_scid(42, test_peer()).unwrap(); + store.insert(ScidWithPeer::new(42, test_peer(), FeePolicy::Flat(FeeTier::Standard))).unwrap(); assert_eq!(store.get_policy(&test_peer()), Some(FeePolicy::Flat(FeeTier::Standard))); } diff --git a/lightning-liquidity/src/lsps4/service.rs b/lightning-liquidity/src/lsps4/service.rs index 299c73c42c1..3d3502a4c55 100644 --- a/lightning-liquidity/src/lsps4/service.rs +++ b/lightning-liquidity/src/lsps4/service.rs @@ -15,10 +15,11 @@ use crate::lsps0::ser::{ JSONRPC_INTERNAL_ERROR_ERROR_CODE, JSONRPC_INTERNAL_ERROR_ERROR_MESSAGE, LSPS0_CLIENT_REJECTED_ERROR_CODE, }; +use crate::lsps4::claim::verify_claim; use crate::lsps4::event::LSPS4ServiceEvent; use crate::lsps4::htlc_store::{HTLCStore, InterceptedHtlc}; use crate::lsps4::fee_policy::{resolve_skim, FeePolicy, FeeTier}; -use crate::lsps4::scid_store::ScidStore; +use crate::lsps4::scid_store::{ScidStore, ScidWithPeer}; use crate::message_queue::MessageQueue; use crate::prelude::hash_map::Entry; use crate::prelude::{new_hash_map, HashMap}; @@ -34,7 +35,7 @@ use lightning::util::logger::{Level, Logger}; use lightning::util::persist::{KVStore, KVStoreSync}; use lightning_types::payment::PaymentHash; -use bitcoin::secp256k1::PublicKey; +use bitcoin::secp256k1::{PublicKey, Secp256k1, VerifyOnly, XOnlyPublicKey}; use core::ops::Deref; use core::sync::atomic::{AtomicUsize, Ordering}; @@ -89,6 +90,10 @@ pub struct LSPS4ServiceConfig { pub cltv_expiry_delta: u32, /// The proportional fee, in millionths, to skim from forwarded payments. pub forwarding_fee_proportional_millionths: u64, + /// Issuer keys trusted to sign fee-policy grants. Empty disables the feature: every claim is + /// rejected and every peer resolves to the standard policy. A claim signed by any one of these + /// keys is honoured. + pub issuer_pubkeys: Vec, } /// The main object allowing to send and receive LSPS4 messages. @@ -106,6 +111,8 @@ where htlc_store: HTLCStore, connected_peers: RwLock>, config: LSPS4ServiceConfig, + /// Long-lived context for verifying fee-claim signatures during registration. + secp_ctx: Secp256k1, } impl LSPS4ServiceHandler @@ -132,6 +139,7 @@ where config, logger, connected_peers: RwLock::new(HashSet::new()), + secp_ctx: Secp256k1::verification_only(), }) } @@ -305,8 +313,56 @@ where self.connected_peers.write().unwrap().remove(counterparty_node_id); } + /// Resolve any fee-policy grant the registering node presented. + /// + /// Returns `None` when the feature is off (no issuer keys configured), when no claim was + /// presented, or when the claim does not verify. A `None` must never downgrade a live grant, so + /// the caller only upserts on `Some`; a new record falls back to the standard policy. + fn resolve_claim_policy( + &self, counterparty: &PublicKey, fee_claim: &Option, + ) -> Option { + if self.config.issuer_pubkeys.is_empty() { + return None; + } + let fee_claim = fee_claim.as_ref()?; + match verify_claim(&self.secp_ctx, fee_claim, &self.config.issuer_pubkeys, counterparty) { + Ok(policy) => Some(policy), + Err(e) => { + log_error!( + self.logger, + "[LSPS4] Rejected fee claim from {}: {:?}", + counterparty, + e + ); + None + }, + } + } + + /// Persist (upsert) a SCID record carrying the resolved fee policy for a peer. + fn persist_scid_policy( + &self, intercept_scid: u64, peer: &PublicKey, policy: FeePolicy, + ) -> Result<(), LightningError> { + self.scid_store + .insert(ScidWithPeer::new(intercept_scid, peer.clone(), policy)) + .map(|_| ()) + .map_err(|e| { + log_error!( + self.logger, + "[LSPS4] Failed to persist intercept SCID {} for peer {}: {}", + intercept_scid, + peer, + e + ); + LightningError { + err: format!("Failed to add intercepted SCID: {}", e), + action: ErrorAction::IgnoreAndLog(Level::Error), + } + }) + } + fn handle_register_node_request( - &self, request_id: LSPSRequestId, counterparty_node_id: &PublicKey, _params: RegisterNodeRequest, + &self, request_id: LSPSRequestId, counterparty_node_id: &PublicKey, params: RegisterNodeRequest, ) -> Result<(), LightningError> { let fn_start = Instant::now(); log_info!( @@ -317,6 +373,8 @@ where ); let step_start = Instant::now(); + let granted_policy = self.resolve_claim_policy(counterparty_node_id, ¶ms.fee_claim); + let intercept_scid = match self.scid_store.get_scid(counterparty_node_id) { Some(intercept_scid) => { log_info!( @@ -326,6 +384,11 @@ where intercept_scid, counterparty_node_id ); + // Upsert a verified grant onto the existing record. An absent or invalid claim + // leaves the live policy untouched so a transient miss can't wipe a grant. + if let Some(policy) = granted_policy { + self.persist_scid_policy(intercept_scid, counterparty_node_id, policy)?; + } intercept_scid }, None => { @@ -344,20 +407,8 @@ where counterparty_node_id ); let store_start = Instant::now(); - self.scid_store.add_intercepted_scid(intercept_scid, counterparty_node_id.clone()) - .map_err(|e| { - log_error!( - self.logger, - "[LSPS4] Failed to persist intercept SCID {} for peer {}: {}", - intercept_scid, - counterparty_node_id, - e - ); - LightningError { - err: format!("Failed to add intercepted SCID: {}", e), - action: ErrorAction::IgnoreAndLog(Level::Error), - } - })?; + let policy = granted_policy.unwrap_or(FeePolicy::Flat(FeeTier::Standard)); + self.persist_scid_policy(intercept_scid, counterparty_node_id, policy)?; log_info!( self.logger, "TIMING: [LSPS4] handle_register_node_request scid_store.add_intercepted_scid() took {}ms - Successfully stored intercept SCID {} for peer {}", From 0a01ca2f7aa5d095db38676a625d6ebdc4bfb642 Mon Sep 17 00:00:00 2001 From: amackillop Date: Wed, 10 Jun 2026 10:45:33 -0700 Subject: [PATCH 09/10] Honor the per-peer fee policy at the skim site The forwarding skim now reads the peer's granted policy from the SCID store instead of hard-coding Flat(Standard). A peer with no grant resolves to Standard, so the skim is byte-for-byte the historical 2% for everyone the issuer set has not waived. This is the read side that makes the persisted grant actually take effect; before it, a verified ZeroFee was stored and then ignored. The lookup is hoisted out of the per-HTLC loop because the policy is keyed by peer, not by HTLC, so it is one store read per batch rather than one per forward. A zero skim is now two distinct cases, so the log is split. ZeroFee is the expected path and logs at info. Any other tier skimming nothing means the fee would have consumed the whole HTLC, which keeps the error-level log: a forward of the full amount that we expected to take a cut on. Previously ZeroFee was unreachable, so the single error log was correct; now that a grant can legitimately waive the fee, the error would be noise. --- lightning-liquidity/src/lsps4/service.rs | 32 ++++++++++++++++++------ 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/lightning-liquidity/src/lsps4/service.rs b/lightning-liquidity/src/lsps4/service.rs index 3d3502a4c55..dc31d65ef11 100644 --- a/lightning-liquidity/src/lsps4/service.rs +++ b/lightning-liquidity/src/lsps4/service.rs @@ -466,6 +466,14 @@ where skimmed_fee_msat: u64, } + // The peer's granted policy, looked up once. Peers with no grant resolve to Standard, so + // the skim matches the historical 2% for everyone the issuer set hasn't waived. + let policy = self + .scid_store + .get_policy(&their_node_id) + .unwrap_or(FeePolicy::Flat(FeeTier::Standard)); + let is_zero_fee = matches!(policy, FeePolicy::Flat(FeeTier::ZeroFee)); + let mut computed_htlcs: Vec = htlcs .drain(..) .map(|htlc| { @@ -476,18 +484,26 @@ where let htlc_id = htlc.id(); let skimmed_fee_msat = resolve_skim( - &FeePolicy::Flat(FeeTier::Standard), + &policy, expected_outbound_msat, self.config.forwarding_fee_proportional_millionths, ); if skimmed_fee_msat == 0 { - // The policy here is always Flat(Standard), so a zero skim can only mean the - // fee would have consumed the entire HTLC; it is never a ZeroFee waiver yet. - log_error!( - self.logger, - "Standard skim would have consumed the entire HTLC {:?}; forwarding the full amount.", - htlc_id - ); + if is_zero_fee { + log_info!( + self.logger, + "Zero-fee policy for HTLC {:?}; forwarding the full amount.", + htlc_id + ); + } else { + // A non-zero-fee tier skimmed nothing only because the fee would have + // consumed the entire HTLC; forward it intact rather than break it. + log_error!( + self.logger, + "Skim would have consumed the entire HTLC {:?}; forwarding the full amount.", + htlc_id + ); + } } let amount_to_forward_msat = expected_outbound_msat.saturating_sub(skimmed_fee_msat); From 9b5f40f00a623ff5e1cbe2540d79c3665589be6e Mon Sep 17 00:00:00 2001 From: amackillop Date: Thu, 11 Jun 2026 05:51:44 -0700 Subject: [PATCH 10/10] Plumb an optional fee_claim through register_node MDK-980 added the fee_claim field to RegisterNodeRequest and the verifier that reads it, but the client still hardcoded None, so no node could ever present a claim. This lets the caller pass one. The claim rides as a per-call argument rather than a field on LSPS4ClientConfig: that config derives Copy, which an Option would break, and the value belongs to the layer above (ldk-node), which already holds it and relays it on every registration. There is a single call site, so threading it as a parameter is the smaller, honest change. The value is opaque here: a lowercase-hex signed grant the LSP alone decodes and verifies. Passing None reproduces today's behavior exactly, so nothing changes until a node is configured with a claim. --- lightning-liquidity/src/lsps4/client.rs | 78 ++++++++++++++++++++++++- 1 file changed, 75 insertions(+), 3 deletions(-) diff --git a/lightning-liquidity/src/lsps4/client.rs b/lightning-liquidity/src/lsps4/client.rs index 3925cb984d0..7175ba50591 100644 --- a/lightning-liquidity/src/lsps4/client.rs +++ b/lightning-liquidity/src/lsps4/client.rs @@ -84,11 +84,15 @@ where /// Requests the LSP to register the node. /// + /// `fee_claim` is an optional lowercase-hex signed grant for a non-standard fee policy; the LSP + /// verifies it against its configured issuer keys. `None` (or an unverifiable claim) leaves the + /// node on the standard policy. + /// /// The user will receive the LSP's response via an [`InvoiceParametersReady`] event. /// /// [`InvoiceParametersReady`]: crate::lsps4::event::LSPS4ClientEvent::InvoiceParametersReady pub fn register_node( - &self, counterparty_node_id: PublicKey + &self, counterparty_node_id: PublicKey, fee_claim: Option, ) -> Result { let fn_start = Instant::now(); eprintln!("TIMING: [LSPS4 Client] register_node START for peer {}", counterparty_node_id); @@ -115,7 +119,7 @@ where } } - let request = LSPS4Request::RegisterNode(RegisterNodeRequest { fee_claim: None }); + let request = LSPS4Request::RegisterNode(RegisterNodeRequest { fee_claim }); let msg = LSPS4Message::Request(request_id.clone(), request).into(); let mut message_queue_notifier = self.pending_messages.notifier(); message_queue_notifier.enqueue(&counterparty_node_id, msg); @@ -210,4 +214,72 @@ where } #[cfg(test)] -mod tests {} +mod tests { + use super::*; + use crate::lsps0::ser::LSPSMessage; + use bitcoin::secp256k1::{Secp256k1, SecretKey}; + use core::sync::atomic::{AtomicU64, Ordering}; + use lightning::util::persist::KVStoreSyncWrapper; + use lightning::util::test_utils::TestStore; + use lightning::util::wakers::Notifier; + use std::collections::VecDeque; + + struct CountingEntropy { + counter: AtomicU64, + } + + impl EntropySource for CountingEntropy { + fn get_secure_random_bytes(&self) -> [u8; 32] { + let counter = self.counter.fetch_add(1, Ordering::SeqCst); + let mut bytes = [0u8; 32]; + bytes[0..8].copy_from_slice(&counter.to_be_bytes()); + bytes + } + } + + type TestKVStore = Arc>>; + + fn setup() -> (LSPS4ClientHandler, TestKVStore>, Arc, PublicKey) + { + let entropy = Arc::new(CountingEntropy { counter: AtomicU64::new(1) }); + let message_queue = Arc::new(MessageQueue::new(Arc::new(Notifier::new()))); + let kv_store = Arc::new(KVStoreSyncWrapper(Arc::new(TestStore::new(false)))); + let event_queue = + Arc::new(EventQueue::new(VecDeque::new(), kv_store, Arc::new(Notifier::new()))); + let client = LSPS4ClientHandler::new( + entropy, + Arc::clone(&message_queue), + event_queue, + LSPS4ClientConfig::default(), + ); + let peer = PublicKey::from_secret_key(&Secp256k1::new(), &SecretKey::from_slice(&[42u8; 32]).unwrap()); + (client, message_queue, peer) + } + + /// The single enqueued message must be a `register_node` request; returns its `fee_claim`. + fn sole_request_fee_claim(message_queue: &MessageQueue) -> Option { + let mut pending = message_queue.get_and_clear_pending_msgs(); + assert_eq!(pending.len(), 1); + match pending.pop().unwrap().1 { + LSPSMessage::LSPS4(LSPS4Message::Request(_, LSPS4Request::RegisterNode(req))) => { + req.fee_claim + }, + other => panic!("expected a register_node request, got {:?}", other), + } + } + + #[test] + fn register_node_carries_the_claim() { + let (client, message_queue, peer) = setup(); + let claim = "deadbeef".to_string(); + client.register_node(peer, Some(claim.clone())).unwrap(); + assert_eq!(sole_request_fee_claim(&message_queue), Some(claim)); + } + + #[test] + fn register_node_without_a_claim_omits_it() { + let (client, message_queue, peer) = setup(); + client.register_node(peer, None).unwrap(); + assert_eq!(sole_request_fee_claim(&message_queue), None); + } +}