Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions docs/src/reference/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
119 changes: 119 additions & 0 deletions docs/src/reference/signer-fees.md
Original file line number Diff line number Diff line change
@@ -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).
34 changes: 31 additions & 3 deletions libs/gl-cli/src/signer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -87,6 +87,18 @@ pub enum Command {
backup_strategy: Option<BackupStrategyArg>,
#[arg(long = "backup-periodic-updates")]
backup_periodic_updates: Option<u32>,
/// 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 {
Expand Down Expand Up @@ -119,6 +131,10 @@ pub async fn command_handler<P: AsRef<Path>>(cmd: Command, config: Config<P>) ->
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,
Expand All @@ -128,6 +144,12 @@ pub async fn command_handler<P: AsRef<Path>>(cmd: Command, config: Config<P>) ->
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
}
Expand Down Expand Up @@ -323,6 +345,7 @@ async fn run_handler<P: AsRef<Path>>(
backup_path: Option<PathBuf>,
backup_strategy: Option<BackupStrategyArg>,
backup_periodic_updates: Option<u32>,
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);
Expand Down Expand Up @@ -371,6 +394,7 @@ async fn run_handler<P: AsRef<Path>>(
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)))?;
Expand Down Expand Up @@ -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());
Expand All @@ -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());
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -544,6 +571,7 @@ mod tests {
backup_path,
backup_strategy,
backup_periodic_updates,
..
} => {
assert_eq!(state_signature_mode, StateSignatureModeArg::Hard);
assert_eq!(
Expand Down
27 changes: 21 additions & 6 deletions libs/gl-client-py/tests/test_plugin.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -171,15 +186,15 @@ 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)

# 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)
Expand Down
15 changes: 15 additions & 0 deletions libs/gl-client/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading