From b8aaab599870d81ebc4b577c4f2d4cbb3a950238 Mon Sep 17 00:00:00 2001 From: amackillop Date: Wed, 10 Jun 2026 09:22:21 -0700 Subject: [PATCH 1/5] 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 545d0b7503db3f3acbded8fa589727eda3e316c3 Mon Sep 17 00:00:00 2001 From: amackillop Date: Wed, 10 Jun 2026 09:58:45 -0700 Subject: [PATCH 2/5] 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 f8e1e749896..e026eb10417 100644 --- a/lightning-liquidity/src/lsps4/client.rs +++ b/lightning-liquidity/src/lsps4/client.rs @@ -109,7 +109,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 30a7750969a95227ec36fdebda4c9213fb1c7e00 Mon Sep 17 00:00:00 2001 From: amackillop Date: Wed, 10 Jun 2026 10:17:28 -0700 Subject: [PATCH 3/5] 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 95c6c0b0df1ad7e9590304eab7b4a486617ef7e5 Mon Sep 17 00:00:00 2001 From: amackillop Date: Wed, 10 Jun 2026 10:34:31 -0700 Subject: [PATCH 4/5] 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 ebd14f658c8..6b23f826107 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}; @@ -105,6 +106,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. @@ -122,6 +127,8 @@ where htlc_store: HTLCStore, connected_peers: RwLock>, config: LSPS4ServiceConfig, + /// Long-lived context for verifying fee-claim signatures during registration. + secp_ctx: Secp256k1, /// Per-peer timestamped cooldown for liquidity actions (splice or channel open). /// Prevents 1Hz retry loops from process_pending_htlcs when actions fail. /// Auto-expires after LIQUIDITY_COOLDOWN_SECS. @@ -152,6 +159,7 @@ where config, logger, connected_peers: RwLock::new(HashSet::new()), + secp_ctx: Secp256k1::verification_only(), liquidity_cooldown: RwLock::new(new_hash_map()), }) } @@ -598,8 +606,56 @@ where } } + /// 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> { log_info!( self.logger, @@ -608,6 +664,8 @@ where request_id ); + 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!( @@ -616,6 +674,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 => { @@ -626,20 +689,8 @@ where intercept_scid, counterparty_node_id ); - 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, "[LSPS4] Successfully stored intercept SCID {} for peer {}", From ad315bcdf9954da6e4a324318b657b49d056f6fb Mon Sep 17 00:00:00 2001 From: amackillop Date: Wed, 10 Jun 2026 10:45:33 -0700 Subject: [PATCH 5/5] 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 6b23f826107..bd074df2351 100644 --- a/lightning-liquidity/src/lsps4/service.rs +++ b/lightning-liquidity/src/lsps4/service.rs @@ -782,6 +782,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| { @@ -792,18 +800,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);