diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 5852d129e..e1ed8f3dd 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -84,6 +84,7 @@ nav: - Testserver: reference/testserver.md - Certificates: reference/certs.md - Security: reference/security.md + - Signer Fee Budgets: reference/signer-fees.md - Signer State: reference/signer-state.md - Signer Backups: reference/signer-backups.md - LSP Integration: reference/lsp.md diff --git a/docs/src/reference/security.md b/docs/src/reference/security.md index 83d0b0ac7..6c3b863ba 100644 --- a/docs/src/reference/security.md +++ b/docs/src/reference/security.md @@ -90,6 +90,18 @@ operations. An attacker that gains access to the node is unable to provide either these signatures and will therefore fail to convince the signer of its injected commands. +## Signer Fee Budgets + +VLS enforces routing-fee allowances across channels and HTLC parts. Independent +handler checks cap actual on-chain fees and remain active in permissive mode. +See [Signer Fee Budgets](signer-fees.md) for configuration, defaults, and +operational limitations. + +!!! warning "Permissive mode does not establish payment authorization" + Default builds can automatically approve a node-supplied invoice without + an authenticated client payment RPC. The fee allowance is relative to + that invoice and does not prove approval of its principal. + ## Signer State Integrity The signer does not store its own state. The node stores the diff --git a/docs/src/reference/signer-fees.md b/docs/src/reference/signer-fees.md new file mode 100644 index 000000000..0928ab2ed --- /dev/null +++ b/docs/src/reference/signer-fees.md @@ -0,0 +1,119 @@ +# Signer Fee Budgets + +The signer limits routing fees on outgoing payments and miner fees on channel +transactions. Set these limits when starting or embedding the signer. + +## Set fee limits + +| CLI flag | Default | Controls | +|----------|---------|----------| +| `--payment-fee-exempt-msat` | 1,000,000 msat (1,000 sat) | Routing fee allowance for small payments. | +| `--max-payment-fee-ppm` | 5,000 ppm (0.5%) | Routing fee allowance as a percentage of the payment amount. | +| `--max-commitment-fee-sat` | 100,000 sat | Maximum miner fee per new commitment or signed local HTLC input. | +| `--max-mutual-close-fee-sat` | 50,000 sat | Maximum miner fee per cooperative channel close. | + +For example, to set the routing percentage to 1% while keeping the other defaults: + +```sh +glcli signer run --max-payment-fee-ppm 10000 +``` + +In Rust, set `SignerConfig::fee_budget_limits`: + +```rust +use gl_client::signer::{FeeBudgetLimits, SignerConfig}; + +let config = SignerConfig { + fee_budget_limits: FeeBudgetLimits { + max_payment_fee_ppm: 10_000, // 1% + ..Default::default() + }, + ..Default::default() +}; +``` + +## Routing fees + +The signer allows the larger of the fixed allowance and the percentage allowance: + +```text +max(payment_fee_exempt_msat, + floor(payment_amount_msat * max_payment_fee_ppm / 1_000_000)) +``` + +With the defaults: + +| Payment amount | Maximum routing fee | +|----------------|---------------------| +| 100,000 sat | 1,000 sat | +| 1,000,000 sat | 5,000 sat | + +The allowance is shared across all parts and channels used by the payment. +For invoices with no amount, it uses the amount supplied in the payment request. +You can retry an amountless invoice with a different amount once the previous +attempt has no outstanding HTLCs. + +Routing checks use whole satoshi. Rounding can allow up to 999 msat extra per +HTLC, including when both routing settings are zero. These limits apply to +outstanding HTLCs per payment hash; they do not track lifetime spending. + +## Dealing with different payment types + +Keep the payment request's fee limit at or below the signer's routing allowance. +For `pay`, direct-invoice `xpay`, and trampoline payments, the signer rejects +higher requested limits before CLN offers any HTLCs. A cheaper available route +does not prevent this rejection. + +### SDK `send` and LNURL pay + +The SDK sets `maxfee` automatically to the larger of 1,000 sat and 0.5% of the +payment amount. It does not read custom signer settings or expose a fee override. +Use the CLN RPCs directly if your application needs a different fee limit. + +### CLN `pay` + +Set `maxfee` in millisatoshi to choose an absolute fee limit. + +Without `maxfee`, CLN uses the larger of: + +- The percentage fee set by `maxfeepercent` (default: 0.5%). +- The fixed allowance set by `exemptfee` (default: 5,000 msat, or 5 sat). + +### CLN `xpay` + +Set `maxfee` explicitly. Xpay's default is the larger of 1% of the payment amount +and 5 sat. For large payments, this exceeds the signer's default allowance. + +If you omit `maxfee` and set `partial_msat`, Xpay calculates the default fee from +the partial amount. The signer's allowance still uses the full payment amount. + +The early fee check covers direct invoices. It does not cover invoices obtained +through offer or BIP353 name resolution. + +### Trampoline + +Trampoline payments prepay the requested percentage surcharge in full. Set +`maxfeepercent` to choose the percentage; omitting it or setting it to zero uses +0.5%. The entire surcharge counts toward the signer's routing allowance. + +### CLN `sendpay` + +When supplying your own routes, keep their combined routing fees within the +signer's allowance. These fees are checked at commitment signing, after the +HTLCs have been offered. + +## Miner fees + +The signer checks these caps before accepting a new commitment or signing a +local HTLC input or cooperative close. The default caps may reject channel +updates when Bitcoin fees are high or many HTLCs are pending. Commitment fees +include the value of small HTLCs omitted from the transaction outputs as dust. + +A previously accepted commitment can still be signed for a force-close if its +fee exceeds the current cap. The signer logs a warning. Lowering a cap therefore +does not prevent force-closing a stored commitment. + +Wallet withdrawals and sweeps retain their existing 150,000 sat/kW (600 sat/vB) +fee-rate ceiling. + +For payment authorization, see [Security](security.md#signer-fee-budgets). diff --git a/libs/gl-cli/src/signer.rs b/libs/gl-cli/src/signer.rs index 9757edd08..e423c73c6 100644 --- a/libs/gl-cli/src/signer.rs +++ b/libs/gl-cli/src/signer.rs @@ -3,9 +3,9 @@ use crate::util; use clap::{Subcommand, ValueEnum}; use core::fmt::Debug; use gl_client::signer::{ - RecoverableChannel, CLNBackup, CLNBackupOptions, Signer, - SignerBackupConfig, SignerBackupSnapshot, SignerBackupStrategy, SignerConfig, - StateSignatureMode, StateSignatureOverrideConfig, + CLNBackup, CLNBackupOptions, FeeBudgetLimits, RecoverableChannel, Signer, SignerBackupConfig, + SignerBackupSnapshot, SignerBackupStrategy, SignerConfig, StateSignatureMode, + StateSignatureOverrideConfig, }; use lightning_signer::bitcoin::Network; use serde::Serialize; @@ -87,6 +87,18 @@ pub enum Command { backup_strategy: Option, #[arg(long = "backup-periodic-updates")] backup_periodic_updates: Option, + /// Routing fee allowance for small payments, in millisatoshi (whole-sat HTLC accounting). + #[arg(long, default_value_t = FeeBudgetLimits::default().payment_fee_exempt_msat)] + payment_fee_exempt_msat: u64, + /// Routing fee allowance in parts per million (5,000 ppm = 0.5%). + #[arg(long, default_value_t = FeeBudgetLimits::default().max_payment_fee_ppm)] + max_payment_fee_ppm: u32, + /// Hard miner-fee ceiling per commitment or signed local HTLC input, in satoshi. + #[arg(long, default_value_t = FeeBudgetLimits::default().max_commitment_fee_sat)] + max_commitment_fee_sat: u64, + /// Hard fee ceiling per mutual-close transaction, in satoshi. + #[arg(long, default_value_t = FeeBudgetLimits::default().max_mutual_close_fee_sat)] + max_mutual_close_fee_sat: u64, }, /// Inspects a local signer backup file InspectBackup { @@ -119,6 +131,10 @@ pub async fn command_handler>(cmd: Command, config: Config

) -> backup_path, backup_strategy, backup_periodic_updates, + payment_fee_exempt_msat, + max_payment_fee_ppm, + max_commitment_fee_sat, + max_mutual_close_fee_sat, } => { run_handler( config, @@ -128,6 +144,12 @@ pub async fn command_handler>(cmd: Command, config: Config

) -> backup_path, backup_strategy, backup_periodic_updates, + FeeBudgetLimits { + payment_fee_exempt_msat, + max_payment_fee_ppm, + max_commitment_fee_sat, + max_mutual_close_fee_sat, + }, ) .await } @@ -323,6 +345,7 @@ async fn run_handler>( backup_path: Option, backup_strategy: Option, backup_periodic_updates: Option, + fee_budget_limits: FeeBudgetLimits, ) -> Result<()> { // Check if we can find a seed file, if we can not find one, we need to register first. let seed_path = config.data_dir.as_ref().join(SEED_FILE_NAME); @@ -371,6 +394,7 @@ async fn run_handler>( state_signature_mode: state_signature_mode.into(), state_signature_override, backup, + fee_budget_limits, }, ) .map_err(|e| Error::custom(format!("Failed to create signer: {}", e)))?; @@ -459,6 +483,7 @@ mod tests { backup_path, backup_strategy, backup_periodic_updates, + .. } => { assert_eq!(state_signature_mode, StateSignatureModeArg::Hard); assert!(state_override.is_none()); @@ -482,6 +507,7 @@ mod tests { backup_path, backup_strategy, backup_periodic_updates, + .. } => { assert_eq!(state_signature_mode, StateSignatureModeArg::Soft); assert!(state_override.is_none()); @@ -512,6 +538,7 @@ mod tests { backup_path, backup_strategy, backup_periodic_updates, + .. }) => { assert_eq!(state_signature_mode, StateSignatureModeArg::Off); assert!(state_override.is_none()); @@ -544,6 +571,7 @@ mod tests { backup_path, backup_strategy, backup_periodic_updates, + .. } => { assert_eq!(state_signature_mode, StateSignatureModeArg::Hard); assert_eq!( diff --git a/libs/gl-client-py/tests/test_plugin.py b/libs/gl-client-py/tests/test_plugin.py index 49cb55817..405d02e87 100644 --- a/libs/gl-client-py/tests/test_plugin.py +++ b/libs/gl-client-py/tests/test_plugin.py @@ -1,4 +1,5 @@ from gltesting.fixtures import * +from glclient import nodepb from pyln.testing.utils import wait_for from pyln import grpc as clnpb import grpc @@ -42,8 +43,9 @@ def test_max_message_size(clients): n1.datastore("some-key", hex=bytes.fromhex(secrets.token_hex(size))) +@pytest.mark.parametrize("amount_msat", [50_000_000, 500_000_000]) @flaky(max_runs=10) -def test_trampoline_pay(bitcoind, clients, node_factory): +def test_trampoline_pay(bitcoind, clients, node_factory, amount_msat, scheduler): c1 = clients.new() c1.register() s1 = c1.signer() @@ -85,15 +87,28 @@ def test_trampoline_pay(bitcoind, clients, node_factory): "17b08f669513b7379728fc1abcea5eaf3448bc1eba55a68ca2cd1843409cdc04" ) inv = l2.rpc.invoice( - amount_msat=50000000, + amount_msat=amount_msat, label="trampoline-pay-test", description="trampoline-pay-test", preimage=invoice_preimage, ) l2.rpc.setpaymentkey(invoice_preimage) l2.rpc.setcheckinvoice(inv["bolt11"]) - l2.rpc.setcheckamount(50000000) + l2.rpc.setcheckamount(amount_msat) + if amount_msat == 500_000_000: + req = nodepb.TrampolinePayRequest( + bolt11=inv["bolt11"], + trampoline_node_id=bytes.fromhex(l2.info["id"]), + maxfeepercent=1.0, + ) + with pytest.raises(ValueError, match="(?i)invoice.*declin"): + n1.inner.call("/greenlight.Node/TrampolinePay", req.SerializeToString()) + wait_for(lambda: len(scheduler.debugger.reports) == 1) + assert "payment allowance" in scheduler.debugger.reports.pop().msg + assert not n1.list_peer_channels(bytes.fromhex(l2.info["id"])).channels[0].htlcs + + # A declined preapproval must leave the node able to pay the same invoice. res = n1.trampoline_pay(inv["bolt11"], bytes.fromhex(l2.info["id"])) assert res assert len(res.payment_hash) == 32 # There was a confusion about hex/bytes return. @@ -115,7 +130,7 @@ def test_trampoline_pay(bitcoind, clients, node_factory): ch = n1.list_peer_channels(bytes.fromhex(l2.info["id"])).channels[0] assert res2 == res - assert ch.to_us_msat.msat == (1000000000 - (50000000 + 0.005 * 50000000)) + assert ch.to_us_msat.msat == (1000000000 - (amount_msat + amount_msat * 5 // 1000)) assert len(ch.htlcs) == 0 # new unknown unconnected node without the trampoline featurebit. @@ -171,7 +186,7 @@ def test_trampoline_multi_htlc(bitcoind, clients, node_factory): # Fund first channel n1.fund_channel( bytes.fromhex(l2.info["id"]), - clnpb.AmountOrAll(amount=clnpb.Amount(msat=70000000)), + clnpb.AmountOrAll(amount=clnpb.Amount(msat=700000000)), ) bitcoind.generate_block(6, wait_for_mempool=1) wait_for(lambda: len([c for c in n1.list_funds().channels if c.state == 2]) == 1) @@ -179,7 +194,7 @@ def test_trampoline_multi_htlc(bitcoind, clients, node_factory): # Fund second channel n1.fund_channel( bytes.fromhex(l2.info["id"]), - clnpb.AmountOrAll(amount=clnpb.Amount(msat=30000000)), + clnpb.AmountOrAll(amount=clnpb.Amount(msat=300000000)), ) bitcoind.generate_block(6, wait_for_mempool=1) wait_for(lambda: len([c for c in n1.list_funds().channels if c.state == 2]) == 2) diff --git a/libs/gl-client/CHANGELOG.md b/libs/gl-client/CHANGELOG.md index 48a66c8c5..8cf8fbea1 100644 --- a/libs/gl-client/CHANGELOG.md +++ b/libs/gl-client/CHANGELOG.md @@ -6,6 +6,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## Unreleased +### Added + +- Configure routing and on-chain fee limits with `FeeBudgetLimits` through `SignerConfig::fee_budget_limits`. See [signer fee budgets](../../docs/src/reference/signer-fees.md) for configuration, defaults, and limitations. + +### Changed + +- Enforce a routing-fee allowance per payment, shared across all parts and channels. The default is the larger of 1,000 sat and 0.5% of the payment amount. `pay`, direct-invoice `xpay`, and trampoline requests with fee limits above the allowance are rejected before HTLCs are offered. +- Enforce on-chain fee limits of 100,000 sat per new commitment or signed local HTLC input, and 50,000 sat per mutual close by default. Previously accepted force-close commitments remain signable after limits are lowered. + +### Fixed + +- Fix missing invoice approvals for invoice-backed `sendpay` and direct-invoice `xpay`, including amountless invoices. +- Allow retrying an amountless invoice with a different amount once the previous attempt has no outstanding HTLCs. +- Fix node hangs caused by signer errors during invoice and keysend preapproval. + ## [0.7.0] - 2026-10-12 ### Added diff --git a/libs/gl-client/src/signer/auth.rs b/libs/gl-client/src/signer/auth.rs index 146505ef3..9a1cc6e01 100644 --- a/libs/gl-client/src/signer/auth.rs +++ b/libs/gl-client/src/signer/auth.rs @@ -5,6 +5,24 @@ use lightning_signer::invoice::Invoice; use std::str::FromStr; use vls_protocol_signer::approver::Approval; +fn normalize_invoice(value: &str) -> String { + let normalized = value.to_lowercase(); + normalized + .strip_prefix("lightning:") + .unwrap_or(&normalized) + .to_owned() +} + +pub(super) fn parse_invoice(value: &str) -> anyhow::Result { + Invoice::from_str(&normalize_invoice(value)) + .map_err(|e| anyhow::anyhow!("invalid payment invoice: {e}")) +} + +pub(super) fn same_invoice(wire: &[u8], context: &str) -> bool { + std::str::from_utf8(wire) + .is_ok_and(|wire| normalize_invoice(wire) == normalize_invoice(context)) +} + pub trait Authorizer { fn authorize(&self, requests: &Vec) -> Result, Error>; } @@ -15,23 +33,21 @@ impl Authorizer for GreenlightAuthorizer { fn authorize(&self, requests: &Vec) -> Result, Error> { let mut approvals = Vec::new(); for request in requests.iter() { - match request { - Request::Pay(req) => { - let inv = req.bolt11.clone().to_lowercase(); - let inv = inv.strip_prefix("lightning:").unwrap_or(&inv); - match Invoice::from_str(inv) { - Ok(invoice) => { - approvals.push(Approval::Invoice(invoice)); - } - Err(e) => { - return Err(crate::Error::IllegalArgument(format!( - "Failed to parse invoice from Pay request: {:?}", - e - ))); - } + let invoice = match request { + Request::Pay(req) => Some(req.bolt11.as_str()), + Request::Xpay(req) => Some(req.invstring.as_str()), + Request::SendPay(req) => req.bolt11.as_deref(), + _ => None, + }; + if let Some(invoice) = invoice { + match parse_invoice(invoice) { + Ok(invoice) => approvals.push(Approval::Invoice(invoice)), + Err(e) => { + // One invalid pending command must not reject + // unrelated signing requests in the same context. + log::warn!("Skipping invalid invoice approval: {e:#}"); } } - _ => {} } } Ok(approvals) diff --git a/libs/gl-client/src/signer/fee_budget.rs b/libs/gl-client/src/signer/fee_budget.rs new file mode 100644 index 000000000..b139fb11f --- /dev/null +++ b/libs/gl-client/src/signer/fee_budget.rs @@ -0,0 +1,970 @@ +//! Fee limits for invoice preapproval and on-chain signing. VLS retains +//! payment accounting; routing_policy supplies its amount-dependent allowance. + +use crate::pb::HsmRequestContext; +use anyhow::{anyhow, bail, Context, Result}; +use lightning_signer::bitcoin::Transaction; +use lightning_signer::channel::{Channel, ChannelId, ChannelSlot}; +use lightning_signer::node::Node; +use vls_protocol::model::Htlc; +use vls_protocol::msgs::Message; + +/// Fee ceilings chosen by the signer operator. On-chain caps are independent +/// of policy filters. +/// The routing cap uses VLS's payment-balance policy. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct FeeBudgetLimits { + /// Minimum routing fee allowance per payment hash, in msat. + pub payment_fee_exempt_msat: u64, + /// Percentage routing fee allowance, in parts per million (5,000 = 0.5%). + /// The effective allowance is the larger of this and the exemption. + pub max_payment_fee_ppm: u32, + /// Actual miner fee when accepting a commitment, including trimmed HTLC + /// value. + pub max_commitment_fee_sat: u64, + /// Actual miner fee per cooperative close. Zero permits no miner fee. + pub max_mutual_close_fee_sat: u64, +} + +impl Default for FeeBudgetLimits { + fn default() -> Self { + Self { + payment_fee_exempt_msat: 1_000_000, + max_payment_fee_ppm: Self::DEFAULT_PAYMENT_FEE_PPM, + // Provisional miner-fee ceiling: e.g. 49,600 sat base fee for an anchor + // commitment with five HTLC outputs at 100 sat/vB, plus 50k dust. + max_commitment_fee_sat: 100_000, + // Provisional miner-fee ceiling: e.g. 200 vB at 250 sat/vB. + max_mutual_close_fee_sat: 50_000, + } + } +} + +impl FeeBudgetLimits { + pub const DEFAULT_PAYMENT_FEE_PPM: u32 = 5_000; + + /// Maximum fee for an approved principal, shared by the signer and clients. + pub fn payment_fee_msat(&self, amount_msat: u64) -> Result { + Ok(self.payment_fee_exempt_msat.max(Self::fee_for_ppm_msat( + amount_msat, + self.max_payment_fee_ppm, + )?)) + } + + fn fee_for_ppm_msat(amount_msat: u64, ppm: u32) -> Result { + u64::try_from(u128::from(amount_msat) * u128::from(ppm) / 1_000_000) + .context("routing fee overflow") + } + + /// Normalize the wire percentage to the nearest ppm; truncate fees to msat. + pub fn fee_for_percent_msat(amount_msat: u64, percent: f32) -> Result { + let ppm = (f64::from(percent) * 10_000.0).round(); + if percent < 0.0 || !ppm.is_finite() || ppm > f64::from(u32::MAX) { + bail!("invalid routing fee percentage"); + } + + Self::fee_for_ppm_msat(amount_msat, ppm as u32) + } +} + +fn cln_fee_limit_msat(amount: u64, maxfee: Option, percent: f64, exempt: u64) -> Result { + if let Some(fee) = maxfee { + return Ok(fee); // CLN's absolute limit replaces percentage and exemption defaults. + } + // CLN parses millionths of a percent, then truncates to whole ppm. + let ppm = (percent * 1_000_000.0).round() as u64 / 100; + if percent < 0.0 || !percent.is_finite() || ppm > u64::from(u32::MAX) { + bail!("invalid payment fee percentage"); + } + + Ok(exempt.max(FeeBudgetLimits::fee_for_ppm_msat(amount, ppm as u32)?)) +} + +// Outside permissive authentication fallback: reject unsafe fee limits before +// CLN offers HTLCs, rather than rejecting the commitment that adds them. +pub(super) fn check_payment_preapproval( + message: &Message, + requests: &[super::model::Request], + limits: FeeBudgetLimits, +) -> Result<()> { + use lightning_signer::invoice::InvoiceAttributes; + let Message::PreapproveInvoice(message) = message else { + return Ok(()); + }; + let invoice = super::auth::parse_invoice(std::str::from_utf8(&message.invstring.0)?)?; + let amount = |chosen| match invoice.amount_milli_satoshis() { + 0 => chosen, + amount => amount, + }; + for request in requests { + use super::model::Request; + let matches = |candidate: &str| super::auth::same_invoice(&message.invstring.0, candidate); + let (amount, fee) = match request { + Request::Pay(r) if matches(&r.bolt11) => { + let amount = amount(r.amount_msat.as_ref().map_or(0, |a| a.msat)); + + ( + amount, + cln_fee_limit_msat( + amount, + r.maxfee.as_ref().map(|a| a.msat), + r.maxfeepercent.unwrap_or(0.5), + r.exemptfee.as_ref().map_or(5_000, |a| a.msat), + )?, + ) + } + Request::Xpay(r) if matches(&r.invstring) => { + let amount = amount(r.amount_msat.as_ref().map_or(0, |a| a.msat)); + + ( + amount, + cln_fee_limit_msat( + r.partial_msat.as_ref().map_or(amount, |a| a.msat), + r.maxfee.as_ref().map(|a| a.msat), + 1.0, + 5_000, + )?, + ) + } + Request::TrampolinePay(r) if matches(&r.bolt11) => { + let amount = amount(r.amount_msat); + let percent = if r.maxfeepercent == 0.0 { + FeeBudgetLimits::DEFAULT_PAYMENT_FEE_PPM as f32 / 10_000.0 + } else { + r.maxfeepercent + }; + + ( + amount, + FeeBudgetLimits::fee_for_percent_msat(amount, percent)?, + ) + } + _ => continue, + }; + let cap = limits.payment_fee_msat(amount)?; + if fee > cap { + bail!("requested fee limit {fee} msat exceeds payment allowance {cap} msat"); + } + } + Ok(()) +} + +fn sum(values: impl IntoIterator) -> Result { + values.into_iter().try_fold(0u64, |a, b| { + a.checked_add(b) + .ok_or_else(|| anyhow!("fee accounting overflow")) + }) +} + +fn check_fee(input_sat: u64, output_sat: u64, cap_sat: u64, kind: &str) -> Result<()> { + let fee_sat = input_sat + .checked_sub(output_sat) + .ok_or_else(|| anyhow!("{kind} outputs exceed input"))?; + if fee_sat > cap_sat { + bail!("{kind} fee {fee_sat} sat exceeds hard budget {cap_sat} sat"); + } + Ok(()) +} + +// Count the actual miner fee, including trimmed outputs. The hsmd protocol +// supplies only untrimmed HTLCs, so dust cannot be subtracted reliably here. +fn check_tx(tx: &Transaction, input_sat: u64, cap_sat: u64, kind: &str) -> Result<()> { + check_fee( + input_sat, + sum(tx.output.iter().map(|o| o.value.to_sat()))?, + cap_sat, + kind, + ) +} + +// VLS builds phase-2 outputs directly from these values, including only the +// anchor outputs whose side has a balance or an untrimmed HTLC. Keys and +// commitment numbers do not affect output values and remain VLS's concern. +fn check_commitment( + channel: &Channel, + local_sat: u64, + remote_sat: u64, + htlcs: impl IntoIterator, + cap: u64, +) -> Result<()> { + let mut htlcs = htlcs.into_iter().peekable(); + let has_htlcs = htlcs.peek().is_some(); + let anchors = if channel.setup.features().supports_anchors_zero_fee_htlc_tx() { + 330 * (u64::from(local_sat > 0 || has_htlcs) + u64::from(remote_sat > 0 || has_htlcs)) + } else { + 0 + }; + check_fee( + channel.setup.channel_value_sat, + sum([local_sat, remote_sat, sum(htlcs)?, anchors])?, + cap, + "commitment", + ) +} + +fn check_phase2( + channel: &Channel, + local: u64, + remote: u64, + htlcs: &[Htlc], + cap: u64, +) -> Result<()> { + if htlcs + .iter() + .any(|h| h.side != Htlc::LOCAL && h.side != Htlc::REMOTE) + { + bail!("invalid HTLC side"); + } + check_commitment( + channel, + local, + remote, + htlcs.iter().map(|h| h.amount / 1000), + cap, + ) +} + +fn check_holder_close(channel: &Channel, number: u64, cap: u64) -> Result<()> { + let state = &channel.enforcement_state; + let info = if number == state.next_holder_commit_num { + state.next_holder_commit_info.as_ref().map(|(info, _)| info) + } else if Some(number) == state.next_holder_commit_num.checked_sub(1) { + state.current_holder_commit_info.as_ref() + } else { + None + } + .ok_or_else(|| anyhow!("no validated holder commitment for close number {number}"))?; + if let Err(e) = check_commitment( + channel, + info.to_broadcaster_value_sat, + info.to_countersigner_value_sat, + info.offered_htlcs + .iter() + .chain(&info.received_htlcs) + .map(|h| h.value_sat), + cap, + ) { + // The fee was fixed when this commitment was accepted. Upgrades or + // lower caps must not prevent recovering funds by force-closing it. + log::warn!("Signing previously accepted holder commitment: {e:#}"); + } + Ok(()) +} + +/// Check on-chain fees before VLS can sign or advance commitment state. +/// SignRemoteHtlcTx spends the peer's claim and is deliberately left to VLS. +pub(super) fn check_request( + message: &Message, + context: Option<&HsmRequestContext>, + node: &Node, + limits: FeeBudgetLimits, +) -> Result<()> { + // A valid SegWit HTLC signature commits to the input amount. A node + // supplying a false witness_utxo value cannot turn it into a spendable + // signature. Check the matching output, including SIGHASH_SINGLE anchor + // transactions, rather than letting unrelated inputs hide our fee share. + let htlc = match message { + Message::SignLocalHtlcTx(m) => Some(( + &m.tx.0, + 0, + m.psbt + .0 + .inner + .inputs + .first() + .and_then(|i| i.witness_utxo.as_ref()) + .map(|o| o.value.to_sat()), + )), + Message::SignAnyLocalHtlcTx(m) => Some(( + &m.tx.0, + m.input as usize, + m.psbt + .0 + .inner + .inputs + .get(m.input as usize) + .and_then(|i| i.witness_utxo.as_ref()) + .map(|o| o.value.to_sat()), + )), + Message::SignLocalHtlcTx2(m) => { + Some((&m.tx.0, m.input as usize, Some(m.htlc_amount_msat / 1000))) + } + _ => None, + }; + if let Some((tx, input, amount)) = htlc { + let amount = amount.ok_or_else(|| anyhow!("HTLC fee check requires input amount"))?; + let output = tx + .output + .get(input) + .ok_or_else(|| anyhow!("HTLC fee check requires matching output"))?; + check_fee( + amount, + output.value.to_sat(), + limits.max_commitment_fee_sat, + "HTLC input", + )?; + return Ok(()); + } + let channel_id = match message { + Message::SignCommitmentTx(m) => ChannelId::new_from_peer_id_and_oid(&m.peer_id.0, m.dbid), + Message::SignRemoteCommitmentTx(_) + | Message::SignRemoteCommitmentTx2(_) + | Message::ValidateCommitmentTx(_) + | Message::ValidateCommitmentTx2(_) + | Message::SignLocalCommitmentTx2(_) + | Message::SignMutualCloseTx(_) + | Message::SignMutualCloseTx2(_) => { + let context = context + .filter(|c| c.dbid != 0) + .ok_or_else(|| anyhow!("fee check requires channel context"))?; + let peer: [u8; 33] = context + .node_id + .as_slice() + .try_into() + .context("invalid channel peer id")?; + ChannelId::new_from_peer_id_and_oid(&peer, context.dbid) + } + _ => return Ok(()), + }; + let slot = node.get_channel(&channel_id).map_err(|e| anyhow!("{e}"))?; + let slot = slot.lock().map_err(|_| anyhow!("channel lock poisoned"))?; + let channel = match &*slot { + ChannelSlot::Ready(channel) => channel, + ChannelSlot::Stub(_) => bail!("fee check requires a ready channel"), + }; + let funding_sat = channel.setup.channel_value_sat; + let commitment_cap = limits.max_commitment_fee_sat; + let close_cap = limits.max_mutual_close_fee_sat; + match message { + Message::SignRemoteCommitmentTx(m) => { + check_tx(&m.tx.0, funding_sat, commitment_cap, "commitment") + } + Message::ValidateCommitmentTx(m) => { + check_tx(&m.tx.0, funding_sat, commitment_cap, "commitment") + } + Message::SignRemoteCommitmentTx2(m) => check_phase2( + channel, + m.to_local_value_sat, + m.to_remote_value_sat, + &m.htlcs, + commitment_cap, + ), + Message::ValidateCommitmentTx2(m) => check_phase2( + channel, + m.to_local_value_sat, + m.to_remote_value_sat, + &m.htlcs, + commitment_cap, + ), + Message::SignCommitmentTx(m) if m.tx.0.lock_time.to_consensus_u32() == 0 => { + check_tx(&m.tx.0, funding_sat, close_cap, "mutual close") + } + Message::SignCommitmentTx(m) => { + // VLS signs its stored current or pending holder commitment, + // rather than the transaction supplied in this message. + check_holder_close(channel, m.commitment_number, commitment_cap) + } + Message::SignLocalCommitmentTx2(m) => { + check_holder_close(channel, m.commitment_number, commitment_cap) + } + Message::SignMutualCloseTx(m) => check_tx(&m.tx.0, funding_sat, close_cap, "mutual close"), + Message::SignMutualCloseTx2(m) => check_fee( + funding_sat, + sum([m.to_local_value_sat, m.to_remote_value_sat])?, + close_cap, + "mutual close", + ), + _ => Ok(()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::signer::{auth, model, record_invoice, Signer}; + use lightning_signer::bitcoin::hashes::{sha256, Hash}; + use lightning_signer::bitcoin::secp256k1::{Message as SecpMessage, Secp256k1, SecretKey}; + use lightning_signer::bitcoin::Network; + use lightning_signer::invoice::InvoiceAttributes; + use lightning_signer::lightning::types::payment::{ + PaymentHash, PaymentPreimage, PaymentSecret, + }; + use lightning_signer::policy::validator::CommitmentSignatures; + use lightning_signer::tx::tx::{CommitmentInfo2, HTLCInfo2}; + use std::sync::Arc; + use vls_protocol::serde_bolt::{Octets, WithSize}; + use vls_protocol_signer::handler::Handler; + + fn fixture() -> (Signer, Arc) { + let signer = Signer::new( + vec![1; 32], + Network::Bitcoin, + crate::credentials::Nobody::default(), + ) + .unwrap(); + let node = signer.handler().unwrap().node().clone(); + (signer, node) + } + + fn channel_fixture() -> (Signer, Arc, HsmRequestContext) { + use lightning_signer::bitcoin::{bip32::DerivationPath, OutPoint}; + use lightning_signer::channel::{ChannelSetup, CommitmentType}; + let (signer, node) = fixture(); + let peer = node.get_id().serialize(); + let (id, _) = node.new_channel(1, &peer, &node).unwrap(); + let points = node + .with_channel_base(&id, |c| Ok(c.get_channel_basepoints())) + .unwrap(); + node.setup_channel( + id, + None, + ChannelSetup { + is_outbound: true, + channel_value_sat: 1_000_000, + push_value_msat: 0, + funding_outpoint: OutPoint::null(), + holder_selected_contest_delay: 144, + holder_shutdown_script: None, + counterparty_points: points, + counterparty_selected_contest_delay: 144, + counterparty_shutdown_script: None, + commitment_type: CommitmentType::StaticRemoteKey, + }, + &DerivationPath::master(), + ) + .unwrap(); + ( + signer, + node, + HsmRequestContext { + node_id: peer.to_vec(), + dbid: 1, + capabilities: 0, + }, + ) + } + + fn tx(output_sat: u64) -> Transaction { + use lightning_signer::bitcoin::{ + absolute::LockTime, transaction::Version, Amount, ScriptBuf, TxIn, TxOut, + }; + Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: vec![TxIn::default()], + output: vec![TxOut { + value: Amount::from_sat(output_sat), + script_pubkey: ScriptBuf::new(), + }], + } + } + + fn amountless_payment() -> (model::cln::PayRequest, PaymentHash) { + use lightning_signer::lightning_invoice::{Currency, InvoiceBuilder}; + let invoice = InvoiceBuilder::new(Currency::Bitcoin) + .description("amountless payment".to_string()) + .payment_hash(sha256::Hash::hash(&[3; 32])) + .payment_secret(PaymentSecret([4; 32])) + .current_timestamp() + .min_final_cltv_expiry_delta(18) + .build_signed(|m| { + Secp256k1::new() + .sign_ecdsa_recoverable(m, &SecretKey::from_slice(&[2; 32]).unwrap()) + }) + .unwrap(); + let hash = auth::parse_invoice(&invoice.to_string()) + .unwrap() + .payment_hash(); + ( + model::cln::PayRequest { + bolt11: invoice.to_string(), + amount_msat: Some(model::cln::Amount { msat: 2_000_000 }), + ..Default::default() + }, + hash, + ) + } + + fn commitment_signatures() -> CommitmentSignatures { + CommitmentSignatures( + Secp256k1::new().sign_ecdsa( + &SecpMessage::from_digest([1; 32]), + &SecretKey::from_slice(&[2; 32]).unwrap(), + ), + vec![], + ) + } + + #[test] + fn routing_budget_aggregates_parts_across_channels() { + use lightning_signer::{policy::validator::BalanceDelta, prelude::Map}; + let (signer, node, context) = channel_fixture(); + let id = ChannelId::new_from_peer_id_and_oid(&node.get_id().serialize(), context.dbid); + let other = ChannelId::new_from_peer_id_and_oid(&node.get_id().serialize(), 2); + let (mut request, hash) = amountless_payment(); + request.amount_msat = Some(model::cln::Amount { + msat: 1_000_000_000, + }); + record_invoice( + &model::Request::Pay(request), + &node, + signer.services.persister.as_ref(), + signer.services.clock.now().as_secs(), + ) + .unwrap(); + let validator = signer.services.validator_factory.make_validator( + Network::Bitcoin, + node.get_id(), + Some(id.clone()), + ); + let mut state = node.get_state(); + state + .payments + .get_mut(&hash) + .unwrap() + .outgoing + .insert(other, 500_000); + // A 1,000,000-sat principal gets 5,000 sat once, shared by both channels. + for (part_sat, allowed) in [(505_000, true), (505_001, false)] { + let outgoing = [(hash, part_sat)].into_iter().collect(); + assert_eq!( + state + .validate_payments( + &id, + &Map::new(), + &outgoing, + &BalanceDelta(0, 0), + validator.clone() + ) + .is_ok(), + allowed + ); + } + // Exercise a provided trait method whose default would silently disable + // the stock validator's configured balance enforcement. + let mut policy = lightning_signer::policy::simple_validator::make_default_simple_policy( + Network::Bitcoin, + ); + policy.enforce_balance = true; + let strict = super::super::routing_policy::RoutingValidatorFactory::new( + policy, + FeeBudgetLimits::default(), + ); + use lightning_signer::policy::validator::ValidatorFactory; + assert!(strict + .make_validator(Network::Bitcoin, node.get_id(), None) + .enforce_balance()); + assert!(validator.validate_payment_cltv(100, 99).is_err()); + assert!(validator + .validate_payment_balance(1_000, 1_000, None) + .is_ok()); + assert!(validator + .validate_payment_balance(1_000, 1_001, None) + .is_err()); + assert!(validator + .validate_payment_balance(0, 0, Some(u64::MAX)) + .is_err()); + } + + #[test] + fn trampoline_preapproval_checks_the_configured_amount_dependent_budget() { + let (payment, _) = amountless_payment(); + let message = Message::PreapproveInvoice(vls_protocol::msgs::PreapproveInvoice { + invstring: vls_protocol::serde_bolt::WireString(payment.bolt11.as_bytes().to_vec()), + }); + for (percent, ppm, allowed) in [ + (0.0, 5_000, true), + (0.25, 5_000, true), + (1.0, 5_000, false), + (1.0, 10_000, true), + (f32::NAN, 5_000, false), + (-0.00001, 5_000, false), + ] { + let request = model::Request::TrampolinePay(crate::pb::TrampolinePayRequest { + bolt11: payment.bolt11.clone(), + amount_msat: 1_000_000_000, + maxfeepercent: percent, + ..Default::default() + }); + let limits = FeeBudgetLimits { + max_payment_fee_ppm: ppm, + ..Default::default() + }; + assert_eq!( + check_payment_preapproval(&message, &[request], limits).is_ok(), + allowed + ); + } + let limits = FeeBudgetLimits::default(); + for (amount, fee) in [ + (1, 1_000_000), + (200_000_000, 1_000_000), + (200_200_000, 1_001_000), + (1_000_000_000, 5_000_000), + ] { + assert_eq!(limits.payment_fee_msat(amount).unwrap(), fee); + } + assert!(FeeBudgetLimits { + max_payment_fee_ppm: u32::MAX, + ..limits + } + .payment_fee_msat(u64::MAX) + .is_err()); + } + + #[test] + fn pay_and_xpay_limits_are_checked_before_htlcs() { + let (payment, _) = amountless_payment(); + let message = Message::PreapproveInvoice(vls_protocol::msgs::PreapproveInvoice { + invstring: vls_protocol::serde_bolt::WireString(payment.bolt11.as_bytes().to_vec()), + }); + let amount = 1_000_000_000; + let cap = 5_000_000; + assert_eq!(cln_fee_limit_msat(amount, None, 0.07, 0).unwrap(), 700_000); + for (maxfee, percent, exempt, allowed) in [ + (None, None, None, true), + (Some(cap), None, None, true), + (Some(cap + 1), None, None, false), + (None, Some(1.0), None, false), + (None, None, Some(cap + 1), false), + (None, Some(0.5000001), None, true), + (None, Some(f64::NAN), None, false), + ] { + let request = model::Request::Pay(model::cln::PayRequest { + bolt11: payment.bolt11.clone(), + amount_msat: Some(model::cln::Amount { msat: amount }), + maxfee: maxfee.map(|msat| model::cln::Amount { msat }), + maxfeepercent: percent, + exemptfee: exempt.map(|msat| model::cln::Amount { msat }), + ..Default::default() + }); + assert_eq!( + check_payment_preapproval(&message, &[request], FeeBudgetLimits::default()).is_ok(), + allowed + ); + } + for (amount, partial, maxfee, allowed) in [ + (amount, None, None, false), + (50_000_000, None, None, true), + (amount, Some(amount / 2), None, true), + (amount, None, Some(cap), true), + (amount, None, Some(cap + 1), false), + ] { + let request = model::Request::Xpay(model::cln::XpayRequest { + invstring: payment.bolt11.clone(), + amount_msat: Some(model::cln::Amount { msat: amount }), + partial_msat: partial.map(|msat| model::cln::Amount { msat }), + maxfee: maxfee.map(|msat| model::cln::Amount { msat }), + ..Default::default() + }); + assert_eq!( + check_payment_preapproval(&message, &[request], FeeBudgetLimits::default()).is_ok(), + allowed + ); + } + } + + #[test] + fn amountless_retries_preserve_pending_and_fulfilled_amounts() { + use lightning_signer::node::RoutedPayment; + let (signer, node, context) = channel_fixture(); + let id = + ChannelId::new_from_peer_id_and_oid(&context.node_id.try_into().unwrap(), context.dbid); + let (mut request, hash) = amountless_payment(); + let record = |request: &model::cln::PayRequest| { + record_invoice( + &model::Request::Pay(request.clone()), + &node, + signer.services.persister.as_ref(), + signer.services.clock.now().as_secs(), + ) + }; + record(&request).unwrap(); + assert_eq!(node.get_state().invoices[&hash].amount_msat, 2_000_000); + request.amount_msat = Some(model::cln::Amount { msat: 3_000_000 }); + record(&request).unwrap(); // No HTLCs: retrying with a new amount is allowed. + + for (incoming, outgoing, fulfilled) in [(0, 3_000, false), (3_000, 0, false), (0, 0, true)] + { + let mut payment = RoutedPayment::new(); + payment.apply(&id, incoming, outgoing, None, None); + payment.preimage = fulfilled.then_some(PaymentPreimage([3; 32])); + node.get_state().payments.insert(hash, payment); + record(&request).unwrap(); // Reusing the existing approval does not reset its amount. + let mut retry = request.clone(); + retry.amount_msat = Some(model::cln::Amount { msat: 4_000_000 }); + assert!(record(&retry) + .unwrap_err() + .to_string() + .contains("pending or fulfilled")); + assert_eq!(node.get_state().invoices[&hash].amount_msat, 3_000_000); + assert_eq!(node.get_state().payments[&hash].is_fulfilled(), fulfilled); + } + + node.get_state().payments.remove(&hash); + node.with_channel(&id, |channel| { + channel.enforcement_state.next_holder_commit_info = Some(( + CommitmentInfo2::new( + false, + 0, + 996_000, + vec![HTLCInfo2 { + value_sat: 3_000, + payment_hash: hash, + cltv_expiry: 100, + }], + vec![], + 253, + ), + commitment_signatures(), + )); + Ok(()) + }) + .unwrap(); + request.amount_msat = Some(model::cln::Amount { msat: 4_000_000 }); + assert!(record(&request) + .unwrap_err() + .to_string() + .contains("pending payment")); + assert_eq!(node.get_state().invoices[&hash].amount_msat, 3_000_000); + node.with_channel(&id, |channel| { + channel.enforcement_state.next_holder_commit_info = None; + Ok(()) + }) + .unwrap(); + record(&request).unwrap(); + assert_eq!(node.get_state().invoices[&hash].amount_msat, 4_000_000); + } + + #[tokio::test] + async fn failed_preapprovals_carry_negative_protocol_replies() { + use vls_protocol::msgs::{ + PreapproveInvoice, PreapproveInvoiceReply, PreapproveKeysend, PreapproveKeysendReply, + SerBolt, + }; + let (signer, _) = fixture(); + for (raw, expected) in [ + ( + PreapproveInvoice { + invstring: vls_protocol::serde_bolt::WireString(b"invalid invoice".to_vec()), + } + .as_vec(), + PreapproveInvoiceReply { result: false }.as_vec(), + ), + ( + PreapproveKeysend { + destination: vls_protocol::model::PubKey([0; 33]), + payment_hash: vls_protocol::model::Sha256([0; 32]), + amount_msat: 1_000, + } + .as_vec(), + PreapproveKeysendReply { result: false }.as_vec(), + ), + ] { + let error = signer + .process_request(crate::pb::HsmRequest { + request_id: 42, + raw, + ..Default::default() + }) + .await + .unwrap_err(); + match error { + crate::signer::Error::PreapprovalDeclined { reply, .. } => { + assert_eq!(reply, expected) + } + other => panic!("unexpected error: {other}"), + } + } + } + + #[test] + fn xpay_context_records_the_amountless_principal() { + use prost::Message; + let (signer, node) = fixture(); + let (payment, hash) = amountless_payment(); + let wire = model::cln::XpayRequest { + invstring: payment.bolt11, + amount_msat: payment.amount_msat, + ..Default::default() + } + .encode_to_vec(); + let request = model::cln::decode_request("/cln.Node/Xpay", &wire).unwrap(); + record_invoice( + &request, + &node, + signer.services.persister.as_ref(), + signer.services.clock.now().as_secs(), + ) + .unwrap(); + assert_eq!(node.get_state().invoices[&hash].amount_msat, 2_000_000); + } + + #[test] + fn amountless_sendpay_defaults_to_final_hop_only_for_single_part() { + let (payment, hash) = amountless_payment(); + for (partid, explicit, expected) in [ + (None, None, Some(2_000_000)), + (Some(0), None, Some(2_000_000)), + (None, Some(4_000_000), Some(4_000_000)), + (Some(1), Some(4_000_000), Some(4_000_000)), + (Some(1), None, None), + ] { + let (signer, node) = fixture(); + let request = model::Request::SendPay(model::cln::SendpayRequest { + bolt11: Some(payment.bolt11.clone()), + partid, + amount_msat: explicit.map(|msat| model::cln::Amount { msat }), + route: [3_000_000, 2_000_000] + .into_iter() + .map(|msat| model::cln::SendpayRoute { + amount_msat: Some(model::cln::Amount { msat }), + ..Default::default() + }) + .collect(), + ..Default::default() + }); + let result = record_invoice( + &request, + &node, + signer.services.persister.as_ref(), + signer.services.clock.now().as_secs(), + ); + match expected { + Some(amount) => { + result.unwrap(); + assert_eq!(node.get_state().invoices[&hash].amount_msat, amount); + } + None => { + assert!(result + .unwrap_err() + .to_string() + .contains("authenticated payment amount")); + assert!(!node.get_state().invoices.contains_key(&hash)); + } + } + } + } + + #[test] + fn stored_force_close_remains_signable_above_current_budget() { + let (_, node, context) = channel_fixture(); + let id = ChannelId::new_from_peer_id_and_oid( + &context.node_id.clone().try_into().unwrap(), + context.dbid, + ); + node.with_channel(&id, |channel| { + channel.enforcement_state.next_holder_commit_num = 1; + channel.enforcement_state.current_holder_commit_info = + Some(CommitmentInfo2::new(false, 0, 899_999, vec![], vec![], 253)); + channel.enforcement_state.next_holder_commit_info = Some(( + CommitmentInfo2::new(false, 0, 899_998, vec![], vec![], 253), + commitment_signatures(), + )); + Ok(()) + }) + .unwrap(); + for number in [0, 1] { + let message = + Message::SignLocalCommitmentTx2(vls_protocol::msgs::SignLocalCommitmentTx2 { + commitment_number: number, + }); + check_request(&message, Some(&context), &node, FeeBudgetLimits::default()).unwrap(); + } + } + + #[test] + fn close_disguised_as_sign_commitment_uses_close_cap() { + use lightning_signer::bitcoin::psbt::Psbt; + let (_, node, context) = channel_fixture(); + let tx = tx(949_999); // 50,001 sat: within commitment cap, outside close cap. + let message = Message::SignCommitmentTx(vls_protocol::msgs::SignCommitmentTx { + peer_id: vls_protocol::model::PubKey(context.node_id.try_into().unwrap()), + dbid: context.dbid, + psbt: WithSize(Psbt::from_unsigned_tx(tx.clone()).unwrap().into()), + tx: WithSize(tx), + remote_funding_key: vls_protocol::model::PubKey(node.get_id().serialize()), + commitment_number: 0, + }); + let error = check_request(&message, None, &node, FeeBudgetLimits::default()).unwrap_err(); + assert!(error.to_string().contains("mutual close fee 50001 sat")); + } + + #[test] + fn phase2_fee_matches_vls_outputs_for_each_channel_type() { + use lightning_signer::channel::CommitmentType; + let (_, node, context) = channel_fixture(); + let id = + ChannelId::new_from_peer_id_and_oid(&context.node_id.try_into().unwrap(), context.dbid); + node.with_channel(&id, |channel| { + for kind in [ + CommitmentType::StaticRemoteKey, + CommitmentType::Anchors, + CommitmentType::AnchorsZeroFeeHtlc, + ] { + channel.setup.commitment_type = kind; + for (local, remote) in [(900_000, 0), (0, 900_000), (450_000, 450_000)] { + let tx = channel.make_counterparty_commitment_tx( + &node.get_id(), + 0, + 253, + local, + remote, + vec![], + ); + let fee = channel.setup.channel_value_sat + - tx.trust() + .built_transaction() + .transaction + .output + .iter() + .map(|o| o.value.to_sat()) + .sum::(); + assert!(check_phase2(channel, local, remote, &[], fee).is_ok()); + assert!(check_phase2(channel, local, remote, &[], fee - 1).is_err()); + } + } + Ok(()) + }) + .unwrap(); + } + + #[tokio::test] + async fn hard_cap_is_enforced_at_handler_entry_even_in_permissive_mode() { + use lightning_signer::bitcoin::{psbt::Psbt, Amount, ScriptBuf, TxOut}; + let (signer, _) = fixture(); + let tx = tx(100_000); + let mut psbt = Psbt::from_unsigned_tx(tx.clone()).unwrap(); + psbt.inputs[0].witness_utxo = Some(TxOut { + value: Amount::from_sat(200_001), + script_pubkey: ScriptBuf::new(), + }); + let message = Message::SignAnyLocalHtlcTx(vls_protocol::msgs::SignAnyLocalHtlcTx { + commitment_number: 0, + tx: WithSize(tx), + psbt: WithSize(psbt.into()), + wscript: Octets(vec![]), + option_anchors: false, + input: 0, + peer_id: vls_protocol::model::PubKey( + signer.handler().unwrap().node().get_id().serialize(), + ), + dbid: 1, + }); + let error = signer + .process_request(crate::pb::HsmRequest { + request_id: 1, + context: None, + raw: message.inner().as_vec(), + signer_state: vec![], + requests: vec![], + }) + .await + .unwrap_err(); + assert!( + matches!(error, crate::signer::Error::FeeBudget(_)), + "{error}" + ); + assert!(error.to_string().contains("HTLC input fee 100001 sat")); + } +} diff --git a/libs/gl-client/src/signer/mod.rs b/libs/gl-client/src/signer/mod.rs index 707ada9d9..dfde1dbee 100644 --- a/libs/gl-client/src/signer/mod.rs +++ b/libs/gl-client/src/signer/mod.rs @@ -23,7 +23,8 @@ use lightning_signer::bitcoin::secp256k1::{ ecdsa::Signature as SecpSignature, Message as SecpMessage, PublicKey, Secp256k1, SecretKey, }; use lightning_signer::bitcoin::Network; -use lightning_signer::node::NodeServices; +use lightning_signer::lightning::types::payment::PaymentHash; +use lightning_signer::node::{Node, NodeServices}; use lightning_signer::policy::filter::FilterRule; use lightning_signer::util::crypto_utils; use log::{debug, error, info, trace, warn}; @@ -58,6 +59,9 @@ pub use backup::{ RecoverchannelRequest, RecoverchannelSkippedChannel, SignerBackupConfig, SignerBackupSnapshot, SignerBackupStrategy, }; +mod fee_budget; +mod routing_policy; +pub use fee_budget::FeeBudgetLimits; pub mod model; mod report; mod resolve; @@ -108,6 +112,7 @@ pub struct StateSignatureOverrideConfig { pub struct SignerConfig { pub state_signature_mode: StateSignatureMode, pub state_signature_override: Option, + pub fee_budget_limits: FeeBudgetLimits, #[cfg(feature = "backup")] pub backup: Option, } @@ -132,6 +137,7 @@ pub struct Signer { state_signature_mode: StateSignatureMode, state_signature_override_enabled: bool, state_signature_override_note: Option, + fee_budget_limits: FeeBudgetLimits, master_rune: Rune, services: NodeServices, tls: TlsConfig, @@ -181,6 +187,16 @@ pub enum Error { required_proof: String, }, + #[error("hard fee budget rejected request: {0}")] + FeeBudget(#[source] anyhow::Error), + + #[error("preapproval declined: {cause}")] + PreapprovalDeclined { + #[source] + cause: Box, + reply: Vec, + }, + #[error("error asking node to be upgraded: {0}")] Upgrade(tonic::Status), @@ -211,9 +227,7 @@ impl Signer { where T: TlsConfigProvider, { - use lightning_signer::policy::{ - filter::PolicyFilter, simple_validator::SimpleValidatorFactory, - }; + use lightning_signer::policy::filter::PolicyFilter; use lightning_signer::signer::ClockStartingTimeFactory; use lightning_signer::util::clock::StandardClock; @@ -266,45 +280,34 @@ impl Signer { let mut policy = lightning_signer::policy::simple_validator::make_default_simple_policy(network); - policy.filter = PolicyFilter::default(); - policy.filter.merge(PolicyFilter { - // TODO: Remove once we have fully switched over to zero-fee anchors - rules: vec![ - FilterRule::new_warn("policy-channel-safe-type-anchors"), - FilterRule::new_warn("policy-routing-balanced"), - ], - }); - - // TODO Remove this once VLS has implemented the fee budget - // per payment, rather than the fee budget per HTLC. - // Ref: https://github.com/Blockstream/greenlight/issues/538 - { - policy.max_feerate_per_kw = 150_000; - policy.filter.merge(PolicyFilter { - rules: vec![ - FilterRule::new_warn("policy-commitment-fee-range"), - FilterRule::new_warn("policy-mutual-fee-range"), - ], - }); - } + // Preserve the existing 600 sat/vB ceiling for sweeps and withdrawals, + // including penalty transactions during fee spikes. Commitment and + // mutual-close fees have separate absolute caps in the handler. + policy.max_feerate_per_kw = 150_000; - policy.filter.merge(PolicyFilter { - // TODO: Remove once we have implemented zero invoice support + // Static feerate limits cannot follow chain fee spikes (#538), and + // the percentage limit has no small-payment fee exemption. Keep these + // as diagnostics. VLS enforces the routing balance and absolute routing + // allowance; the handler independently caps on-chain transaction fees. + policy.filter = PolicyFilter { rules: vec![ - FilterRule::new_warn("policy-routing-balanced"), + // TODO: Remove once we have fully switched over to zero-fee anchors + FilterRule::new_warn("policy-channel-safe-type-anchors"), + FilterRule::new_warn("policy-commitment-fee-range"), + FilterRule::new_warn("policy-mutual-fee-range"), FilterRule::new_warn("policy-htlc-fee-range"), ], - }); + }; // Increase the invoices limit. Results in a larger state, but // bumping into this is rather annoying. policy.max_invoices = 10_000usize; - // Relaxed max_routing_fee since we no longer have the - // presplitter which was causing the HTLCs to be smaller. - policy.max_routing_fee_msat = 1_000_000; - - let validator_factory = Arc::new(SimpleValidatorFactory::new_with_policy(policy)); + // VLS aggregates the payment hash across channels and HTLC parts. + let validator_factory = Arc::new(routing_policy::RoutingValidatorFactory::new( + policy, + config.fee_budget_limits, + )); let starting_time_factory = ClockStartingTimeFactory::new(); let clock = Arc::new(StandardClock()); @@ -350,6 +353,7 @@ impl Signer { state_signature_mode, state_signature_override_enabled, state_signature_override_note, + fee_budget_limits: config.fee_budget_limits, master_rune: mr, services, tls: creds.tls_config(), @@ -732,27 +736,36 @@ impl Signer { .map_err(|e| Error::NodeDisconnect(e))?; } Err(e) => { - // Report the rejection to the server for debugging - // Note: process_request already reports some errors, but we ensure - // all errors are reported here as a safety net - report::Reporter::report(crate::pb::scheduler::SignerRejection { + let rejection = crate::pb::scheduler::SignerRejection { msg: format!("Error in run_once loop: {:?}", e), request: Some(req.clone()), git_version: GITHASH.to_string(), node_id: self.node_id(), - }) - .await; - - let response = HsmResponse { - raw: vec![], - request_id, - error: format!("{:?}", e), - signer_state: vec![], + }; + let response = match &e { + Error::PreapprovalDeclined { reply, .. } => HsmResponse { + raw: reply.clone(), + request_id, + ..Default::default() + }, + _ => HsmResponse { + request_id, + error: format!("{e:?}"), + ..Default::default() + }, }; client .respond_hsm_request(response) .await .map_err(|e| Error::NodeDisconnect(e))?; + // CLN has its reply before reporting can wait on the scheduler. + tokio::spawn(async move { + let _ = tokio::time::timeout( + Duration::from_secs(30), + report::Reporter::report(rejection), + ) + .await; + }); warn!( "Rejected request {} with error: {}. State: {:?}", hex_req, e, signer_state, @@ -783,6 +796,35 @@ impl Signer { } async fn process_request(&self, req: HsmRequest) -> Result { + use vls_protocol::msgs::{Message, PreapproveInvoiceReply, PreapproveKeysendReply}; + let msg = vls_protocol::msgs::from_vec(req.raw.clone()).map_err(Error::Protocol)?; + let decline = match &msg { + Message::PreapproveInvoice(_) => { + Some(PreapproveInvoiceReply { result: false }.as_vec()) + } + Message::PreapproveKeysend(_) => { + Some(PreapproveKeysendReply { result: false }.as_vec()) + } + _ => None, + }; + let result = self + .process_request_inner(req, msg, decline.is_some()) + .await; + match (result, decline) { + (Err(cause), Some(reply)) => Err(Error::PreapprovalDeclined { + cause: Box::new(cause), + reply, + }), + (result, _) => result, + } + } + + async fn process_request_inner( + &self, + req: HsmRequest, + msg: vls_protocol::msgs::Message, + preapproval: bool, + ) -> Result { debug!("Processing request {:?}", req); let req = req; @@ -854,18 +896,21 @@ impl Signer { }) .collect::>(); - let msg = vls_protocol::msgs::from_vec(req.raw.clone()).map_err(|e| Error::Protocol(e))?; log::debug!("Handling message {:?}", msg); log::trace!("Signer state {}", prestate_log); if let Err(e) = self.authenticate_request(&msg, &ctxrequests, req.context.as_ref()) { - report::Reporter::report(crate::pb::scheduler::SignerRejection { - msg: e.to_string(), - request: Some(req.clone()), - git_version: GITHASH.to_string(), - node_id: self.node_id(), - }) - .await; + if preapproval { + warn!("Preapproval context did not resolve: {e}"); + } else { + report::Reporter::report(crate::pb::scheduler::SignerRejection { + msg: e.to_string(), + request: Some(req.clone()), + git_version: GITHASH.to_string(), + node_id: self.node_id(), + }) + .await; + } #[cfg(not(feature = "permissive"))] return Err(e); }; @@ -903,23 +948,32 @@ impl Signer { approver.approve(approvals); let root_handler = self.handler_with_approver(approver)?; + fee_budget::check_payment_preapproval(&msg, &ctxrequests, self.fee_budget_limits) + .map_err(Error::FeeBudget)?; + log::trace!("Updating state from context"); - if let Err(e) = update_state_from_context(&ctxrequests, &root_handler) { - error!("Failed to update state from context: {:?}", e); - report::Reporter::report(crate::pb::scheduler::SignerRejection { - msg: format!("Failed to update state from context: {:?}", e), - request: Some(req.clone()), - git_version: GITHASH.to_string(), - node_id: self.node_id(), - }) - .await; - return Err(Error::Other(anyhow!( - "Failed to update state from context: {:?}", - e - ))); + for request in &ctxrequests { + if let Err(e) = record_invoice( + request, + &root_handler.node(), + self.services.persister.as_ref(), + self.services.clock.now().as_secs(), + ) { + warn!("Skipping payment context: {e:#}"); + } } log::trace!("State updated"); + // Do not put this inside authenticate_request's permissive fallback + // or VLS's filterable policy engine. + fee_budget::check_request( + &msg, + req.context.as_ref(), + &root_handler.node(), + self.fee_budget_limits, + ) + .map_err(Error::FeeBudget)?; + // Match over root and client handler. let response = match req.context.clone() { Some(HsmRequestContext { dbid: 0, .. }) | None => { @@ -943,6 +997,9 @@ impl Signer { let response = match response { Ok(r) => r, + Err(e) if preapproval => { + return Err(Error::Other(anyhow!("preapproving payment: {e:?}"))); + } Err(e) => { report::Reporter::report(crate::pb::scheduler::SignerRejection { msg: format!("{:?}", e), @@ -1566,51 +1623,112 @@ impl Signer { } } -/// Look through the context requests and update the state -/// accordingly. This is useful to modify allowlists and invoice lists -/// extracted from the authenticated requests. -fn update_state_from_context( - requests: &Vec, - handler: &handler::RootHandler, -) -> Result<(), Error> { - log::debug!("Updating state from {} context request", requests.len()); - let node = handler.node(); - - requests.iter().for_each(|r| { - if let Err(e) = update_state_from_request(r, &node) { - log::warn!("Failed to update state from request: {:?}", e); +fn record_invoice( + request: &model::Request, + node: &Node, + persister: &dyn lightning_signer::persist::Persist, + now_secs: u64, +) -> Result<()> { + use lightning_signer::invoice::InvoiceAttributes; + + let (invoice_text, requested_amount_msat) = match request { + model::Request::Pay(r) => (r.bolt11.as_str(), r.amount_msat.as_ref().map(|a| a.msat)), + model::Request::Xpay(r) => (r.invstring.as_str(), r.amount_msat.as_ref().map(|a| a.msat)), + model::Request::TrampolinePay(r) => (r.bolt11.as_str(), Some(r.amount_msat)), + model::Request::SendPay(r) => { + let Some(invoice) = r.bolt11.as_deref() else { + return Ok(()); + }; + let amount = match (&r.amount_msat, r.partid) { + (Some(amount), _) => Some(amount), + // CLN defaults single-part payments to the final hop's amount. + (None, None | Some(0)) => r.route.last().and_then(|hop| hop.amount_msat.as_ref()), + (None, Some(_)) => None, + }; + (invoice, amount.map(|a| a.msat)) + } + _ => return Ok(()), + }; + + let invoice = auth::parse_invoice(invoice_text)?; + let payment_hash = invoice.payment_hash(); + let invoice_amount_msat = invoice.amount_milli_satoshis(); + let amountless = invoice_amount_msat == 0; + let approved_invoice = node.get_state().invoices.get(&payment_hash).cloned(); + + if let Some(approved) = &approved_invoice { + if approved.invoice_hash != invoice.invoice_hash() { + anyhow::bail!("different invoice for an already approved payment hash"); + } + if approved.amount_msat != 0 { + if !amountless || requested_amount_msat == Some(approved.amount_msat) { + return Ok(()); // Keep the existing approval without checking expiry again. + } + ensure_amount_can_change(node, &payment_hash)?; } - }); + } + + let amount_msat = if amountless { + requested_amount_msat + .filter(|a| *a > 0) + .ok_or_else(|| anyhow!("amountless invoice requires an authenticated payment amount"))? + } else { + invoice_amount_msat // Overpayments consume the routing allowance. + }; + let mut velocity = node.get_state().velocity_control.clone(); + if amountless && !velocity.insert(now_secs, amount_msat) { + anyhow::bail!("payment amount exceeds signer velocity limit"); + } + if approved_invoice.is_none() && !node.add_invoice(invoice).map_err(|e| anyhow!("{e}"))? { + anyhow::bail!("payment invoice exceeds signer velocity limit"); + } + if !amountless { + return Ok(()); + } + + let mut state = node.get_state(); + let approved = state + .invoices + .get_mut(&payment_hash) + .ok_or_else(|| anyhow!("invoice was not recorded"))?; + approved.amount_msat = amount_msat; + state.velocity_control = velocity; + persister + .update_node(&node.get_id(), &state) + .map_err(|e| anyhow!("persisting payment amount: {e:?}"))?; Ok(()) } -fn update_state_from_request( - request: &model::Request, - node: &lightning_signer::node::Node, -) -> Result<(), Error> { - use lightning_signer::invoice::Invoice; - use std::str::FromStr; - match request { - model::Request::SendPay(model::cln::SendpayRequest { - bolt11: Some(inv), .. - }) => match Invoice::from_str(inv) { - Ok(invoice) => { - log::debug!( - "Adding invoice {:?} as side-effect of this sendpay {:?}", - invoice, - request - ); - if let Err(e) = node.add_invoice(invoice) { - log::warn!("Failed to add invoice to node state: {:?}", e); - } - } - Err(e) => { - log::warn!("Failed to parse invoice from sendpay request: {:?}", e); - } - }, - _ => {} +fn ensure_amount_can_change(node: &Node, payment_hash: &PaymentHash) -> Result<()> { + let amount_is_locked = node + .get_state() + .payments + .get(payment_hash) + .is_some_and(|payment| { + payment.is_fulfilled() || !payment.is_no_incoming() || !payment.is_no_outgoing() + }); + if amount_is_locked { + anyhow::bail!("cannot change the amount of a pending or fulfilled payment"); } + // Holder validation can precede VLS's payment-summary update. + let channels: Vec<_> = node.get_channels().values().cloned().collect(); + for slot in channels { + let slot = slot.lock().map_err(|_| anyhow!("channel lock poisoned"))?; + let lightning_signer::channel::ChannelSlot::Ready(channel) = &*slot else { + continue; + }; + let Some((commitment, _)) = &channel.enforcement_state.next_holder_commit_info else { + continue; + }; + if commitment + .offered_htlcs + .iter() + .any(|htlc| htlc.payment_hash == *payment_hash) + { + anyhow::bail!("cannot change the amount of a pending payment"); + } + } Ok(()) } @@ -1742,6 +1860,7 @@ mod tests { SignerConfig { state_signature_mode: StateSignatureMode::Soft, state_signature_override: None, + fee_budget_limits: FeeBudgetLimits::default(), backup: Some(SignerBackupConfig { path: "backup.json".into(), strategy: SignerBackupStrategy::Periodic { updates: 0 }, diff --git a/libs/gl-client/src/signer/model/cln.rs b/libs/gl-client/src/signer/model/cln.rs index d34abb756..89a701dc7 100644 --- a/libs/gl-client/src/signer/model/cln.rs +++ b/libs/gl-client/src/signer/model/cln.rs @@ -35,6 +35,7 @@ pub fn decode_request(uri: &str, p: &[u8]) -> anyhow::Result { Request::ListTransactions(ListtransactionsRequest::decode(p)?) } "/cln.Node/Pay" => Request::Pay(PayRequest::decode(p)?), + "/cln.Node/Xpay" => Request::Xpay(XpayRequest::decode(p)?), "/cln.Node/ListNodes" => Request::ListNodes(ListnodesRequest::decode(p)?), "/cln.Node/WaitAnyInvoice" => Request::WaitAnyInvoice(WaitanyinvoiceRequest::decode(p)?), "/cln.Node/WaitInvoice" => Request::WaitInvoice(WaitinvoiceRequest::decode(p)?), diff --git a/libs/gl-client/src/signer/model/mod.rs b/libs/gl-client/src/signer/model/mod.rs index e5ad0eb48..dcc22f753 100644 --- a/libs/gl-client/src/signer/model/mod.rs +++ b/libs/gl-client/src/signer/model/mod.rs @@ -33,6 +33,7 @@ pub enum Request { ListSendPays(cln::ListsendpaysRequest), ListTransactions(cln::ListtransactionsRequest), Pay(cln::PayRequest), + Xpay(cln::XpayRequest), PreApproveInvoice(cln::PreapproveinvoiceRequest), ListNodes(cln::ListnodesRequest), WaitAnyInvoice(cln::WaitanyinvoiceRequest), diff --git a/libs/gl-client/src/signer/resolve.rs b/libs/gl-client/src/signer/resolve.rs index eb137bf51..d4fa8c4c0 100644 --- a/libs/gl-client/src/signer/resolve.rs +++ b/libs/gl-client/src/signer/resolve.rs @@ -131,16 +131,23 @@ impl Resolver { true } (Message::PreapproveInvoice(l), Request::Pay(r)) => { - l.invstring.0.eq_ignore_ascii_case(r.bolt11.as_bytes()) + super::auth::same_invoice(&l.invstring.0, &r.bolt11) + } + (Message::PreapproveInvoice(l), Request::SendPay(r)) => r + .bolt11 + .as_ref() + .is_some_and(|invoice| super::auth::same_invoice(&l.invstring.0, invoice)), + (Message::PreapproveInvoice(l), Request::Xpay(r)) => { + super::auth::same_invoice(&l.invstring.0, &r.invstring) } (Message::PreapproveInvoice(l), Request::PreApproveInvoice(r)) => { // Manually calling preapproveinvoice should // always be allowed. The bolt11 string have to // match. - l.invstring.0.eq_ignore_ascii_case(r.bolt11.as_bytes()) + super::auth::same_invoice(&l.invstring.0, &r.bolt11) } (Message::PreapproveInvoice(l), Request::TrampolinePay(r)) => { - l.invstring.0.eq_ignore_ascii_case(r.bolt11.as_bytes()) + super::auth::same_invoice(&l.invstring.0, &r.bolt11) } (_, _) => false, }; @@ -164,6 +171,45 @@ mod tests { SpliceOperation, SplicePolicyDecision, SplicePolicyViolation, VlsProof, }; + #[test] + fn preapproval_matches_invoice_case_and_prefix_for_each_payment_rpc() { + let message = Message::PreapproveInvoice(vls_protocol::msgs::PreapproveInvoice { + invstring: vls_protocol::serde_bolt::WireString(b"lnbc-invoice".to_vec()), + }); + for (invoice, approved) in [ + (Some("LNBC-INVOICE"), true), + (Some("LIGHTNING:LNBC-INVOICE"), true), + (Some("lnbc-other"), false), + (None, false), + ] { + use crate::signer::model::{cln, greenlight}; + for request in [ + Request::Pay(cln::PayRequest { + bolt11: invoice.unwrap_or_default().into(), + ..Default::default() + }), + Request::SendPay(cln::SendpayRequest { + bolt11: invoice.map(str::to_string), + ..Default::default() + }), + Request::Xpay(cln::XpayRequest { + invstring: invoice.unwrap_or_default().into(), + ..Default::default() + }), + Request::TrampolinePay(greenlight::TrampolinePayRequest { + bolt11: invoice.unwrap_or_default().into(), + ..Default::default() + }), + Request::PreApproveInvoice(cln::PreapproveinvoiceRequest { + bolt11: invoice.unwrap_or_default().into(), + }), + ] { + let result = Resolver::try_resolve(&message, &[request], &State::new(), &[], None); + assert_eq!(result.is_ok(), approved, "invoice: {invoice:?}"); + } + } + } + #[test] fn rejected_splice_decision_is_a_policy_error() { let result = Resolver::enforce_splice_decision( diff --git a/libs/gl-client/src/signer/routing_policy.rs b/libs/gl-client/src/signer/routing_policy.rs new file mode 100644 index 000000000..b501663dd --- /dev/null +++ b/libs/gl-client/src/signer/routing_policy.rs @@ -0,0 +1,316 @@ +//! The wrapper adds amount-dependent fee limits while preserving VLS’s payment accounting and avoiding a fork. +//! The tradeoff is delegation code we must maintain when upgrading VLS. +//! We want to move this logic into VLS and remove the wrapper in the future. +//! +//! VLS upgrade checklist: audit this delegation against every Validator method, +//! including provided methods. New trait defaults need not fail compilation and +//! can differ from the stock validator's behavior. + +use super::fee_budget::FeeBudgetLimits; +use lightning_signer::bitcoin::{ + bip32::DerivationPath, + block::Header as BlockHeader, + hash_types::FilterHeader, + secp256k1::{PublicKey, SecretKey}, + sighash::{EcdsaSighashType, SegwitV0Sighash}, + BlockHash, Network, OutPoint, ScriptBuf, Transaction, +}; +use lightning_signer::channel::{ChannelId, ChannelSetup, ChannelSlot}; +use lightning_signer::invoice::Invoice; +use lightning_signer::lightning::{ + ln::chan_utils::{ClosingTransaction, HTLCOutputInCommitment, TxCreationKeys}, + offers::invoice::UnsignedBolt12Invoice, +}; +use lightning_signer::policy::{ + error::ValidationError, + simple_validator::{SimplePolicy, SimpleValidatorFactory}, + validator::{ChainState, CommitmentSignatures, EnforcementState, Validator, ValidatorFactory}, + Policy, +}; +use lightning_signer::signer::vls_channel_signer::VlsChannelSigner; +use lightning_signer::tx::tx::{CommitmentInfo, CommitmentInfo2}; +use lightning_signer::txoo::proof::TxoProof; +use lightning_signer::wallet::Wallet; +use std::{ + sync::{Arc, Mutex}, + time::Duration, +}; + +#[derive(Clone)] +pub(super) struct RoutingValidatorFactory { + policy: SimplePolicy, + limits: FeeBudgetLimits, +} + +impl RoutingValidatorFactory { + pub(super) fn new(mut policy: SimplePolicy, limits: FeeBudgetLimits) -> Self { + policy.max_routing_fee_msat = limits.payment_fee_exempt_msat; + Self { policy, limits } + } + + fn stock( + &self, + network: Network, + node_id: PublicKey, + channel_id: Option, + fee: u64, + ) -> Arc { + let mut policy = self.policy.clone(); + policy.max_routing_fee_msat = fee; + SimpleValidatorFactory::new_with_policy(policy).make_validator(network, node_id, channel_id) + } +} + +impl ValidatorFactory for RoutingValidatorFactory { + fn make_validator( + &self, + network: Network, + node_id: PublicKey, + channel_id: Option, + ) -> Arc { + Arc::new(RoutingValidator { + inner: self.stock( + network, + node_id, + channel_id.clone(), + self.limits.payment_fee_exempt_msat, + ), + factory: self.clone(), + network, + node_id, + channel_id, + }) + } + + fn policy(&self, _network: Network) -> Box { + Box::new(self.policy.clone()) + } +} + +struct RoutingValidator { + inner: Arc, + factory: RoutingValidatorFactory, + network: Network, + node_id: PublicKey, + channel_id: Option, +} + +// Forward every other method, including provided methods: their trait defaults +// can differ from SimpleValidator (notably enforce_balance and CLTV checks). +macro_rules! delegate_to_vls { + ($(fn $name:ident(&self $(, $arg:ident: $ty:ty)* $(,)?) -> $result:ty;)*) => { + $(fn $name(&self, $($arg: $ty),*) -> $result { + self.inner.$name($($arg),*) + })* + }; +} + +impl Validator for RoutingValidator { + fn validate_payment_balance( + &self, + incoming_msat: u64, + outgoing_msat: u64, + invoiced_amount_msat: Option, + ) -> Result<(), ValidationError> { + let Some(amount) = invoiced_amount_msat else { + return self + .inner + .validate_payment_balance(incoming_msat, outgoing_msat, None); + }; + let fee = match self.factory.limits.payment_fee_msat(amount) { + Ok(fee) => fee, + Err(e) => { + return self + .inner + .policy() + .policy_error("policy-routing-balanced".into(), e.to_string()) + } + }; + // VLS adds these u64 values internally. Reject overflow before dispatch. + if incoming_msat + .checked_add(amount) + .and_then(|a| a.checked_add(fee)) + .is_none() + { + return self.inner.policy().policy_error( + "policy-routing-balanced".into(), + "payment balance overflow".into(), + ); + } + // Only this call gets the amount-dependent allowance. Principal, node + // state, filters, and other validators remain untouched. + self.factory + .stock(self.network, self.node_id, self.channel_id.clone(), fee) + .validate_payment_balance(incoming_msat, outgoing_msat, Some(amount)) + } + + delegate_to_vls! { + fn validate_setup_channel( + &self, + wallet: &dyn Wallet, + setup: &ChannelSetup, + holder_shutdown_key_path: &DerivationPath, + ) -> Result<(), ValidationError>; + fn validate_channel_value(&self, setup: &ChannelSetup) -> Result<(), ValidationError>; + fn validate_onchain_tx( + &self, + wallet: &dyn Wallet, + channels: Vec>>>, + tx: &Transaction, + segwit_flags: &[bool], + values_sat: &[u64], + opaths: &[DerivationPath], + weight_lower_bound: usize, + ) -> Result; + fn decode_commitment_tx( + &self, + keys: &VlsChannelSigner, + setup: &ChannelSetup, + is_counterparty: bool, + tx: &Transaction, + output_witscripts: &[Vec], + ) -> Result; + fn validate_counterparty_commitment_tx( + &self, + estate: &EnforcementState, + commit_num: u64, + commitment_point: &PublicKey, + setup: &ChannelSetup, + cstate: &ChainState, + info2: &CommitmentInfo2, + ) -> Result<(), ValidationError>; + fn validate_holder_commitment_tx( + &self, + estate: &EnforcementState, + commit_num: u64, + commitment_point: &PublicKey, + setup: &ChannelSetup, + cstate: &ChainState, + info2: &CommitmentInfo2, + ) -> Result<(), ValidationError>; + fn validate_counterparty_revocation( + &self, + state: &EnforcementState, + revoke_num: u64, + commitment_secret: &SecretKey, + ) -> Result<(), ValidationError>; + fn decode_and_validate_htlc_tx( + &self, + is_counterparty: bool, + setup: &ChannelSetup, + txkeys: &TxCreationKeys, + tx: &Transaction, + redeemscript: &ScriptBuf, + htlc_amount_sat: u64, + output_witscript: &ScriptBuf, + ) -> Result<(u32, HTLCOutputInCommitment, SegwitV0Sighash, EcdsaSighashType), ValidationError>; + fn validate_htlc_tx( + &self, + setup: &ChannelSetup, + cstate: &ChainState, + is_counterparty: bool, + htlc: &HTLCOutputInCommitment, + feerate_per_kw: u32, + ) -> Result<(), ValidationError>; + fn decode_and_validate_mutual_close_tx( + &self, + wallet: &dyn Wallet, + setup: &ChannelSetup, + state: &EnforcementState, + tx: &Transaction, + opaths: &[DerivationPath], + ) -> Result; + fn validate_mutual_close_tx( + &self, + wallet: &dyn Wallet, + setup: &ChannelSetup, + state: &EnforcementState, + to_holder_value_sat: u64, + to_counterparty_value_sat: u64, + holder_shutdown_script: &Option, + counterparty_shutdown_script: &Option, + holder_wallet_path_hint: &DerivationPath, + ) -> Result<(), ValidationError>; + fn validate_delayed_sweep( + &self, + wallet: &dyn Wallet, + setup: &ChannelSetup, + cstate: &ChainState, + tx: &Transaction, + input: usize, + amount_sat: u64, + key_path: &DerivationPath, + ) -> Result<(), ValidationError>; + fn validate_counterparty_htlc_sweep( + &self, + wallet: &dyn Wallet, + setup: &ChannelSetup, + cstate: &ChainState, + tx: &Transaction, + redeemscript: &ScriptBuf, + input: usize, + amount_sat: u64, + key_path: &DerivationPath, + ) -> Result<(), ValidationError>; + fn validate_justice_sweep( + &self, + wallet: &dyn Wallet, + setup: &ChannelSetup, + cstate: &ChainState, + tx: &Transaction, + input: usize, + amount_sat: u64, + key_path: &DerivationPath, + ) -> Result<(), ValidationError>; + fn validate_payment_cltv( + &self, + incoming_cltv: u32, + outgoing_cltv: u32, + ) -> Result<(), ValidationError>; + fn enforce_balance(&self) -> bool; + fn minimum_initial_balance(&self, holder_value_msat: u64) -> u64; + fn is_ready(&self, cstate: &ChainState) -> bool; + fn policy(&self) -> Box<&dyn Policy>; + fn set_next_holder_commit_num( + &self, + estate: &mut EnforcementState, + num: u64, + current_commitment_info: CommitmentInfo2, + counterparty_signatures: CommitmentSignatures, + ) -> Result<(), ValidationError>; + fn get_current_holder_commitment_info( + &self, + estate: &mut EnforcementState, + commitment_number: u64, + ) -> Result; + fn set_next_counterparty_commit_num( + &self, + estate: &mut EnforcementState, + num: u64, + current_point: PublicKey, + current_commitment_info: CommitmentInfo2, + ) -> Result<(), ValidationError>; + fn set_next_counterparty_revoke_num( + &self, + estate: &mut EnforcementState, + num: u64, + ) -> Result<(), ValidationError>; + fn validate_block( + &self, + proof: &TxoProof, + height: u32, + header: &BlockHeader, + external_block_hash: Option<&BlockHash>, + prev_filter_header: &FilterHeader, + outpoint_watches: &[OutPoint], + trusted_oracle_pubkeys: &Vec, + ) -> Result<(), ValidationError>; + fn validate_invoice(&self, invoice: &Invoice, now: Duration) -> Result<(), ValidationError>; + fn validate_bolt12_invoice_unsigned( + &self, + invoice: &UnsignedBolt12Invoice, + now: Duration, + bolt12_pubkey: PublicKey, + ) -> Result<(), ValidationError>; + } +} diff --git a/libs/gl-plugin/CHANGELOG.md b/libs/gl-plugin/CHANGELOG.md index c300fde29..09de43fb1 100644 --- a/libs/gl-plugin/CHANGELOG.md +++ b/libs/gl-plugin/CHANGELOG.md @@ -6,6 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## Unreleased +### Fixed + +- Use checked fee arithmetic for trampoline payments, rejecting invalid percentages and amount overflow. +- Preapprove invoice-backed `sendpay` calls before offering HTLCs, while the signed request context is still available to the signer. + ### Removed - Legacy client-side LSPS2 `htlc_accepted` hook — JIT channel fee handling is now performed natively by CLN diff --git a/libs/gl-plugin/src/node/wrapper.rs b/libs/gl-plugin/src/node/wrapper.rs index 6af681b50..befcf36bd 100644 --- a/libs/gl-plugin/src/node/wrapper.rs +++ b/libs/gl-plugin/src/node/wrapper.rs @@ -211,6 +211,15 @@ impl Node for WrappedNodeServer { &self, r: Request, ) -> Result, Status> { + if let Some(bolt11) = &r.get_ref().bolt11 { + // Record the invoice while the signed SendPay context is still pending, + // before CLN offers HTLCs and returns ahead of commitment signing. + self.inner + .pre_approve_invoice(Request::new(pb::PreapproveinvoiceRequest { + bolt11: bolt11.clone(), + })) + .await?; + } self.inner.send_pay(r).await } diff --git a/libs/gl-plugin/src/tramp.rs b/libs/gl-plugin/src/tramp.rs index 22bd4c45e..e172febfb 100644 --- a/libs/gl-plugin/src/tramp.rs +++ b/libs/gl-plugin/src/tramp.rs @@ -20,7 +20,8 @@ const TRAMPOLINE_FEATURE_BIT: usize = 427; // https://github.com/lightning/bolts/blob/master/04-onion-routing.md#max-htlc-cltv-selection const MAX_DELAY_DEFAULT: u32 = 2016; // The amount we overpay to allow the trampoline node to spend some fees. -const DEFAULT_OVERPAY_PERCENT: f32 = 0.5; +const DEFAULT_OVERPAY_PERCENT: f32 = + gl_client::signer::FeeBudgetLimits::DEFAULT_PAYMENT_FEE_PPM as f32 / 10_000.0; // Type used to address bolt11 in the onion payload. const TLV_BOLT11: u64 = 33001; // Type used to address the amount in msat in the onion payload, in case @@ -388,11 +389,24 @@ pub async fn trampolinepay( // We need to add some sats to the htlcs to allow the trampoline node // to pay fees on routing. let tlv_amount_msat = amount_msat; - let overpay = amount_msat as f64 - * (as_option(req.maxfeepercent).unwrap_or(DEFAULT_OVERPAY_PERCENT) as f64 / 100 as f64); - let amount_msat = amount_msat + overpay as u64; + let overpay = gl_client::signer::FeeBudgetLimits::fee_for_percent_msat( + amount_msat, + as_option(req.maxfeepercent).unwrap_or(DEFAULT_OVERPAY_PERCENT), + ) + .map_err(|e| { + error!( + TrampolineErrorCode::PaymentFailure, + "Invalid trampoline fee: {}", e + ) + })?; + let amount_msat = amount_msat.checked_add(overpay).ok_or_else(|| { + error!( + TrampolineErrorCode::PaymentFailure, + "Trampoline amount plus fee overflows" + ) + })?; - debug!("overpay={}, total_amt={}", overpay as u64, amount_msat); + debug!("overpay={}, total_amt={}", overpay, amount_msat); let channels: Vec = rpc .call_typed(&cln_rpc::model::requests::ListpeerchannelsRequest { id: Some(node_id) }) diff --git a/libs/gl-sdk/CHANGELOG.md b/libs/gl-sdk/CHANGELOG.md index 55d68b47a..1a88cf78f 100644 --- a/libs/gl-sdk/CHANGELOG.md +++ b/libs/gl-sdk/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## Unreleased +### Changed + +- `Node.send` and `Node.lnurl_pay` set CLN's `maxfee` to the larger of 1,000 sat and 0.5% of the payment principal, matching the signer's default routing policy for fixed and amountless invoices. + ## [0.5.0] - 2026-10-12 ### Added diff --git a/libs/gl-sdk/src/node.rs b/libs/gl-sdk/src/node.rs index e97f522b1..92a1fb575 100644 --- a/libs/gl-sdk/src/node.rs +++ b/libs/gl-sdk/src/node.rs @@ -147,25 +147,7 @@ impl Node { pub fn send(&self, invoice: String, amount_msat: Option) -> Result { self.check_connected()?; let mut cln_client = exec(self.get_cln_client())?.clone(); - let req = clnpb::PayRequest { - amount_msat: match amount_msat { - Some(a) => Some(clnpb::Amount { msat: a }), - None => None, - }, - - bolt11: invoice, - description: None, - exclude: vec![], - exemptfee: None, - label: None, - localinvreqid: None, - maxdelay: None, - maxfee: None, - maxfeepercent: None, - partial_msat: None, - retry_for: None, - riskfactor: None, - }; + let req = pay_request(invoice, amount_msat)?; exec(cln_client.pay(req)) .map_err(|e| Error::rpc(e.to_string())) .map(|r| r.into_inner().into()) @@ -1044,10 +1026,7 @@ impl Node { // Phase 2: Pay the invoice let mut cln_client = exec(self.get_cln_client())?.clone(); - let pay_response = match exec(cln_client.pay(clnpb::PayRequest { - bolt11: invoice_str.clone(), - ..Default::default() - })) { + let pay_response = match exec(cln_client.pay(pay_request(invoice_str.clone(), None)?)) { Ok(r) => r.into_inner(), Err(e) => { let payment_hash = invoice_str @@ -1225,6 +1204,31 @@ fn url_action_domain_mismatch(callback_url: &str, action_url: &str) -> Option) -> Result { + let normalized = invoice.to_lowercase(); + let parsed = normalized + .strip_prefix("lightning:") + .unwrap_or(&normalized) + .parse::() + .map_err(|e| Error::argument("invoice", e.to_string()))?; + let principal = parsed + .amount_milli_satoshis() + .filter(|a| *a > 0) + .or(amount_msat) + .filter(|a| *a > 0) + .ok_or_else(|| Error::argument("amount_msat", "missing payment amount"))?; + let maxfee = gl_client::signer::FeeBudgetLimits::default() + .payment_fee_msat(principal) + .map_err(|e| Error::argument("amount_msat", e.to_string()))?; + Ok(clnpb::PayRequest { + bolt11: invoice, + amount_msat: amount_msat.map(|msat| clnpb::Amount { msat }), + maxfee: Some(clnpb::Amount { msat: maxfee }), + ..Default::default() + }) +} + fn validate_lnurl_pay_input(request: &crate::lnurl::LnUrlPayRequest) -> Result<(), Error> { let data = &request.data; if request.amount_msat < data.min_sendable { @@ -2617,6 +2621,40 @@ fn node_event_from_pb(other: glpb::NodeEvent) -> Option { mod tests { use super::*; + #[test] + fn payment_requests_scale_fees_for_fixed_and_amountless_invoices() { + use bitcoin::{ + hashes::{Hash, sha256}, + secp256k1::{Secp256k1, SecretKey}, + }; + use lightning_invoice::{Currency, InvoiceBuilder, PaymentSecret}; + for invoice_amount in [Some(1_000_000_000), None, Some(0)] { + let mut builder = InvoiceBuilder::new(Currency::Bitcoin) + .description("SDK fee budget".to_string()) + .payment_hash(sha256::Hash::hash(&[3; 32])) + .payment_secret(PaymentSecret([4; 32])) + .current_timestamp() + .min_final_cltv_expiry_delta(18); + if let Some(amount) = invoice_amount { + builder = builder.amount_milli_satoshis(amount); + } + let invoice = builder + .build_signed(|m| { + Secp256k1::new() + .sign_ecdsa_recoverable(m, &SecretKey::from_slice(&[2; 32]).unwrap()) + }) + .unwrap(); + let fixed = invoice_amount.is_some_and(|amount| amount > 0); + let amount = if fixed { None } else { Some(1_000_000_000) }; + let request = pay_request(invoice.to_string(), amount).unwrap(); + assert_eq!(request.amount_msat.map(|a| a.msat), amount); + assert_eq!(request.maxfee.unwrap().msat, 5_000_000); + if !fixed { + assert!(pay_request(invoice.to_string(), None).is_err()); + } + } + } + #[test] fn parse_amount_or_all_handles_all_variants() { let all = parse_amount_or_all("all").unwrap(); diff --git a/libs/gl-sdk/tests/test_lnurl.py b/libs/gl-sdk/tests/test_lnurl.py index acd526b3c..ed7bfbf06 100644 --- a/libs/gl-sdk/tests/test_lnurl.py +++ b/libs/gl-sdk/tests/test_lnurl.py @@ -10,10 +10,14 @@ import asyncio +import pytest +from pyln import grpc as clnpb + from gltesting.fixtures import * # noqa: F401, F403 from pyln.testing.utils import wait_for import glsdk +import glclient MNEMONIC = ( @@ -28,7 +32,8 @@ def make_sdk_node(nobody_id, scheduler): """Register a GL node via the SDK and return it with signer running.""" dev_cert = glsdk.DeveloperCert(nobody_id.cert_chain, nobody_id.private_key) config = glsdk.Config().with_developer_cert(dev_cert) - node = glsdk.register(MNEMONIC, None, config) + config = config.with_network(glsdk.Network.REGTEST) + node = glsdk.NodeBuilder(config).register(MNEMONIC, None) return node, config @@ -254,3 +259,44 @@ def test_lnurl_pay_with_message_success_action( assert sa.message == "Thank you for your payment!" finally: sdk_node.disconnect() + + +@pytest.fixture +def funded_sdk_node(scheduler, nobody_id, node_factory, bitcoind, lnurl_service): + relay = fund_and_connect(node_factory, bitcoind, lnurl_service) + node, _ = make_sdk_node(nobody_id, scheduler) + credentials = glclient.Credentials.from_bytes(node.credentials()) + gl_node = glclient.Scheduler(network="regtest", creds=credentials).node() + relay_id = relay.info["id"] + + gl_node.connect_peer(relay_id, f"127.0.0.1:{relay.daemon.port}") + bitcoind.rpc.sendtoaddress(gl_node.new_address().bech32, 0.5) + bitcoind.generate_block(1, wait_for_mempool=1) + wait_for(lambda: len(gl_node.list_funds().outputs) > 0) + gl_node.fund_channel( + bytes.fromhex(relay_id), + clnpb.AmountOrAll(amount=clnpb.Amount(msat=CHANNEL_SATS * 1000)), + ) + bitcoind.generate_block(6, wait_for_mempool=1) + wait_for(lambda: any(ch.state == 2 for ch in gl_node.list_peer_channels().channels)) + + try: + yield node + finally: + node.disconnect() + + +@pytest.mark.parametrize("amountless", [False, True]) +def test_sdk_send(funded_sdk_node, lnurl_service, amountless): + amount_msat = 500_000_000 + recipient = lnurl_service.cln_node + invoice = recipient.rpc.invoice( + "any" if amountless else amount_msat, "sdk-send", "desc" + ) + result = funded_sdk_node.send( + invoice["bolt11"], amount_msat if amountless else None + ) + assert result.status == glsdk.PayStatus.COMPLETE + assert result.amount_msat == amount_msat + assert result.amount_sent_msat - amount_msat <= 2_500_000 + assert recipient.rpc.listinvoices("sdk-send")["invoices"][0]["status"] == "paid" diff --git a/libs/gl-testing/tests/test_gl_node.py b/libs/gl-testing/tests/test_gl_node.py index 2590de86c..19b587d46 100644 --- a/libs/gl-testing/tests/test_gl_node.py +++ b/libs/gl-testing/tests/test_gl_node.py @@ -145,3 +145,49 @@ def test_configure_close_to_addr(node_factory, clients, bitcoind): wait_for(lambda: gl1.list_peer_channels().channels[0].state == 2) assert gl1.list_peer_channels().channels[0].close_to_addr == close_to_addr + + +def test_node_forward(node_factory, clients, bitcoind): + """Forward a payment from sender -> Greenlight -> recipient.""" + sender, recipient = node_factory.get_nodes(2) + c = clients.new() + c.register(configure=True) + gl1 = c.node() + s = c.signer().run_in_thread() + for peer in [sender, recipient]: + gl1.connect_peer(peer.info['id'], f'127.0.0.1:{peer.daemon.port}') + + sender.fundwallet(2_000_000) + sender.rpc.fundchannel(c.node_id.hex(), 1_000_000) + + gl1_address = gl1.new_address().bech32 + bitcoind.rpc.sendtoaddress(gl1_address, 1) + bitcoind.generate_block(1, wait_for_mempool=1) + wait_for(lambda: len(gl1.list_funds().outputs) > 0) + gl1.fund_channel( + bytes.fromhex(recipient.info['id']), + clnpb.AmountOrAll(amount=clnpb.Amount(msat=1_000_000_000)), + ) + bitcoind.generate_block(6, wait_for_mempool=1) + wait_for(lambda: len(gl1.list_peer_channels().channels) == 2 + and all(ch.state == 2 for ch in gl1.list_peer_channels().channels)) + for peer in [sender, recipient]: + wait_for(lambda: peer.rpc.listpeerchannels()['channels'][0]['state'] + == 'CHANNELD_NORMAL') + + incoming = sender.rpc.listpeerchannels()['channels'][0]['short_channel_id'] + # Use Greenlight's alias for its private outgoing channel. + outgoing = recipient.rpc.listpeerchannels()['channels'][0]['alias']['remote'] + inv = recipient.rpc.invoice(100_000_000, 'forward', 'desc') + route = [ + {'id': c.node_id.hex(), 'channel': incoming, + 'amount_msat': 100_010_000, 'delay': 80}, + {'id': recipient.info['id'], 'channel': outgoing, + 'amount_msat': 100_000_000, 'delay': 18}, + ] + sender.rpc.sendpay(route, inv['payment_hash'], bolt11=inv['bolt11'], + payment_secret=inv['payment_secret']) + assert sender.rpc.waitsendpay(inv['payment_hash'], timeout=30)['status'] == 'complete' + assert recipient.rpc.listinvoices('forward')['invoices'][0]['status'] == 'paid' + wait_for(lambda: all(not ch.htlcs for ch in gl1.list_peer_channels().channels)) + s.shutdown() diff --git a/libs/gl-testing/tests/test_node.py b/libs/gl-testing/tests/test_node.py index e4afd0c2d..91dd5047e 100644 --- a/libs/gl-testing/tests/test_node.py +++ b/libs/gl-testing/tests/test_node.py @@ -353,18 +353,12 @@ def test_vls_crash_repro( inv = l1.rpc.invoice(amount_msat=2499000, label="lbl", description="desc") +@pytest.mark.parametrize("parts", [1, 2]) +@pytest.mark.parametrize("amountless", [False, True]) def test_sendpay_signer( - clients: Clients, scheduler: Scheduler, node_factory, bitcoind + clients: Clients, scheduler: Scheduler, node_factory, bitcoind, parts, amountless ) -> None: - """Ensure that `sendpay` works with the signer. - - `sendpay` injects HTLCs outside of the context of a payment, and - as such there is no indication that the HTLC was intended by the - user. By extracting invoices from `sendpay` commands in the - context, we should be able to tell VLS about a payment before it - gets the `commitment_signed` message with a spurious HTLC (from - its PoV). - """ + """Record the invoice before SendPay returns and loses its signed context.""" l1 = node_factory.get_node() c = clients.new() c.register(configure=True) @@ -387,7 +381,7 @@ def test_sendpay_signer( ) amount = 10**9 chan = l1.rpc.listpeerchannels()["channels"][0] - inv = l1.rpc.invoice(amount, "lbl", "desc") + inv = l1.rpc.invoice("any" if amountless else amount, "lbl", "desc") payment_hash = bytes.fromhex(inv["payment_hash"]) payment_secret = bytes.fromhex(inv["payment_secret"]) @@ -395,24 +389,71 @@ def test_sendpay_signer( clnpb.SendpayRoute( id=bytes.fromhex(l1.info["id"]), channel=chan["short_channel_id"], - amount_msat=clnpb.Amount(msat=amount), + amount_msat=clnpb.Amount(msat=amount // parts), delay=18, ), ] - req = clnpb.SendpayRequest( - route=route, - payment_hash=payment_hash, - bolt11=inv["bolt11"], - payment_secret=payment_secret, - ) - - res = clnpb.SendpayResponse.FromString( - bytes(gl1.inner.call("/cln.Node/SendPay", req.SerializeToString())) - ) - - req = clnpb.WaitsendpayRequest(payment_hash=payment_hash) - res = clnpb.WaitsendpayResponse.FromString( - bytes(gl1.inner.call("/cln.Node/WaitSendPay", req.SerializeToString())) + if parts == 1 and not amountless: + for limits in [ + {"maxfee": clnpb.Amount(msat=5_000_001)}, + {"maxfeepercent": 1.0}, + {"exemptfee": clnpb.Amount(msat=5_000_001)}, + ]: + req = clnpb.PayRequest(bolt11=inv["bolt11"], **limits) + with pytest.raises(ValueError, match="(?i)invoice.*declin"): + gl1.inner.call("/cln.Node/Pay", req.SerializeToString()) + wait_for(lambda: len(scheduler.debugger.reports) == 1) + assert "payment allowance" in scheduler.debugger.reports.pop().msg + assert not gl1.list_peer_channels(bytes.fromhex(l1.info["id"])).channels[0].htlcs + + for part in range(1, parts + 1): + req = clnpb.SendpayRequest( + route=route, + payment_hash=payment_hash, + bolt11="LIGHTNING:" + inv["bolt11"].upper() if part == 1 else None, + payment_secret=payment_secret, + amount_msat=clnpb.Amount(msat=amount) if parts > 1 else None, + partid=part if parts > 1 else None, + groupid=1 if parts > 1 else None, + ) + gl1.inner.call("/cln.Node/SendPay", req.SerializeToString()) + + # SendPay has returned and its context is gone before waiting for settlement. + for part in range(1, parts + 1): + req = clnpb.WaitsendpayRequest( + payment_hash=payment_hash, + partid=part if parts > 1 else None, + groupid=1 if parts > 1 else None, + timeout=30, + ) + res = clnpb.WaitsendpayResponse.FromString( + bytes(gl1.inner.call("/cln.Node/WaitSendPay", req.SerializeToString())) + ) + assert res.amount_msat.msat == amount // parts + invoice = l1.rpc.listinvoices("lbl")["invoices"][0] + assert invoice["status"] == "paid" + assert int(invoice["amount_received_msat"]) == amount + + +def test_expired_sendpay_preapproval_does_not_block_node(clients, scheduler): + c = clients.new() + c.register(configure=True) + handle = c.signer().run_in_thread() + node = c.node() + invoice = node.invoice( + clnpb.AmountOrAny(amount=clnpb.Amount(msat=42_000)), + "expired", + "desc", + expiry=0, ) - print(res) + time.sleep(1) + request = clnpb.SendpayRequest(bolt11=invoice.bolt11, payment_hash=invoice.payment_hash) + with pytest.raises(ValueError, match="(?i)invoice.*declin"): + node.inner.call("/cln.Node/SendPay", request.SerializeToString()) + wait_for(lambda: len(scheduler.debugger.reports) == 1) + assert "expired" in scheduler.debugger.reports.pop().msg.lower() + assert node.invoice( + clnpb.AmountOrAny(amount=clnpb.Amount(msat=42_000)), "live", "desc" + ).bolt11 + handle.shutdown()