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/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/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; 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 }); + } +} diff --git a/lightning-liquidity/src/lsps4/scid_store.rs b/lightning-liquidity/src/lsps4/scid_store.rs index de3afd2740f..e3561c30927 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) @@ -179,16 +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, - ); - 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(); @@ -212,6 +207,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 +242,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 +258,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.insert(ScidWithPeer::new(42, test_peer(), FeePolicy::Flat(FeeTier::Standard))).unwrap(); + + assert_eq!(store.get_policy(&test_peer()), Some(FeePolicy::Flat(FeeTier::Standard))); + } } \ No newline at end of file diff --git a/lightning-liquidity/src/lsps4/service.rs b/lightning-liquidity/src/lsps4/service.rs index ebd14f658c8..bd074df2351 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 {}", @@ -731,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| { @@ -741,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);