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 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 95dc914bd05..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 {}); + 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); + } +} diff --git a/lightning-liquidity/src/lsps4/fee_policy.rs b/lightning-liquidity/src/lsps4/fee_policy.rs new file mode 100644 index 00000000000..0d5aad32576 --- /dev/null +++ b/lightning-liquidity/src/lsps4/fee_policy.rs @@ -0,0 +1,201 @@ +// 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 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. +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..5d16b18fba0 100644 --- a/lightning-liquidity/src/lsps4/mod.rs +++ b/lightning-liquidity/src/lsps4/mod.rs @@ -9,8 +9,10 @@ //! Implementation of LSPS4: JIT Channel Negotiation specification. +pub(crate) mod claim; pub mod client; pub mod event; +pub mod fee_policy; pub(crate) mod htlc_store; pub mod msgs; pub(crate) mod scid_store; 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 aeff112ae0a..e3561c30927 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,16 +32,14 @@ pub(crate) const INTERCEPT_SCID_STORE_PERSISTENCE_SECONDARY_NAMESPACE: &str = "" pub struct ScidWithPeer { scid: u64, peer_id: PublicKey, + policy: FeePolicy, } impl ScidWithPeer { pub fn new( - scid: u64, peer_id: PublicKey, + scid: u64, peer_id: PublicKey, policy: FeePolicy, ) -> Self { - Self { - scid, - peer_id, - } + Self { scid, peer_id, policy } } pub fn store_key(&self) -> String { @@ -54,17 +53,23 @@ 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 where L::Target: Logger, KV::Target: KVStoreSync { peer_by_scid: RwLock>, scid_by_peer: RwLock>, + policy_by_peer: RwLock>, kv_store: KV, logger: L } @@ -104,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 { @@ -117,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, @@ -134,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) @@ -171,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(); @@ -204,4 +207,101 @@ 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)] +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(), 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::ZeroFee)); + } + + #[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)); + } + + 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 21eb54f05a4..dc31d65ef11 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::scid_store::ScidStore; -use crate::lsps4::utils::compute_forward_fee; +use crate::lsps4::fee_policy::{resolve_skim, FeePolicy, FeeTier}; +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 {}", @@ -415,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| { @@ -424,33 +483,32 @@ where } let htlc_id = htlc.id(); - let mut fee_msat = match crate::lsps4::utils::compute_forward_fee( + let skimmed_fee_msat = resolve_skim( + &policy, expected_outbound_msat, self.config.forwarding_fee_proportional_millionths, - ) { - Some(fee) => core::cmp::min(fee, expected_outbound_msat), - None => { + ); + if skimmed_fee_msat == 0 { + 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, - "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 { - log_error!( - self.logger, - "Skimmed fee equaled the entire HTLC amount for {:?}. Skipping skim.", - htlc_id - ); - fee_msat = 0; - amount_to_forward_msat = expected_outbound_msat; + self.logger, + "Skim would have consumed the entire HTLC {:?}; forwarding the full amount.", + htlc_id + ); + } } - 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();