Skip to content

Rebase fork onto rust-lightning v0.2.5 - #28

Draft
amackillop wants to merge 147 commits into
lsp-0.2.0from
lsp-0.2.5
Draft

Rebase fork onto rust-lightning v0.2.5#28
amackillop wants to merge 147 commits into
lsp-0.2.0from
lsp-0.2.5

Conversation

@amackillop

@amackillop amackillop commented Aug 7, 2026

Copy link
Copy Markdown

Review vehicle only — do not merge. Downstream consumes this branch by rev pin (ldk-nodelightning-node). Merging into lsp-0.2.0 would only add a pointless merge commit; lsp-0.2.0 stays frozen as the old line.

What

lsp-0.2.5 = our 33 fork commits rebased from upstream v0.2 onto v0.2.5, plus two new commits (an upstream-test fix and the legacy splice-bit restore, below). We were missing all 112 upstream commits from the 0.2.1–0.2.5 patch series.

Security highlights we pick up:

How to review

Don't read the file diff (it includes upstream's 112 commits). Review the rebase itself:

git fetch origin lsp-0.2.0 lsp-0.2.5 && git fetch https://github.com/lightningdevkit/rust-lightning.git --tags
git range-diff v0.2..origin/lsp-0.2.0 v0.2.5..origin/lsp-0.2.5

29/33 commits are byte-identical (=). The interesting entries:

Dropped (2): Strip splice prototype bit when peering with ACINQ + Dual-advertise splice prototype and production feature bits (MDK-799). Upstream 0.2.2 redefined SplicePrototype as bit 63 (== SpliceProduction, lightningdevkit#4387). Both commits applied cleanly on the rebase but with broken semantics: the ACINQ strip's clear_splicing() now clears the production bit, i.e. it would have disabled splicing with ACINQ outright; dual-advertise's extra set_splicing_production_optional() is a no-op. Replaced by the restore commit below.

Modified (4):

  • Make minimum channel reserve configurable (#1): upstream changed get_holder_selected_channel_reserve_satoshis to return Result (err when channel value < minimum) and cap the proportional input at 1M. Kept upstream's shape, threaded our configurable min_their_channel_reserve_satoshis through it, including the Err bound — so the LSP min=0 case can never hit the error path, preserving the reserve-free-channel behavior. Also de-duplicated a stacked import block git auto-merge produced in the channel.rs test module, and ported our test_configurable_min_channel_reserve to the renamed test APIs (contextfunding accessors, TestFeeEstimator::new).
  • Migrate LSPS4 to async/sync persistence: auto-merge adopted upstream's did_persist |= accumulation pattern (from Bypass monitor sync requests when no partition key given in update_monitor_with_chain_data lightningdevkit/rust-lightning#4544-era logging fix) at the LSPS2/5 persist call sites. No LSPS4 logic change.
  • Fix tests for LSPS4 and Logger generic additions (#19) / Gate counterparty-initiated splices behind conf depth (#20): context-only conflicts in the channel.rs test module and splicing_tests.rs (upstream appended its new 0FC reserve-breach tests at the same spot our min-depth test lives). Both sides kept.

New commits (2):

  • Fix upstream lsps5 test for LSPS4 constructor and config additions — upstream 0.2.3's new lsps5_integration_tests construct LiquidityManagerSync directly and predate our extra logger param / lsps4_*_config fields. Same role as old Fix tests for LSPS4 and Logger generic additions #19, applied to the new base.
  • Restore splice negotiation with clients on the legacy prototype bit (d6f87a44d) — correct reimplementation of the two dropped commits on the new base. New SplicePrototypeLegacy feature at bit 155 (unused upstream post-renumber), dual-advertised by default in provided_init_features, accepted at the splice_channel gate (supports_splicing() || supports_splicing_legacy()), ACINQ strip clears the legacy bit only. We must assume bit-155-only clients exist until client version telemetry (MDK-424, CumuloGlobal/lightning-node#824) ships. Safe cross-version: the splice wire protocol is byte-identical between v0.2 and v0.2.5 (msgs.rs/interactivetxs.rs untouched across the patch series) — only the negotiation bit moved. Sunset path in the commit message. Upstream's test_splicing_not_supported_api_error adapted to clear both bits; new unit test covers dual-advertise + strip.

Also verified: LSPS4 does not need the lightningdevkit#4597 persistence-stuck fix — its persist() is a no-op; the SCID/HTLC stores persist immediately per operation.

Test evidence

RUSTFLAGS="--cfg=lsps1_service" cargo test --workspace (the justfile check command) at d6f87a44d: 1509 passed, 0 failed across 31 test binaries.

tankyleo and others added 30 commits December 3, 2025 16:08
Discourage people from running Knots with LDK
`SocketDescriptor::send_data` semantics were changed in 0.2,
without changing the method signature. As such the release notes
really should be explicit about this.
…send-data-relnotes

Note `SocketDescriptor::send_data` semantics changes in renotes
`AttributionData` is a part of the public `UpdateFulfillHTLC` and
`UpdateFailHTLC` messages, but its not actually `pub`. Yet again
re-exports bite us and leave us with a broken public API - we
ended up accidentally sealing `AttributionData`.

Instead, here, we just make `onion_utils` `pub` so that we avoid
making the same mistake in the future.

Note that this still leaves us with arather useless public
`AttributionData` API - it can't be created, updated, or decoded,
it can only be serialized and deserialized, but at least it exists.

Backport of bd57823

Conflicts resolved in:
 * lightning/src/ln/onion_utils.rs

semver-breaking `pub use` removal dropped in:
 * lightning/src/ln/mod.rs
The next backport commit requires this and it was done upstream in
173481f, which we partially
backport here.
In 20877b3 we added a
`debug_assert`ion to validate that if we call
`maybe_free_holding_cell_htlcs` and it doesn't manage to generate
a new commitment (implying `!can_generate_new_commitment()`) that
we don't have any HTLCs to fail, but there was no reason for that,
and its reachable.

Here we simply remove the spurious debug assertion and add a test
that exercises it.

Backport of b524b9b
Previously, `lightning-background-processor`'s `Selector` would poll all
other futures *before* finally polling the sleeper and returning the
`exit` flag if it's ready. This could lead to scenarios where we
infinitely keep processing background events and never respect the
`exit` flag, as long as any of other futures keep being ready.

Here, we instead bias the `Selector` to always *first* poll the sleeper
future, and hence have us act on the `exit` flag immediately if is set.

Backport of 9c0ca26
Electrum's `blockchain.scripthash.get_history` will return the
*confirmed* history for any scripthash, but will then also append any
matching entries from the mempool, with respective `height` fields set
to 0 or -1 (depending on whether all inputs are confirmed or not).

Unfortunately we previously only included a filter for confirmed
`get_history` entries in the watched output case, and forgot to add such
a check also when checking for watched transactions. This would have us
treat the entry as confirmed, then failing on the `get_merkle` step
which of course couldn't prove block inclusion. Here we simply fix this
omission and skip entries that are still unconfirmed (e.g., unconfirmed
funding transactions from 0conf channels).

Signed-off-by: Elias Rohrer <dev@tnull.de>

Backport of cc1eb16
In `ChannelMonitor` logging, we often wrap a logger with
`WithChannelMonitor` to automatically include metadata in our
structured logging. That's great, except having too many logger
wrapping types flying around makes for less compatibility if we
have methods that want to require a wrapped-logger.

Here we change the `WithChannelMonitor` "constructors" to actually
return a `WithContext` instead, making things more consistent.

Backport of 0f253c0
In much of LDK we pass around `Logger` objects both to avoid having
to `Clone` `Logger` `Deref`s (soon to only be `Logger`s) and to
allow us to set context with a wrapper such that any log calls on
that wrapper get additional useful metadata in them.

Sadly, when we added a `Logger` type to `OutboundPayments` we broke
the ability to do the second thing - payment information logged
directly or indirectly via logic in the `OutboundPayments` has no
context making log-searching rather challenging.

Here we fix this by retunring to passing loggers explicitly to
`OutboundPayments` methods that need them, specifically requiring
`WithContext` wrappers to ensure the callsite sets appropriate
context on the logger.

Fixes lightningdevkit#4307

Backport of 5e64c40

Conflicts resolved in:
 * lightning/src/ln/channelmanager.rs
This is really dumb, `assert!(cfg!(fuzzing))` is a perfectly
reasonable thing to write!

Backport of 6ff720b
Backport of 60b5d66

Conflicts resolved in:
 * lightning/src/ln/chanmon_update_fail_tests.rs
We previously assumed background events would eventually be processed
prior to another `ChannelManager` write, so we would immediately remove
all in-flight monitor updates that completed since the last
`ChannelManager` serialization. This isn't always the case, so we now
keep them all around until we're ready to handle them, i.e., when
`process_background_events` is called.

This was discovered while fuzzing `chanmon_consistency_target` on the
main branch with some changes that allow it to connect blocks. It was
triggered by reloading the `ChannelManager` after a monitor update
completion for an outgoing HTLC, calling
`ChannelManager::best_block_updated`, and reloading the `ChannelManager`
once again. A test is included that provides a minimal reproduction of
this case.

Backport of 7e84268
When we shipped 0.2 we used the feature bit 155 to signal splicing,
in line with what eclair was using. However, eclair was actually
using that bit to signal splicing on a previous design which is
incompatible with the current spec.

The result of this was that eclair nodes may attempt to splice
using their protocol and we'd fail to deserialize their splice
message (resulting in a reconnect, which luckily would clear their
splice attempt and return the connection to normal).

As we really need to get off of their feature bit and there's not
much reason to keep using a non-final-spec bit, we simply redefine
`SplicePrototype` to bit 63 here.

Backport of 98c3cff
In debug mode, using SignedAmount::abs can lead to an integer overflow
when used with SignedAmount::MIN. Use SignedAmount::unsigned_abs to
avoid this.

Backport of 2d948fd

Conflicts resolved in:
 * lightning/src/ln/channel.rs
Per the spec clarification in lightning/bolts#1316:
- Writers MUST set offer_amount greater than zero when present
- Readers MUST NOT respond to offers where offer_amount is zero

Reject amount_msats(0) in the builder with InvalidAmount, and reject
parsed offers with amount=0 (with or without currency) during TLV
deserialization.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Backport of a06c446

Conflicts resolved in:
 * lightning/src/offers/offer.rs

Compared to the upstream commit, this instead allows downstream
code to pass a 0 amount but converts it to no-amount to ensure
upgraded readers of the built offer will accept it. This avoids
changing the API in a backport.
…-0.2

[0.2] Reject offer_amount of 0 as invalid per BOLT 12
Previously, we logged "Persisting LiquidityManager..." on each
background processor wakeup, which can be very spammy, even on TRACE
level.

Here, we opt to only log if something actually needed to be repersisted
and we did so (in case of failure we're logging that anyways, too).

Backport of 369ea98
Fernando Ledesma and others added 27 commits August 7, 2026 12:21
When an HTLC is intercepted while the peer is offline, the store
previously persisted to S3 before inserting into the in-memory
HashMap. This created a window where peer_connected() could run,
read an empty HashMap, and find 0 HTLCs to forward -- even though
the HTLC was being stored at that exact moment.

Two fixes:
- htlc_store: Insert into HashMap before S3 persist (with rollback
  on failure) so the HTLC is immediately visible to other threads.
- service: After storing, re-check if the peer reconnected during
  the persist window. If so, process HTLCs immediately rather than
  relying on the webhook path that already missed them.
Production logs show 16 instances over 7 days where peer_connected()
fires and immediately tries to forward stored HTLCs, but the HTLC
silently fails because the channel hasn't completed ChannelReestablish
yet.

The root cause: forward_intercepted_htlc() checks is_usable() (channel
confirmed, not shutting down) which passes. But later, send_htlc()
checks is_peer_disconnected() which fails because clear_peer_disconnected
only happens after channel_reestablish completes - which is AFTER
peer_connected fires.

Timeline of the bug:
1. peer_connected fires (TCP/noise handshake complete)
2. LSPS4 calls forward_intercepted_htlc() on channel manager
3. forward_intercepted_htlc checks is_usable() - passes, returns Ok(())
4. HTLC is queued in forward_htlcs map
5. process_pending_htlc_forwards -> send_htlc() checks
   is_peer_disconnected() - FAILS
6. HTLC is failed back to payer with no LSPS4-level error logged

This commit adds logging at every step of this pipeline to confirm the
theory and quantify the problem:

LSPS4 service (lightning-liquidity):
- peer_connected: log is_usable/is_channel_ready for all channels
- peer_disconnected: log orphaned HTLCs left in store
- channel_ready: log channel state and pending HTLCs
- htlc_intercepted: log connected_peers set size and channel usability
- execute_htlc_actions: log PRE-FORWARD channel state, forward result
- calculate_htlc_actions: log is_usable per channel
- handle_expired_htlcs: log details of expired HTLCs

LDK core (lightning):
- forward_intercepted_htlc: log is_usable vs is_live vs is_connected,
  emit WARNING when is_usable && !is_live (the race condition)
- channel_reestablish: log when clear_peer_disconnected() fires
- send_htlc: log when HTLC is rejected due to peer_disconnected
- process_forward_htlcs: log channel liveness before queue_add_htlc

Grafana queries to confirm the bug after deploying:
- "forward_intercepted_htlc.*is_usable: true.*is_live: false"
- "send_htlc: REJECTING HTLC.*peer_disconnected"
- "peer_connected.*is_usable: false"
The 10s threshold was too tight for the webhook delivery pipeline,
causing premature HTLC expiry and RetriesExhausted failures on
402 payments. 45s gives ample room for webhook round-trips while
still well within CLTV delta bounds.
LDK fires peer_connected after the TCP+Init handshake but before
channel_reestablish completes. During this window, channels exist
but are not yet usable (is_usable=false). The previous code
forwarded HTLCs unconditionally in peer_connected and
htlc_intercepted, causing ~10% of payments to fail on reconnect.

Distinguish two connected-peer cases in htlc_intercepted and
peer_connected:
- Channels exist but none usable (reestablish in progress): defer
  the HTLC for timer-based retry via process_pending_htlcs().
- No channels exist (first payment, JIT open needed): proceed
  immediately so calculate_htlc_actions_for_peer can emit the
  OpenChannel event.

process_pending_htlcs (5s timer) only retries the reestablish
case (channels exist, waiting to become usable). It must not
handle the no-channel case to avoid emitting duplicate
OpenChannel events while a JIT open is already in flight.

Remove the re-check race in htlc_intercepted that could fire
concurrently with peer_connected. The webhook + peer_connected
path is the single owner of the offline-peer reconnect flow.

Blocking inside peer_connected was also considered but rejected:
there is no LDK event for "channel usable after reconnect" to
wake on, so it would require a spin-wait with arbitrary timeout.
A timer-based retry is cleaner and avoids holding the lock.
process_pending_htlcs and htlc_intercepted both call
calculate_htlc_actions_for_peer independently. When both
observe insufficient capacity on an existing channel, both
emit OpenChannel — opening two JIT channels for one payment.

The timer's role is to retry deferred forwards through channels
that became usable after reestablish, not to open new channels.
Restrict it to forwarding only: if the action set contains a
new channel request, skip and let htlc_intercepted,
peer_connected, or channel_ready handle it.

A second race hid behind non-usable channels appearing in the
capacity map. A still-opening channel advertised high
outbound_capacity, so the HTLC was routed into it. The forward
failed (channel still opening), consuming the InterceptId.
channel_ready fired moments later but found an empty store —
the payment hung until the payer retried. Filter the capacity
map to usable channels only.

A third window exists between the usability check and the
actual forward_intercepted_htlc call: the peer can disconnect
in between, wasting the InterceptId on a doomed forward. Add
a peer liveness check immediately before forwarding so the
HTLC survives for retry on the next timer tick. The residual
sub-millisecond window is acceptable; plumbing connection
state into the channel manager's forward path would be far
more invasive for marginal gain.
…blish

When an HTLC arrives for an offline peer, htlc_intercepted stores it
and sends a webhook. When the peer reconnects, peer_connected deferred
all processing if channels existed but weren't usable yet (reestablish
in progress). Later, process_pending_htlcs found insufficient capacity
but assumed a channel open was already in flight - nobody ever opened
the channel.

Fix: call process_htlcs_for_peer even when channels aren't usable.
calculate_htlc_actions skips non-usable channels, so if existing
capacity is insufficient it returns new_channel_needed_msat and
execute_htlc_actions emits OpenChannel. No premature forwarding
occurs since the forwards list is empty (no usable channels).
The actual HTLC forwards happen via channel_ready once the new
channel is established.
Instead of always opening new channels when outbound capacity is
insufficient, prefer splicing into the largest existing usable channel.
Falls back to new channel open when no usable channels exist.

Key changes:
- Add SpliceChannel event variant to LSPS4ServiceEvent
- Add per-peer liquidity_cooldown to prevent 1Hz retry loops
- Modify calculate_htlc_actions_for_peer to prefer splice over new channel
- Add cooldown checks in execute_htlc_actions and process_pending_htlcs
- Clear cooldown on channel_ready (covers both splice-lock and new channel)

Use is_channel_ready instead of is_usable when selecting splice
candidates. This ensures we prefer splice over new channel even
during channel_reestablish (~1s window). splice_channel() will
fail with "pending open/close" if the channel isn't usable yet,
and the timer retries once reestablishment completes.

---------

Co-authored-by: Martin Saposnic <martinsaposnic@gmail.com>
When a splice promotes a new FundingScope, `holder_max_htlc_value_in_flight_msat`
and `counterparty_max_htlc_value_in_flight_msat` stay pinned at their pre-splice
values instead of scaling with the new channel capacity. Any HTLC larger than
the pre-splice cap is rejected:
- on the sender with HTLCMaximum inside send_htlc
- on the receiver with "Remote HTLC add would put them over our max HTLC
  value", which force-closes the channel

Both manifestations break LSPS4 JIT splice flows where the client's embedded
node first opens a small JIT channel, then splices in capacity when a larger
payment arrives. The receiver force-close is particularly damaging because
it destroys the channel along with any pending fees.

Rescale both caps proportionally to the channel-value change on splice
promotion. Also tightens the test_splice_in assertions (the previous check
compared msats to sats).
Channel usability (is_usable) was checked at four separate points:
htlc_intercepted, peer_connected, process_pending_htlcs, and
calculate_htlc_actions_for_peer. Each had its own deferral logic,
and they had to coordinate.

Move the usability check to execute_htlc_actions, right before
forward_intercepted_htlc. If no usable channel exists, the forward
is skipped and the HTLC stays in store for the timer to retry.
htlc_intercepted, peer_connected, and process_pending_htlcs now all
call process_htlcs_for_peer unconditionally.

Change the pre-forward guard from is_peer_connected to
has_usable_channel, which covers the disconnect+reconnect race
where the peer is connected but the channel has not finished
reestablishing.
htlc_intercepted only persisted HTLCs when a liquidity action
(splice/open) was needed. This prevents calculate_htlc_actions
from returning empty actions (i.e. "do nothing, let the timer
handle it") because the HTLC would never make it into the store
and the timer would never see it.

Move the insert before calculate_htlc_actions_for_peer so every
intercepted HTLC is in the store before we decide what to do
with it. execute_htlc_actions already removes the HTLC on
successful forward, so the store entry is short-lived on the
happy path.

The cost is two extra KV store round-trips (S3 write + delete)
per HTLC on the forward-only path, where previously there were
none. This hits every payment since LSPS4 intercepts all of
them. Unfortunately unavoidable: the insert must happen before
action calculation because we don't know yet whether the result
will be "forward now" or "do nothing and defer to the timer."
If we wait until after and the result is "do nothing," we've
already lost the HTLC.

Replace the unconditional unwrap on insert with explicit error
handling. On persistence failure:

- Forward-only: proceed. If the forward succeeds the store
  entry was redundant. If persistence failed and the channel
  becomes unusable between calculate and execute, the HTLC is
  orphaned (not in store for timer retry, not forwarded) until
  LDK's CLTV timeout cleans it up. Requires both S3 failure
  and a channel state change between two adjacent calls.

- Liquidity action needed: fail the HTLC back to sender. The
  splice/open is async and the timer needs the stored HTLC to
  forward once the channel is ready. Without it, we'd spend
  on-chain fees on a splice that never results in a forward.
When the LSP intercepts an HTLC for an offline peer, the peer
reconnects and peer_connected fires before channel_reestablish
completes. All channels report is_usable=false at this point.

calculate_htlc_actions_for_peer builds its capacity map from
is_usable channels (empty set), finds zero capacity, then falls
through to the splice candidate filter. That filter used
is_channel_ready, which matches mid-reestablish channels, so it
emits a splice. Once reestablish finishes,
the HTLC gets forwarded through the existing
channel. The splice was wasted, and the 30s liquidity cooldown
it sets blocks all subsequent liquidity actions for that peer.

Two fixes in calculate_htlc_actions_for_peer:

1. Early return when channels exist but none are usable. The
   capacity map is empty so any decision would be wrong. The
   HTLC stays in the store (persisted by the prior commit) and
   the 1Hz timer retries once channels become usable.

2. Splice candidate filter changed from is_channel_ready to
   is_usable. A mid-reestablish channel maye already have sufficient
   capacity but it's just not visible yet. Splicing into it adds
   unnecessary on-chain cost and a 30s cooldown for capacity
   that was never actually insufficient. splice_channel() would
   also fail if the channel does become usable in time.
LiquidityManager gained an 8th generic parameter (Logger) and
KVStore targets now require KVStoreSync. Several test files and
doctests were not updated after these changes landed.

background-processor: The NO_LIQUIDITY_MANAGER const uses a dyn
trait object for KVStore, but trait objects can only name one
trait. Add a KVStoreFull supertrait combining KVStore and
KVStoreSync so the dyn bound remains expressible. Add the Logger
generic and KVStoreSync impl to the process_events_async
doctest's stub Store type.

lightning-liquidity tests: Add missing lsps4_service_config and
lsps4_client_config fields (None) to config initializers in
lsps0, lsps2, and lsps5 integration tests. Pass logger to
LiquidityManagerSync::new calls and add TestLogger to type
annotations.

channel.rs tests: Remove duplicate import block left over from
a bad merge in 736d3a8. Fix field accesses that moved from
ChannelContext to FundingScope (channel_value_satoshis,
holder_selected_channel_reserve_satoshis). Update
TestFeeEstimator construction to use the new constructor API.
On 0-conf channels, splice_locked is sent immediately because the
channel's minimum_depth is 0. This is fine when the LSP initiates
the splice (it constructed the funding output), but when the
counterparty initiates a splice, the LSP trusts a funding output
it didn't build. The counterparty can double-spend the splice tx
after the LSP has already sent splice_locked.

Add ChannelHandshakeConfig::splice_minimum_depth which, when set,
overrides the per-FundingScope minimum_depth for splices where
is_initiator is false. The override is applied in
on_tx_signatures_exchange before the funding is pushed to
negotiated_candidates, so check_funding_meets_minimum_depth sees
the configured depth instead of the channel's 0-conf minimum.

Self-initiated splices are unaffected: the LSP constructed the
funding output and the existing 0-conf behavior is correct.
Introduce a Nix dev shell and a justfile so work on this fork has a
reproducible toolchain and a one-command local gate, mirroring the setup
in the ldk-node repo.

The toolchain is pinned to stable 1.90.0 via fenix rather than tracking
latest. The version is bounded on both ends: clippy must be at least
1.87 so ci/check-lint.sh can resolve the lint names it allows (older
clippy hard-errors on the unknown clippy::manual_is_multiple_of), but
1.92+ adds clippy::assertions_on_constants which fires on existing code
in lightning/src/chain/channelmonitor.rs and would break -D warnings on
the inherited tree. 1.90.0 sits in that window and matches what CI's
stable resolves to today. fenix is used over rust-overlay because it
pins an exact channel by hash, so the shell can't drift.

just check only compiles and runs the workspace test suite. It
deliberately skips clippy and rustfmt: this branch is not clean under
either of CI's gates (pre-existing dead code in lsps2/lsps4 trips
check-lint.sh's -D warnings, and the tree predates the pinned rustfmt),
and reformatting or de-linting code we don't own is out of scope.
Introduce the fee_policy module: a FeePolicy/FeeTier ADT and a single
pure resolve_skim mapping a policy plus an HTLC amount to the msat to
skim. Shared foundation for upcoming work that waives the JIT-channel
skim for grant recipients; nothing wires it up yet.

When the skim would eat the whole HTLC, resolve_skim waives it rather
than clamping to amount - 1: a residual that small may fall below
htlc_minimum_msat and be rejected anyway, so clamping would bank a fee
on a payment that never settles. This only triggers for a rate >= 100%
or an outsized Custom base; the standard 2% never reaches it.

This is also where resolve_skim deliberately diverges from the code it
replaces. The service computed the proportional fee in u64, so
amount * ppm overflowed for very large HTLCs and skimmed nothing,
forwarding the whole HTLC for free. resolve_skim multiplies in u128 and
skims correctly. At the standard 2% the result is identical for any
realistic HTLC; only the previously-overflowing range changes, from
"free" to "charged". Tests pin the standard tier against the legacy
computation for non-overflow sizes and document the large-HTLC case.

The other arms are encoding-only. ZeroFee resolves to zero; Custom
{ ppm, base_msat } adds a flat base to the proportional component.
Custom is kept though v1 never constructs it: it's free in the TLV and
documents the shape of an explicit rate.

Both enums serialize via impl_writeable_tlv_based_enum! with reserved
type bytes (FeeTier: Standard=0, ZeroFee=2, Custom=4; FeePolicy: Flat=0)
so future variants are additive. Flat is a length-prefixed tuple variant
wrapping FeeTier, letting a later commit store a policy as a TLV field
that defaults cleanly on old records.
Persist a FeePolicy per intercept SCID record so a later milestone can
look up a peer's policy at the skim site. ScidWithPeer::new still hands
every record Flat(Standard), so nothing changes yet.

The field uses a length-delimited TLV with a default_value of
Flat(Standard) at type 4. Records written before this field existed
carry only types 0 and 2, so they read back as Standard with no
migration. A test encodes a copy of the old two-field layout and decodes
it through the new struct to pin that default.
Replace the inline compute_forward_fee block in
calculate_htlc_actions_for_peer with a single resolve_skim call against a
literal Flat(Standard) policy. This is the one place the LSP decides what
to skim; routing it through the pure function is what later milestones
need to swap the literal for a per-peer policy lookup.

Not a strict no-op: it inherits the u128 overflow fix from resolve_skim,
so a >9223-BTC HTLC is now skimmed 2% instead of overflowing and
forwarding free. Every realistic HTLC is unchanged. The peer's stored
policy is still ignored; every forward resolves Flat(Standard) until the
lookup lands.

The two old log lines (overflow, skim-ate-the-HTLC) collapse into one:
resolve_skim can't overflow, so a zero skim on a non-zero HTLC can only
mean the fee would have eaten the whole amount.
MDK-980 lets a registering node carry a signed grant for a
non-standard FeePolicy that the LSP verifies locally before honouring.
This is just the pure core: the claim ADT and the verifier. Nothing
calls it yet, so behaviour is unchanged.

A claim is a versioned TLV pair. ClaimPayload binds {scheme, node_id,
policy}; SignedFeeClaim wraps the encoded payload as an opaque byte
string plus a detached BIP340 signature over SHA256 of those bytes.
Keeping the payload opaque means the verifier hashes exactly what was
signed and never reproduces the payload's TLV layout to check the
signature. TLV rather than a fixed concatenation keeps later schemes
(more policy arms, an issuer key-id) additive: a new tag, not a re-issue.

verify_claim takes a slice of issuer keys, not a single Option. An empty
slice rejects every claim, which is how the feature stays inert until a
key is configured. A non-empty slice accepts a signature from any one of
the keys. That covers a no-key-id claim today and key rotation later,
and per-key scoping can slot in without a breaking config change.

The verifier borrows a caller-supplied secp context rather than building
its own, so the verifier stays allocation free and context lifetime is
the caller's call. The handler wiring that follows will own a long-lived
verify context on the service and hand it in. The scheme byte is read
and matched before the signature check because it selects which
verification rules apply, so an unknown scheme is rejected before any
crypto runs.

The wire format is the contract MDK-981 (ldk-node) and MDK-982 (the TS
minter) must reproduce byte-for-byte, so an in-tree test vector pins a
known issuer key and claim hex. Drift in the TLV layout or the signing
input fails that test instead of silently diverging across repos. The
signing helper is test-only; the verifier does no I/O.
Adds an optional fee_claim string to RegisterNodeRequest so a node can
present a signed grant when it registers. The field is hex of the
SignedFeeClaim bytes from the previous commit. Nothing reads it yet; the
service-side verify and persist land later.

serde(default, skip_serializing_if = "Option::is_none") keeps the wire
backward compatible both ways. An old client that sends {} still decodes
to fee_claim: None through the existing params.unwrap_or(json!({})) path,
and a node with no claim emits {} rather than an explicit null, so the
request bytes match today exactly when no claim is set.

The client constructs the request with fee_claim: None; only a node that
has been granted a claim populates it, which is wired up in a later
change.
The persisted ScidWithPeer already carries a policy field, but the store
only built peer<->scid lookups, so the granted policy was written to disk
and then lost in memory. This adds a policy_by_peer map kept in lockstep
with the existing two: populated on load from the decoded records, upserted
on insert, dropped on remove. A get_policy accessor reads it back.

ScidWithPeer::new now takes the policy explicitly rather than defaulting it,
so there is one way to build the record and the policy is always named at
the construction site. add_intercepted_scid passes Flat(Standard), the path
a peer with no claim takes; the register handler will pass a verified grant.
The on-disk default is unchanged: it comes from the TLV default_value, not
the constructor. get_policy warns as dead code until the handler reads it.

Keeping policy in its own map rather than handing out whole ScidWithPeer
records keeps the skim site's read to a single lookup by node id, which is
all it needs.
Wires the claim verifier into register_node. The service now holds a set of
trusted issuer keys and a long-lived verification context; when a peer
registers, any fee_claim it presented is verified against those keys and the
granted policy is persisted onto the peer's SCID record before the response
is enqueued, so the policy is in place by the time the SCID is handed out.

The feature is inert by default. An empty issuer_pubkeys set short-circuits
before any crypto and resolves every peer to the standard policy, byte for
byte identical to today. Population of the key set is the operator's job
downstream; nothing in-tree sets it.

A grant is only ever upserted, never downgraded. An absent or unverifiable
claim returns None and leaves an existing record untouched, so a transient
miss or a malformed claim can't wipe a live grant; a brand-new record falls
back to standard. This deviates from a literal "else standard" on purpose:
once a node has been granted zero-fee, a dropped claim on a later
re-registration must not silently restore the 2% skim.

The verifier borrows the service's context rather than allocating per call,
and resolve_claim_policy logs and swallows verification failures rather than
failing the registration: a bad claim should cost the node its discount, not
its channel.

add_intercepted_scid is removed. It only built a standard-policy record,
which the new persist path now does directly with the resolved policy, so
the old helper had no remaining caller.
The forwarding skim now reads the peer's granted policy from the SCID store
instead of hard-coding Flat(Standard). A peer with no grant resolves to
Standard, so the skim is byte-for-byte the historical 2% for everyone the
issuer set has not waived. This is the read side that makes the persisted
grant actually take effect; before it, a verified ZeroFee was stored and
then ignored.

The lookup is hoisted out of the per-HTLC loop because the policy is keyed
by peer, not by HTLC, so it is one store read per batch rather than one per
forward.

A zero skim is now two distinct cases, so the log is split. ZeroFee is the
expected path and logs at info. Any other tier skimming nothing means the
fee would have consumed the whole HTLC, which keeps the error-level log:
a forward of the full amount that we expected to take a cut on. Previously
ZeroFee was unreachable, so the single error log was correct; now that a
grant can legitimately waive the fee, the error would be noise.
MDK-980 added the fee_claim field to RegisterNodeRequest and the verifier
that reads it, but the client still hardcoded None, so no node could ever
present a claim. This lets the caller pass one.

The claim rides as a per-call argument rather than a field on
LSPS4ClientConfig: that config derives Copy, which an Option<String> would
break, and the value belongs to the layer above (ldk-node), which already
holds it and relays it on every registration. There is a single call site,
so threading it as a parameter is the smaller, honest change.

The value is opaque here: a lowercase-hex signed grant the LSP alone decodes
and verifies. Passing None reproduces today's behavior exactly, so nothing
changes until a node is configured with a claim.
Upstream 0.2.3 added lsps5_integration_tests cases that construct
LiquidityManagerSync directly. Our LSPS4 work extends the liquidity
config structs and appends a logger parameter to the constructor, so
the new upstream call sites no longer compile. Same role as the
earlier 'Fix tests for LSPS4 and Logger generic additions' commit,
applied to the v0.2.5 base.
@amackillop amackillop changed the title Rebase fork onto rust-lightning v0.2.5 (security fixes) Rebase fork onto rust-lightning v0.2.5 Aug 7, 2026
The v0.2.5 rebase dropped the MDK-799 dual-advertise commits because
upstream 0.2.2 renumbered SplicePrototype to bit 63, turning their
clear_splicing() ACINQ carve-out into a production-bit strip. That left
bit-155-only clients unable to splice: we neither advertised a bit they
recognize nor recognized the bit they advertise, so LSPS4 would fall
back to opening a second channel on every liquidity top-up.

We have to assume such clients exist: client version telemetry (MDK-424,
lightning-node#824) has not shipped, and the legacy e2e jobs cannot
distinguish a splice from the channel-open fallback by design.

Reintroduce the bit under a new SplicePrototypeLegacy feature (155 is
unused upstream now) and accept it at the splice_channel gate. The wire
protocol is unchanged between v0.2 and v0.2.5 (msgs.rs and
interactivetxs.rs are byte-identical across the patch series), so a
0.2.5 LSP can splice with a v0.2-fork client; only the negotiation bit
moved. The ACINQ carve-out returns unchanged: eclair rejects an Init
carrying both 155 (their pre-standard splice bit) and 63.

Sunset: once lightningdevkit#824 ships and shows no bit-155 clients, drop the
define_feature block, the set_splicing_legacy_optional() call, the
strip, and the gate's legacy arm.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.