Compare commits

...

137 commits

Author SHA1 Message Date
ziggieXXX
c863397a4f
Merge pull request #10953 from ziggie1984/bump-v0.20.2-final
build: bump version to v0.20.2
2026-07-07 20:50:06 -03:00
ziggie
5e26b56067
build: bump version to v0.20.2 2026-07-07 07:08:59 -03:00
ziggieXXX
529bdb1a8b
Merge pull request #10935 from ziggie1984/bump-v0.20.2
build: bump version to 0.20.2-beta.rc1
2026-06-29 10:48:35 -03:00
ziggie
f4fa341e02
build: bump version to 0.20.2-beta.rc1 2026-06-29 07:51:35 -03:00
ziggieXXX
bf8df0a144
Merge pull request #10933 from lightningnetwork/backport-10895-to-v0.20.x-branch
[v0.20.x-branch] Backport #10895: htlcswitch: separate onchain and offchain intercpeted HTLCs cleanly
2026-06-27 14:14:31 -03:00
ziggie
cf20de9bfb
docs: add v0.20.2 release note
(cherry picked from commit 9c5f32a2ec)
2026-06-26 14:29:35 -03:00
ziggie
bc8463a98f
routerrpc: add clarifying docs for the intercepted forward
routerrpc: document on-chain interceptor responses
(cherry picked from commit 8909c2fbf5)
2026-06-26 14:29:23 -03:00
ziggie
7a42b56cbf
witnessbeacon: avoid interceptor deadlock
Release the preimage beacon lock before invoking the on-chain
interceptor. The interceptor path can block on the htlcswitch event
loop, while resolution of another held on-chain HTLC can call back
into the beacon to add a preimage.

If interceptor delivery fails after the subscriber was registered,
cancel the subscription before returning the error.

On-chain held entries are replay handles for the interceptor while
contractcourt waits for a preimage or on-chain expiry. Once the resolver
tears down, keeping the handle until the refund timeout can replay a stale
HTLC to a reconnecting interceptor.

Thread a dedicated cleanup signal from the witness subscription cancel path
back through the interceptable switch event loop. The held set only removes
on-chain entries for that signal, leaving off-chain entries under the link
flow lifecycle.

(cherry picked from commit 98da7b4a56)
2026-06-26 14:29:23 -03:00
ziggie
b32e432e0b
htlcswitch: track held HTLC source
Store held forwards as off-chain or on-chain entries instead of a raw
InterceptedForward map. Off-chain entries keep the existing resume, fail,
settle and auto-fail behavior. On-chain entries are settle-only and
expire by pruning local interceptor state.

When contractcourt re-offers a circuit that is already held off-chain,
replace the stored entry with the on-chain forward so a later SETTLE
reaches the witness beacon instead of the old link mailbox path.

Also set the on-chain interceptor deadline to the HTLC refund timeout.
This keeps the public interceptor deadline populated while ensuring only
off-chain held entries use that value to fail back.

Only off-chain held HTLCs can be released when an optional interceptor
disconnects, because they can resume into the link forwarding flow.

On-chain held HTLCs have no link flow to resume. Keep them in the held
set so a reconnecting interceptor can replay and settle them while
contractcourt waits for the preimage or on-chain expiry.

Use distinct internal deadline types for off-chain auto-fail heights and
on-chain settlement deadlines instead of overloading the intercepted packet
field.

Project both variants back into the existing router RPC auto_fail_height
field to preserve wire compatibility. Reject mismatched held HTLC deadline
types in tests.

On-chain intercepted HTLCs can only be settled. Resume and fail actions
already return concrete errors through the on-chain intercepted forward, so
let those errors propagate to the interceptor client instead of converting
them to success.

Keep the held entry tracked on these errors so the client can reconnect and
settle the HTLC later.

(cherry picked from commit eb1193f80b)
2026-06-26 14:29:23 -03:00
ziggie
de8ca73610
itest: cover on-chain interceptor settlement
Add coverage for held forwards that move on chain after the
incoming channel force closes.

The restart case exercises the path where Bob loses the in-memory
held set and contractcourt re-offers the HTLC through the witness
beacon. The no-restart case keeps the original off-chain hold and
proves that settlement must still reach the on-chain resolver.

(cherry picked from commit 9b31ba83ef)
2026-06-26 14:29:22 -03:00
Yong
77989b955d
Merge pull request #10929 from lightningnetwork/backport-10927-to-v0.20.x-branch
[v0.20.x-branch] Backport #10927: htlcswitch+invoices: tighten final-hop CLTV validation
2026-06-26 22:35:22 +08:00
ziggie
b9619a28cd
docs: update release notes
(cherry picked from commit bffae65a60)
2026-06-26 07:58:48 -03:00
ziggie
30ddd606fc
htlcswitch: fix outgoing cltv spelling
Rename the ForwardingInfo.OutgoingCTLV field to OutgoingCLTV and update all
call sites. This keeps the exported field spelling consistent with the CLTV
terminology used elsewhere.

Also fix the remaining CTLV typos in nearby comments.

(cherry picked from commit 4518bc7276)
2026-06-26 07:58:48 -03:00
ziggie
1c82b7a27d
contractcourt: align final htlc handling in contest resolver
Mirror the link's final-hop HTLC checks in the incoming contest resolver so
the off-chain link path and on-chain resolver use the same final-hop handling.

Use MaxFinalCltvDelta directly in contractcourt to match invoice creation and
link processing.

Preserve the link's custom HTLC behavior by leaving amount checks to auxiliary
traffic shapers when custom HTLC handling applies.

(cherry picked from commit 03ca45a282)
2026-06-26 07:58:48 -03:00
ziggie
141acda471
config: check cltv expiry policy range
Check configured and advertised forwarding CLTV deltas against max-cltv-expiry
so local configuration and advertised channel policy stay within the same
supported range.

(cherry picked from commit b8e861fe6b)
2026-06-26 07:58:47 -03:00
ziggie
6968e2bdab htlcswitch+invoices: align final-hop CLTV handling
Apply the same supported CLTV delta range to final-hop HTLC handling that is
already used for forwarding.

Use a shared helper for the exit-hop link path so final-hop amount and CLTV
checks remain consistent across invoice creation and HTLC handling.

(cherry picked from commit c5992d381f)
2026-06-26 10:15:18 +00:00
ziggieXXX
3061190445
Merge pull request #10924 from lightningnetwork/backport-10914-to-v0.20.x-branch
[v0.20.x-branch] Backport #10914: discovery: fix panic in DNS fallback SRV lookup
2026-06-25 18:30:24 -03:00
ziggieXXX
0b20b0aab7
Merge pull request #10925 from lightningnetwork/backport-10904-to-v0.20.x-branch
[v0.20.x-branch] Backport #10904: rpcperms: recover RPC handler panics
2026-06-25 17:09:20 -03:00
Erick Cestari
010f72681b
docs: add release note for 0.20.2
(cherry picked from commit 2ee49698af)
2026-06-25 14:26:48 -03:00
Erick Cestari
2c4af78fe1
discovery: fix panic in DNS fallback SRV lookup
The fallback SRV lookup type-asserted each DNS Answer record to *dns.SRV
unconditionally. If the response contains a non-SRV record (e.g. an A or
CNAME), the type assertion panics and crashes the daemon. Use the
comma-ok form to skip non-SRV records instead.

Also guard against an empty LookupHost result for the shim, which would
otherwise panic on an out-of-bounds index into addrs.

This is safe to discuss and fix in public. The bug is very unlikely to be
exploitable: triggering it requires either a DNS seeder to serve a
malformed response, or an on-path MITM injecting one (the fallback
response is unauthenticated). A malicious seeder already has far more
direct ways to disrupt a node, and a MITM attack is hard to mount, so the
panic does not meaningfully widen the attack surface.

(cherry picked from commit 2a3642c691)
2026-06-25 14:21:43 -03:00
yyforyongyu
0b04e339a4
rpcperms: recover RPC handler panics
(cherry picked from commit 4bbfcab910)
2026-06-25 13:36:43 -03:00
ziggieXXX
848b72ce96
Merge pull request #10551 from lightningnetwork/v-0-20-1-final
build: bump version to v0.20.1
2026-02-10 07:42:54 +01:00
Olaoluwa Osuntokun
f9035f74f5 build: bump version to v0.20.1 2026-02-09 18:00:34 -08:00
Yong
a4f375ffdd
Merge pull request #10554 from lightningnetwork/backport-10540-to-v0.20.x-branch
[v0.20.x-branch] Backport #10540: discovery: fix gossiper shutdown deadlock
2026-02-06 10:15:49 +08:00
Olaoluwa Osuntokun
cac164694f
Merge pull request #10556 from ziggie1984/v0.20.x-branch
Pin sqldb package to pseudo-version
2026-02-05 14:59:07 -08:00
ziggie
b6bd8c4fd2
mod: pin sqldb to pseudo-version for v0.20.x release
This removes the local replace directive for the sqldb package and
pins it to v1.0.12-0.20260113193010-8565d12e40b1 (commit 8565d12e4).
2026-02-05 15:55:40 -05:00
Matt Morehouse
f427fee341 docs: add release note for #10540 2026-02-04 17:16:20 -08:00
Matt Morehouse
81d4da7ffd discovery: fix gossiper shutdown deadlock
When processing a remote network announcement, it is possible for two
error messages to be sent back on the errChan.  Since Brontide doesn't
actually read from errChan, and since errChan only buffered one error
message, the sending goroutine would deadlock forever.  This would only
become apparent when the gossiper attempted to shut down and got hung
up.

For now, we can fix this simply by buffering up to two error messages on
errChan.  There is an existing TODO to restructure this logic entirely
to use the actor model, and we can do a more thorough fix as part of
that work.

This bug was discovered while doing full node fuzz testing and was
triggered by sending a specific channel_announcement message and then
shutting down LND.

(cherry picked from commit 21588acb3d)
2026-02-05 01:12:49 +00:00
ziggieXXX
d190a01be4
Merge pull request #10546 from lightningnetwork/backport-10529-to-v0.20.x-branch
[v0.20.x-branch] Backport #10529: graphdb: fix backwards-compat for channel edge feature deserialization
2026-02-04 13:38:17 -05:00
Olaoluwa Osuntokun
fe486e13a9 graphdb: fix backwards-compat for channel edge feature deserialization
This commit fixes a backwards compatibility issue that prevented nodes
from upgrading from v0.19.x to v0.20.x.

In v0.19.x, channel edge features were serialized as raw feature bytes
without a length prefix. In v0.20.x (commit 2f2845dfc), the serialization
changed to use Features.Encode() which adds a 2-byte big-endian length
prefix before the feature bits. The deserialization code was updated to
use Features.Decode() which expects this length prefix.

When v0.20.x reads a database created by v0.19.x, Decode() tries to read
a length prefix that doesn't exist, causing an EOF error:

    unable to decode features: EOF

The fix adds a deserializeChanEdgeFeatures() helper that detects which
format is being read and decodes accordingly:

- New format (v0.20+): First 2 bytes encode the length of the remaining
  bytes. Detected when uint16(bytes[0:2]) == len(bytes)-2.

- Legacy format (pre-v0.20): Raw feature bits without length prefix.
  Uses DecodeBase256 with the known length.

The format detection is safe because in the legacy format, the first byte
always has at least one bit set (the serialization uses minimum bytes),
so the first two bytes can never encode a value equal to len-2.

Fixes #10528.

(cherry picked from commit 56a7f45b99)
2026-02-03 18:24:39 -08:00
ziggieXXX
802849f0d5
Merge pull request #10539 from Roasbeef/v0-20-1-rc-2
build: bump version to v0.20.1 rc2
2026-02-02 20:08:10 -05:00
Olaoluwa Osuntokun
3be1baf9cf build: bump version to v0.20.1 rc2 2026-02-02 17:15:34 -04:00
Olaoluwa Osuntokun
60bc0f389c
Merge pull request #10523 from lightningnetwork/revert-10331-backport
[v0.20.x-branch] Revert backport #10331: re-org aware channel close logic
2026-01-26 14:39:19 -05:00
Olaoluwa Osuntokun
1495920bb9 Revert "Merge pull request #10510 from lightningnetwork/backport-10331-to-v0.20.x-branch"
This reverts commit 40d8b475b5, reversing
changes made to 0b9e8c33e1.
2026-01-26 13:36:14 -04:00
ziggieXXX
706ec5e136
Merge pull request #10518 from lightningnetwork/backport-10514-to-v0.20.x-branch
[v0.20.x-branch] Backport #10514: Reduce graphDB log noise
2026-01-22 19:51:11 +01:00
ziggie
175933a6ca graphdb: reduce log noise from WRN to DBG
(cherry picked from commit c78a75f5d8)
2026-01-22 15:50:59 +00:00
ziggieXXX
adda56d40b
Merge pull request #10511 from lightningnetwork/v0-20-1-rc1
build: bump version to v0.20.1 rc1
2026-01-17 09:03:38 +01:00
Olaoluwa Osuntokun
a8b00fcb70 build: bump version to v0.20.1 rc1 2026-01-16 17:06:26 -08:00
Olaoluwa Osuntokun
40d8b475b5
Merge pull request #10510 from lightningnetwork/backport-10331-to-v0.20.x-branch
[v0.20.x-branch] Backport #10331: multi: update close logic to handle re-orgs of depth n-1, where n is num confs - add min conf floor
2026-01-16 16:59:07 -08:00
Olaoluwa Osuntokun
82b4345a3e multi: increase min cltv delta to 24 2026-01-16 15:32:43 -08:00
Olaoluwa Osuntokun
8cf08fde1d routing: increase MinCLTVDelta from 18 to 24 blocks
This increases the minimum CLTV delta allowed for invoice creation to
provide more headroom above DefaultFinalCltvRejectDelta (19 blocks).
The previous value of 18 was below the reject threshold, which could
allow users to create invoices with CLTV deltas that would be rejected
when receiving payments.
2026-01-16 15:32:43 -08:00
Olaoluwa Osuntokun
c12c9e7bd2 docs/release-notes: add release notes entry 2026-01-16 15:32:43 -08:00
Olaoluwa Osuntokun
3a6a756e0a lncfg: increase DefaultIncomingBroadcastDelta to 16
With this change, we'll go to chain even earlier to ensure that we have
enough time to sweep a potentially contested HTLC, now that we're
waiting longer before sweeps to ensure that the commitment transaction
is sufficeitnyl burried before we sweep.
2026-01-16 15:32:43 -08:00
Olaoluwa Osuntokun
ab7a002c94 contractcourt: unify+simplify new re-org aware logic 2026-01-16 15:32:43 -08:00
Olaoluwa Osuntokun
4adfa8ec39 contractcourt: add sync dispatch fast-path for single confirmation closes
In this commit, we add a fast-path optimization to the chain watcher's
closeObserver that immediately dispatches close events when only a single
confirmation is required (numConfs == 1). This addresses a timing issue
with integration tests that were designed around the old synchronous
blockbeat behavior, where close events were dispatched immediately upon
spend detection.

The recent async confirmation architecture (introduced in commit f6f716ab7)
properly handles reorgs by waiting for N confirmations before dispatching
close events. However, this created a race condition in integration tests
that mine blocks synchronously and expect immediate close notifications.
With the build tag setting numConfs to 1 for itests, the async confirmation
notification could arrive after the test already started waiting for the
close event, causing timeouts.

We introduce a new handleSpendDispatch method that checks if numConfs == 1
and, if so, immediately calls handleCommitSpend to dispatch the close event
synchronously, then returns true to skip the async state machine. This
preserves the old behavior for integration tests while maintaining the full
async reorg protection for production (where numConfs >= 3).

The implementation adds the fast-path check in both spend detection paths
(blockbeat and spend notification) to ensure consistent behavior regardless
of which detects the spend first. We also update the affected unit tests to
remove their expectation of confirmation registration, since the fast-path
bypasses that step entirely.

This approach optimizes for the integration test scenario without compromising
production safety, as the fast-path only activates when a single confirmation
is sufficient - a configuration that only exists in the controlled test
environment.
2026-01-16 15:32:43 -08:00
Olaoluwa Osuntokun
fadfdecd0e itest: add new coop close rbf itest
This ensures that during the RBF process, if one confirms, a re-org
occurs, then another confirms, that we'll properly detect this case.
2026-01-16 15:32:43 -08:00
Olaoluwa Osuntokun
25b19461db contractcourt: add generic close re-org tests
In this commit, we add a set of generic close re-org tests. The most
important test is the property based test, they will randomly confirm
transactions, generate a re-org, then assert that eventually we dtect
the final version.
2026-01-16 15:32:43 -08:00
Olaoluwa Osuntokun
8de352e276 contractcourt: add unit tests for rbf re-org cases
This set of new tests ensures that if have created N RBF variants of the
coop close transaction, that any of then can confirm, and be re-org'd,
with us detecting the final spend once it confirms deeploy enough.
2026-01-16 15:32:43 -08:00
Olaoluwa Osuntokun
19d8bb2a6c contractcourt: update existing chain watcher tests due to new logic
All the tests need to send a confirmation _after_ the spend is detected
now.
2026-01-16 15:32:43 -08:00
Olaoluwa Osuntokun
6f6034dd51 contractcourt: add new chainWatcherTestHarness
We'll use this for all the upcoming tests.
2026-01-16 15:32:43 -08:00
Olaoluwa Osuntokun
a8e37b08e4 lntest: add new wait for conf helper method to ChainNotifier 2026-01-16 15:32:43 -08:00
Olaoluwa Osuntokun
4d5a14de3d contractcourt: update close logic to handle re-orgs of depth n-1, where n is num confs
In this commit, we update the close logic to handle re-ogs up to the
final amount of confirmations. This is done generically, so we're able
to handle events such as: coop close confirm, re-org, breach confirm,
re-org, force close confirm, re-org, etc.

The upcoming set of new tests will exercise all of these cases.

We modify the block beat handling to unify the control flow. As it's
possible we get the beat, then see the spend, or the oher way around.
2026-01-16 15:32:42 -08:00
Olaoluwa Osuntokun
99b32c9465 peer: send out a notification after the 1st conf, then wait for the rest
We wnt to add better handling, but not break any UIs or wallets. So
we'll continue to send out a notification after a single confirmation,
then send another after things are fully confirmed.
2026-01-16 15:31:04 -08:00
Olaoluwa Osuntokun
65d53ce875 multi: add new ChannelCloseConfs param, thread thru as needed
In this commit, we add a new param that'll allow us to scale up the
number of confirmations before we act on a new close. We'll use this
later to improve the current on chain handling logic.
2026-01-16 15:31:03 -08:00
Olaoluwa Osuntokun
a28a09670a lncfg: add new dev config option for scaling channel close confs
This'll be useful for the set up upcoming itests.
2026-01-16 15:28:27 -08:00
Olaoluwa Osuntokun
d9d2bf466e peer+rpcserver: use new conf scaling for notifications 2026-01-16 15:28:27 -08:00
Olaoluwa Osuntokun
bd6fa84d8d lnwallet: add tests for new conf scaling helper funcs 2026-01-16 15:28:27 -08:00
Olaoluwa Osuntokun
e5c5011900 lnwallet: define helper func to coop close conf scaling
We have two versions: for itests, we just use one conf, but in prod,
we'll scale the number of confirmations.
2026-01-16 15:28:27 -08:00
Olaoluwa Osuntokun
8a38c86628 server: use new FundingConfsForAmounts helper func 2026-01-16 15:28:27 -08:00
Olaoluwa Osuntokun
55807ac4d0 lnwallet: add new helper functions to scale confirmations based on amt 2026-01-16 15:28:27 -08:00
ziggieXXX
0b9e8c33e1
Merge pull request #10494 from ziggie1984/backport-10491-to-v0.20.x-branch
Backport #10491 to v0.20.x-branch
2026-01-13 22:55:58 +01:00
ziggie
c8364afaeb
fn: fix printf vet check in TestSomeToOkf Go 1.24+
See also https://github.com/golang/go/issues/60529. Now we need
to use a constant.

(cherry picked from commit 7085f4706d)
2026-01-13 20:30:10 +01:00
ziggie
8565d12e40
mod: update the minimum go version to 1.24.11
We update the minimum required go version to build the LND
exectuable to the latest minor release of go 1.24.11.

(cherry picked from commit e26a114cdc)
2026-01-13 20:30:10 +01:00
ziggie
a7ca338720
build: update CI+release version to Go 1.25.5
This updates the toolchain which we require to build the LND
executable.

(cherry picked from commit 61d82fd1f2)
2026-01-13 20:30:09 +01:00
ziggieXXX
d59e98b3f4
Merge pull request #10492 from lightningnetwork/backport-10470-to-v0.20.x-branch
[v0.20.x-branch] Backport #10470: discovery: add panic recovery for gossip message processing
2026-01-13 17:03:49 +01:00
ziggie
56829a9c8a
discovery: fix new usetesting linter issues
(cherry picked from commit 35260fc4a7)
2026-01-13 13:08:06 +01:00
Olaoluwa Osuntokun
5cf38edc62
docs/release-notes: add release notes
(cherry picked from commit 7da41cb367)
2026-01-13 08:17:50 +01:00
Olaoluwa Osuntokun
a0be1c926b discovery: add panic recovery for serial announce signatures processing
In this commit, we extend the panic recovery mechanism to cover the
serial processing path for AnnounceSignatures1 messages. Unlike other
gossip messages which are processed in parallel goroutines, announcement
signatures are processed serially in the main networkHandler loop.

A panic during this serial processing would previously crash the entire
gossiper. This change wraps the processing in an anonymous function with
a deferred panic recovery, ensuring resilience without changing the
serial processing semantics.

Since AnnounceSignatures bypass the validation barrier, we pass nil for
the jobID parameter.

(cherry picked from commit bcb65f5ac7)
2026-01-13 01:11:00 +00:00
Olaoluwa Osuntokun
b7b73b02e1 discovery: add panic recovery for gossip message processing
In this commit, we add a centralized panic recovery mechanism for gossip
goroutines. This increases the robustness of message processing in the
gossiper, as now we are able to keep on trucking in the face of logic
errors that may lead to panics.

We ensure that any deps are freed and we log the panic trace to help
catch bugs in the future.

(cherry picked from commit caf4850f74)
2026-01-13 01:11:00 +00:00
ziggieXXX
1827699848
Merge pull request #10490 from lightningnetwork/backport-10469-to-v0.20.x-branch
[v0.20.x-branch] Backport #10469: lnwire: enforce non-zero timestamp in gossip messages
2026-01-12 17:46:30 +01:00
Olaoluwa Osuntokun
8b471eadeb
discovery: enforce non-zero timestamp in gossip messages
In this commit, we add validation for channel updates and node
announcements to ensure that we reject gossip messages with zero
timestamps at the discovery layer.

From BOLT 7:
  "MUST set timestamp to greater than 0, AND to greater than any
  previously-sent channel_update for this short_channel_id."

This validation is performed in the gossip handlers (handleNodeAnnouncement
and handleChanUpdate) rather than at the wire protocol level. This approach
ensures we can still decode messages from disk or embedded in onion errors
while rejecting invalid gossip from peers.

Remote peers sending zero-timestamp gossip will have their ban score
incremented.

(cherry picked from commit cad1b957bf)
2026-01-12 14:05:21 +01:00
Olaoluwa Osuntokun
2cb9ff24fc
Merge pull request #10483 from lightningnetwork/backport-10462-to-v0.20.x-branch
[v0.20.x-branch] Backport #10462: channeldb: fix race condition in link node pruning
2026-01-07 11:56:04 -08:00
ziggie
f5527e1e60
multi: make sure previous inconsitent states are fixed
We make sure that nodes previously suffering from this error will
have a consitent db view when restarting their node.

(cherry picked from commit d9fb9092b6)
2026-01-07 09:21:24 +01:00
ziggie
57069cf3a1 channeldb: fix race condition in link node pruning
This commit fixes a critical race condition in MarkChanFullyClosed and
pruneLinkNode where link nodes could be incorrectly deleted despite
having pending or open channels.

The race occurred because the check for open channels and the link node
deletion happened in separate database transactions:

  Thread A: TX1 checks open channels → [] (empty)
  Thread A: TX1 commits
  Thread B: Opens new channel with same peer
  Thread A: TX2 deletes link node (using stale data)
  Result: Link node deleted despite pending channel existing

This creates a TOCTOU (time-of-check to time-of-use) vulnerability where
database state changes between reading the channel count and deleting
the node.

Fix for MarkChanFullyClosed:
- Move link node deletion into the same transaction as the channel
  closing check, making the check-and-delete operation atomic

Fix for pruneLinkNode:
- Add double-check within the write transaction to verify no channels
  were opened since the caller's initial check
- Maintains performance by keeping early return for common case
- Prevents deletion if channels exist at delete time

This ensures the invariant: "link node exists iff channels exist"
is never violated, preventing database corruption and potential
connection issues.

(cherry picked from commit 51f3c6f528)
2026-01-07 07:56:04 +00:00
ziggieXXX
96652812d7
Merge pull request #10475 from lightningnetwork/backport-10356-to-v0.20.x-branch
[v0.20.x-branch] Backport #10356: graph: fix inefficient query for IsPublicNode
2026-01-05 23:05:00 +01:00
Abdullahi Yunus
580e820b6a
docs: add release note
(cherry picked from commit 68f558c865)
2026-01-05 20:31:45 +01:00
ziggieXXX
86cc097fec
Merge pull request #10477 from lightningnetwork/backport-10476-to-v0.20.x-branch
[v0.20.x-branch] Backport #10476: itest: fix endorsement itests
2026-01-05 20:30:11 +01:00
ziggie
f1332fe4a7
itest: fix endorsement itests
Due to the signaling period expiring tests had to be adopted bc
they were not taking the activation time period into account.

(cherry picked from commit 5f30797738)
2026-01-05 17:02:21 +01:00
Abdullahi Yunus
10aff6c580 graph: use UNION for isPublicNode query
In this commit we updated the IsPublicV1Node query to use UNION
instead of OR, since sqlite struggles to efficiently use
multiple indexes in a single query involving OR conditions across
different columns.

We use UNION ALL since the query doesn't care about duplicates.

(cherry picked from commit ac2cec462c)
2026-01-05 09:35:35 +00:00
Abdullahi Yunus
6c656a6af6 graphdb: add benchmark for isPublicNode query
In this commit we add a benchmark to test the performance of
IsPublicNode query.

(cherry picked from commit 86cde4b93f)
2026-01-05 09:35:35 +00:00
Yong
1a83e9a8a0
Merge pull request #10451 from lightningnetwork/backport-10449-to-v0.20.x-branch
[v0.20.x-branch] Backport #10449: server: fix timestamp comparison in setSelfNode
2025-12-17 16:47:28 +08:00
ziggie
99b136e39e
docs: add release-notes for LND 20.1
(cherry picked from commit e5b0704d73)
2025-12-16 18:18:22 +01:00
ziggie
677ffabed8 server: fix timestamp comparison in setSelfNode
Fix bug where setSelfNode compared only the seconds component of
timestamps instead of the full timestamp. This caused the node to
attempt persisting an older timestamp than what existed in the
database during restart, resulting in "sql: no rows in result set"
errors.

(cherry picked from commit 865e1556d4)
2025-12-16 08:58:46 +00:00
ziggieXXX
eceaadc958
Merge pull request #10445 from lightningnetwork/backport-10439-to-v0.20.x-branch
[v0.20.x-branch] Backport #10439: Always add the payment address when probing an invoice
2025-12-15 12:59:18 +01:00
Elle
07b845207f
Merge pull request #10443 from ellemouton/backport-fixMissingEdge
[backport] localchans: populate FundingScript for missing edges
2025-12-15 08:02:08 +02:00
ziggie
f82c35c777
docs: add release-notes for LND 20.1
(cherry picked from commit ac30443cc1)
2025-12-13 01:28:32 +01:00
ziggie
1a543fbfcd
routerrpc: fix payment address deep copy
using copy for a slice of size 0 will not copy anything so we need
to first initialize the slice before we do the deep copy.

(cherry picked from commit 1c4bcc3b7d)
2025-12-13 01:28:32 +01:00
Olaoluwa Osuntokun
c6467a6305
Merge pull request #10444 from ziggie1984/backport/enhance-lsp-heuritic
backport/enhance lsp heuritic
2025-12-12 16:16:42 -08:00
ziggie
43091bdd51
docs: add release-notes for LND 20.1 2025-12-12 10:10:40 +01:00
ziggie
68c4809913
docs: update api documentation for estimateRouteFee 2025-12-12 10:10:40 +01:00
ziggie
6af171f365
itest: enhance testEstimateRouteFee with multi-LSP scenarios
This commit enhances the integration test to validate the LSP heuristic
end-to-end with real network topology and payment probing.

Network topology additions:
- Added Frank node as a private destination
- Created multi-LSP test scenario with Bob, Eve, and Dave as LSPs

New test cases:

1. "probe based estimate, public target with public hop hints"
   - Validates Rule 1: public invoice target routes directly
   - Even with public hop hints, direct routing is used
   - Expected: standard single-hop fees

2. "probe based estimate, multiple different public LSPs"
   - Validates multi-LSP worst-case selection
   - Frank has routes through Bob (low fee), Eve (HIGH fee), Dave (medium)
   - Expected: Eve's worst-case fees (most expensive)
   - Tests griefing protection (max 3 LSP probes)
2025-12-12 10:10:40 +01:00
ziggie
f1fc329eb2
routerrpc: implement LSP heuristic and multi-LSP worst-case probing
This commit implements a comprehensive LSP (Lightning Service Provider)
detection heuristic and updates the payment probing logic to handle
multiple LSPs with worst-case fee estimation.

Key changes:

1. LSP Detection Heuristic (isLSP function):
   Implements three rules to detect LSP setups:
   - Rule 1: If invoice target is public → NOT an LSP (route directly)
   - Rule 2: If at least one destination hop is public → IS an LSP
   - Rule 3: If all destination hops are private → NOT an LSP

2. LSP Route Preparation (prepareLspRouteHints function):
   - Groups route hints by unique public LSP nodes
   - Filters out non-LSP routes based on the heuristic
   - Tracks worst-case fees and CLTV delays for each LSP
   - Returns adjusted route hints with LSP hop stripped

3. Multi-LSP Probing (probePaymentRequest updates):
   - Probes up to 3 unique LSPs maximum (griefing protection)
   - Selects the WORST-CASE (most expensive) route for conservative
     fee estimation
   - Adds comprehensive debug logging for worst-case selection process
   - Properly formats vertex logging using %v (calls Vertex.String())

The worst-case approach ensures users won't be surprised by higher fees
when the actual payment is sent, providing a more conservative and
reliable fee estimate.

This commit also  adds extensive unit test coverage for the LSP detection
heuristic and route preparation logic.

TestIsLsp:
- Edge cases: empty route hints, nil scenarios
- Rule 1: Public invoice target (3 tests)
- Rule 2: All private destination hops (4 tests)
- Rule 3: At least one public destination hop (6 tests)

TestPrepareLspRouteHints:
- LSP grouping and filtering logic
- Worst-case fee selection across route hints
- Worst-case CLTV delta tracking
- Adjusted route hints validation (LSP hop stripped)
- Multi-LSP scenarios with different fees
2025-12-12 10:10:40 +01:00
ziggie
04ebd363c2
routerrpc: add HasNode backend function for LSP heuristic
This commit adds the HasNode function to the RouterBackend struct,
which checks if a node exists in the graph (i.e., has public channels).
This function is needed by the LSP detection heuristic to determine
if a node is publicly reachable.

The function is wired up in rpcserver.go to query the graph database.
2025-12-12 10:10:39 +01:00
ziggie
246abd1697
graph/db: fix HasNode comment
The comment was incorrectly referring to HasLightningNode but the
function is named HasNode. Update the comment to match the actual
function name.
2025-12-12 10:10:39 +01:00
Elle Mouton
53994daf13
docs: update release notes 2025-12-12 10:51:14 +02:00
Elle Mouton
7d695f581f
localchans: populate funding script for missing edges
When creating a missing edge, we need to populate the funding script too
so that the graph builder can update its ChainView appropriately. We use
the MakeFundingScript helper from the funding package which ensures that
we are using the same logic for creating a funding script as is used for
any of the channels that we own.
2025-12-12 10:23:51 +02:00
Elle Mouton
c32cbd940a
funding: export MakeFundingScript
So that we can re-use this helper else where.
2025-12-12 10:23:50 +02:00
Yong
189e23af97
Merge pull request #10429 from ziggie1984/backport/fix-sql-pool-exhaustion
backport: fix potential sql tx exhaustion
2025-12-11 23:02:42 +08:00
Olaoluwa Osuntokun
0d225a1110
Merge pull request #10433 from ellemouton/backport-removePubKeyCaching
backport: graph: fix various races
2025-12-09 17:11:57 -08:00
ziggie
0c7db1a206
docs: add release-notes for LND 20.1 2025-12-09 16:16:46 +01:00
ziggie
c079362c2c
graphdb: fix potential sql tx exhaustion
We should avoid taking the lock of a mutex inside transaction.
Currently we also take this lock in other places and there is a
chance that in case the application lock aquires the lock but
all transactions are already blocked waiting for the mutex to
unlock, we end up in a deadlock.
2025-12-09 16:16:45 +01:00
Elle Mouton
ad87b492d1
docs: add release notes for race condition fixes 2025-12-09 10:18:39 +02:00
Elle Mouton
439c3ede7d
graph/db: fix Node receivers
Let all the "Node" struct receivers be "n" in order to fix the linter
check.
2025-12-09 10:18:01 +02:00
Elle Mouton
a83d1177b8
graph/db: fix race in DisconnectBlockAtHeight cache access
The DisconnectBlockAtHeight method was modifying the rejectCache and
chanCache without holding the cacheMu lock. This caused races with
other operations that properly held the lock, such as AddChannelEdge
which modifies the caches in its OnCommit callback while the batch
scheduler holds cacheMu.

Fix by acquiring cacheMu before removing channels from the caches.
2025-12-08 14:22:08 +02:00
Elle Mouton
550de8d348
graph/db/models: fix race conditions in ChannelEdgeInfo
Both NodeKey1 and NodeKey2 methods had the same race condition as the
Node.PubKey method, where concurrent calls could race to write to the
cached fields.

Remove the caching for the same reasons: parsing overhead is minimal
and doesn't justify the complexity and race risk.
2025-12-08 14:22:08 +02:00
Elle Mouton
540224239b
graph/db/models: fix race condition in Node.PubKey
The PubKey method had a race condition where concurrent calls could
all pass the nil check and race to write to the cached pubKey field.
This is a classic check-then-act race.

Remove the caching entirely to fix the race. The overhead of parsing
a public key is minimal and doesn't justify the added complexity and
race risk of caching.
2025-12-08 13:49:17 +02:00
Yong
ec480f033f
Merge pull request #10422 from mohamedawnallah/handle-partial-tls-files-v0.20.x
tls_manager: port tls partial files handling back to `v0.20.x-branch`
2025-12-05 20:05:54 +08:00
Yong
5bfcdf9957
Merge pull request #10418 from ellemouton/fixSourceNodeSetting-v0.20.x
backport[0.20.x]: graph/db: fix SetSourceNode no rows error
2025-12-04 20:35:52 +08:00
Mohamed Awnallah
222e038a77 docs: update release notes 2025-12-04 11:20:41 +00:00
Thiago Romão Barcala
76b0701745 tls_manager.go: handle case when either TLS pair files exist 2025-12-04 11:19:57 +00:00
Mohamed Awnallah
24de03d766 tls_manager_test.go: reproduce partial tls files handling
When there is only one of the tls pairs (key/certificate) and the
other is missing, the TLS manager currently assumes it exists
and ignore generating them. This results in error propgated to user
that the other tls pair file is missing/not found.
2025-12-04 11:19:57 +00:00
Elle Mouton
2590593c3f
docs: add release note 2025-12-04 12:21:42 +02:00
Elle Mouton
c207461aca
graph/db: fix SetSourceNode race with lenient upsert
This commit fixes a race condition where multiple goroutines call
SetSourceNode concurrently during startup, causing sql.ErrNoRows
errors. The race occurs when multiple code paths (setSelfNode,
createNewHiddenService, RPC updates) read the same old timestamp,
independently increment it to the same new value (T+1), and race to
write.

The fix uses the new UpsertSourceNode SQL query (without strict
timestamp constraint) instead of UpsertNode. This allows
last-write-wins semantics for our own node, ensuring all parameter
changes persist even when timestamps collide.

Refactored sql_store.go for reusability:
- upsertNodeAncillaryData: common logic for features/addresses/extras
- populateNodeParams: common parameter building with callback pattern
- buildNodeUpsertParams: builds params for strict UpsertNode
- buildSourceNodeUpsertParams: builds params for lenient UpsertSourceNode
- upsertSourceNode: new function using lenient query

Updated TestSetSourceNodeSameTimestamp to verify that concurrent
updates with the same timestamp now succeed and parameter changes
persist.

Fixes the itest error:
"unable to upsert source node: upserting node(...): sql: no rows in
result set"
2025-12-04 12:21:40 +02:00
Elle Mouton
2a9e82b60a
sqldb: add UpsertSelfNode query
This query is less strict in terms of the latest update timestamp field.
We want to be less strict with our own node data since we always want
our own updates recorded.
2025-12-04 12:15:06 +02:00
Elle Mouton
13c6a3777c
graph/db: add test for SetSourceNode same timestamp behavior
This commit adds TestSetSourceNodeSameTimestamp to demonstrate the
current behavior when SetSourceNode is called with the same last update
timestamp. The test reveals a difference between the SQL and bbolt
implementations:

- SQL store returns sql.ErrNoRows when attempting to update with the
  same timestamp, as the upsert query's UPDATE clause requires the new
  timestamp to be strictly greater than the existing one
- bbolt store silently ignores stale updates and returns no error

This behavior is important to document because our own node
announcements may change quickly with the same timestamp, unlike
announcements from other nodes where same timestamp typically means
identical parameters.
2025-12-04 12:15:02 +02:00
Olaoluwa Osuntokun
b60920b1ae
Merge pull request #10402 from ziggie1984/backport/fix-mission-control-startup
backport:allow misson control manager to startup despite errors
2025-12-02 19:20:56 -08:00
ziggie
1fa6f70b0d
docs: add release-notes for LND 20.1 2025-12-02 20:47:50 +01:00
ziggie
2d477d699d
routing: allow misson control manager to startup despite errors
We now allow the mission control manager to skip over deserializable
errors. We cannot repair this these results but we just skip over
it so we can startup properly.

When fetchAll() encounters entries that fail to deserialize, in
addition to skipping them, now also:

- Delete the corrupted entries from the database
- Remove them from the in-memory keysMap and keys tracking structures

This prevents corrupted entries from:
- Being counted toward maxRecords, which would cause valid entries
  to be pruned prematurely
- Persisting in the database indefinitely
- Causing inaccurate entry counts in startup logs
2025-12-02 20:47:50 +01:00
Olaoluwa Osuntokun
4eaf57d71d
Merge pull request #10397 from ziggie1984/backport/add-back-globallock
backport: readd global lock for postgres and the channeldb_kv table
2025-12-01 14:44:34 -08:00
ziggie
6bdfb1dc4d
docs: add release-notes for LND 20.1 2025-11-28 09:36:34 +01:00
ziggie
a25659a6e4
lncfg+scripts: use configurable global lock for postgres backends
Replace hardcoded WithGlobalLock assignment with configurable
options wallet postgres backends. Also add the WithGlobalLock
option to the channeldb table for postgres backends.

Defaults:
- channeldb: false (allow concurrent access)
- wallet: true (maintain safe single-writer behavior)

Users can now override these defaults via:
- db.postgres.channeldb-with-global-lock
- db.postgres.walletdb-with-global-lock

This gives operators flexibility while maintaining safe defaults
until full native SQL migration is complete.

Moreover exclude db.postgres.walletdb-with-global-lock check
in the sample config file script. We cannot easily check the
correct default because we set it later in the LND startup
sequence so we exclude it.
2025-11-28 09:36:34 +01:00
ziggie
dd304e94fa
mod: use local path for sqldb until the new version is tagged 2025-11-28 09:36:33 +01:00
ziggie
bd8f49afd6
sqldb: add global lock config options for postgres
Add two configuration options to control global lock usage for
different postgres database backends:

- ChannelDBWithGlobalLock: for channeldb access (default: false)
- WalletDBWithGlobalLock: for wallet database access (default: true)

These allow fine-grained control over which databases use global
locks, rather than hardcoding the behavior. This is a temporary
measure until the revocation log and wallet are migrated to native
SQL and become fully concurrent-safe.
2025-11-28 09:27:29 +01:00
Yong
0aa757b022
Merge pull request #10398 from bitromortac/backport/2511-fix-tor-healthcheck
backport: server: prevent duplicate onion addresses in getinfo
2025-11-28 00:06:14 +08:00
bitromortac
7bf9f30b55
docs: add release-notes for lnd v0.20.1 2025-11-27 13:11:18 +01:00
bitromortac
63da9b3259
server: ensure unique addresses for node ann
Modifiers of the node announcement may add duplicate addresses, which we
remove here after the modifications were applied. This also ensures that
any previously added duplicate addresses are removed as well.
2025-11-27 13:11:18 +01:00
Yong
f8b5cb0e89
Merge pull request #10395 from ffranr/backport/v0.20.x-branch-add-field-committxblockheight-to-resolutionreq
backport: lnwallet: add field `CommitTxBlockHeight` to ResolutionReq
2025-11-27 09:41:18 +08:00
ffranr
07003fc3a0
lnwallet: add field CommitTxBlockHeight to ResolutionReq
Introduce `CommitTxBlockHeight` field to the `ResolutionReq` structure
and related methods. This field records the block height where a
commitment transaction has confirmed.

(cherry picked from commit 3d2b0d703e)
2025-11-26 15:53:21 +00:00
Olaoluwa Osuntokun
64e2f013e2
Merge pull request #10388 from ziggie1984/backport/fix-scanning-issue
Backport contracourt fix #10387
2025-11-24 10:10:06 -08:00
ziggie
465013f097
docs: add release-notes for LND 20.1 2025-11-22 09:47:23 +01:00
ziggie
856693d70c
contractcourt: use confheight instead of rescanning the chain 2025-11-22 09:47:23 +01:00
ziggie
c4d8dc8e00
contracourt: fix comment 2025-11-22 09:47:22 +01:00
ziggie
dac47cf698
contracourt: rename broadcastHeight to confirmHeight
The broadcastHeight was misleading because the commit resolver
is only created when the commitment transaction is confirmed.
2025-11-22 09:47:17 +01:00
Olaoluwa Osuntokun
911f7296a8
Merge pull request #10381 from ziggie1984/backport/10378
Backport graph cache fix #10378 to minor release branch
2025-11-19 14:36:33 -08:00
ziggie
da55e567e9
docs: add release-notes for 20.1 2025-11-19 19:03:19 +01:00
ziggie
8bc240770a
graph: add regression test for the fixed behaviour 2025-11-19 19:03:19 +01:00
ziggie
f16886041d
graph: fix graph cache population for channels with both policies disabled
Fix a bug where channels with both policies disabled were not added to
the graph cache during startup. When a policy update later re-enabled
one of the directions, the update would succeed in the database but fail
to update the graph cache (since the channel structure was never added),
preventing the channel from being used for routing.
2025-11-19 19:03:15 +01:00
114 changed files with 6829 additions and 1161 deletions

View file

@ -52,7 +52,7 @@ runs:
# The key is used to create and later look up the cache. It's made of
# four parts:
# - The base part is made from the OS name, Go version and a
# job-specified key prefix. Example: `linux-go-1.25.3-unit-test-`.
# job-specified key prefix. Example: `linux-go-1.25.5-unit-test-`.
# It ensures that a job running on Linux with Go 1.25 only looks for
# caches from the same environment.
# - The unique part is the `hashFiles('**/go.sum')`, which calculates a

View file

@ -40,7 +40,7 @@ env:
# If you change this please also update GO_VERSION in Makefile (then run
# `make lint` to see where else it needs to be updated as well).
GO_VERSION: 1.25.3
GO_VERSION: 1.25.5
jobs:
static-checks:

View file

@ -12,7 +12,7 @@ defaults:
env:
# If you change this please also update GO_VERSION in Makefile (then run
# `make lint` to see where else it needs to be updated as well).
GO_VERSION: 1.25.3
GO_VERSION: 1.25.5
jobs:
########################

View file

@ -1,7 +1,7 @@
run:
# If you change this please also update GO_VERSION in Makefile (then run
# `make lint` to see where else it needs to be updated as well).
go: "1.25.3"
go: "1.25.5"
# Abort after 10 minutes.
timeout: 10m

View file

@ -1,6 +1,6 @@
# If you change this please also update GO_VERSION in Makefile (then run
# `make lint` to see where else it needs to be updated as well).
FROM golang:1.25.3-alpine as builder
FROM golang:1.25.5-alpine as builder
# Force Go to use the cgo based DNS resolver. This is required to ensure DNS
# queries required to connect to linked containers succeed.

View file

@ -28,7 +28,7 @@ ACTIVE_GO_VERSION_MINOR := $(shell echo $(ACTIVE_GO_VERSION) | cut -d. -f2)
# GO_VERSION is the Go version used for the release build, docker files, and
# GitHub Actions. This is the reference version for the project. All other Go
# versions are checked against this version.
GO_VERSION = 1.25.3
GO_VERSION = 1.25.5
GOBUILD := $(GOCC) build -v
GOINSTALL := $(GOCC) install -v

View file

@ -47,7 +47,7 @@ const (
AppMinor uint = 20
// AppPatch defines the application patch for this binary.
AppPatch uint = 00
AppPatch uint = 02
// AppPreRelease MUST only contain characters from semanticAlphabet per
// the semantic versioning spec.

View file

@ -1,6 +1,6 @@
module github.com/lightningnetwork/lnd/cert
go 1.19
go 1.24.11
require github.com/stretchr/testify v1.8.2

View file

@ -1363,11 +1363,7 @@ func (c *ChannelStateDB) FetchClosedChannelForID(cid lnwire.ChannelID) (
// the pending funds in a channel that has been forcibly closed have been
// swept.
func (c *ChannelStateDB) MarkChanFullyClosed(chanPoint *wire.OutPoint) error {
var (
openChannels []*OpenChannel
pruneLinkNode *btcec.PublicKey
)
err := kvdb.Update(c.backend, func(tx kvdb.RwTx) error {
return kvdb.Update(c.backend, func(tx kvdb.RwTx) error {
var b bytes.Buffer
if err := graphdb.WriteOutpoint(&b, chanPoint); err != nil {
return err
@ -1413,44 +1409,72 @@ func (c *ChannelStateDB) MarkChanFullyClosed(chanPoint *wire.OutPoint) error {
// other open channels with this peer. If we don't we'll
// garbage collect it to ensure we don't establish persistent
// connections to peers without open channels.
pruneLinkNode = chanSummary.RemotePub
openChannels, err = c.fetchOpenChannels(
tx, pruneLinkNode,
)
remotePub := chanSummary.RemotePub
openChannels, err := c.fetchOpenChannels(tx, remotePub)
if err != nil {
return fmt.Errorf("unable to fetch open channels for "+
"peer %x: %v",
pruneLinkNode.SerializeCompressed(), err)
remotePub.SerializeCompressed(), err)
}
if len(openChannels) > 0 {
return nil
}
// If there are no open channels with this peer, prune the
// link node. We do this within the same transaction to avoid
// a race condition where a new channel could be opened
// between this check and the deletion.
log.Infof("Pruning link node %x with zero open "+
"channels from database",
remotePub.SerializeCompressed())
err = deleteLinkNode(tx, remotePub)
if err != nil {
return fmt.Errorf("unable to delete link "+
"node: %w", err)
}
return nil
}, func() {
openChannels = nil
pruneLinkNode = nil
})
if err != nil {
return err
}
// Decide whether we want to remove the link node, based upon the number
// of still open channels.
return c.pruneLinkNode(openChannels, pruneLinkNode)
}, func() {})
}
// pruneLinkNode determines whether we should garbage collect a link node from
// the database due to no longer having any open channels with it. If there are
// any left, then this acts as a no-op.
func (c *ChannelStateDB) pruneLinkNode(openChannels []*OpenChannel,
remotePub *btcec.PublicKey) error {
// the database due to no longer having any open channels with it.
//
// NOTE: This function should be called after an initial check shows no open
// channels exist. It will double-check within a write transaction to avoid a
// race condition where a channel could be opened between the initial check
// and the deletion.
func (c *ChannelStateDB) pruneLinkNode(remotePub *btcec.PublicKey) error {
return kvdb.Update(c.backend, func(tx kvdb.RwTx) error {
// Double-check for open channels to avoid deleting a link node
// if a channel was opened since the caller's initial check.
//
// NOTE: This avoids a race condition where a channel could be
// opened between the initial check and the deletion.
openChannels, err := c.fetchOpenChannels(tx, remotePub)
if err != nil {
return err
}
// If channels exist now, don't prune.
if len(openChannels) > 0 {
return nil
}
// No open channels, safe to prune the link node.
log.Infof("Pruning link node %x with zero open channels "+
"from database",
remotePub.SerializeCompressed())
err = deleteLinkNode(tx, remotePub)
if err != nil {
return fmt.Errorf("unable to prune link node: %w", err)
}
if len(openChannels) > 0 {
return nil
}
log.Infof("Pruning link node %x with zero open channels from database",
remotePub.SerializeCompressed())
return c.linkNodeDB.DeleteLinkNode(remotePub)
}, func() {})
}
// PruneLinkNodes attempts to prune all link nodes found within the database
@ -1479,7 +1503,11 @@ func (c *ChannelStateDB) PruneLinkNodes() error {
return err
}
err = c.pruneLinkNode(openChannels, linkNode.IdentityPub)
if len(openChannels) > 0 {
continue
}
err = c.pruneLinkNode(linkNode.IdentityPub)
if err != nil {
return err
}
@ -1488,6 +1516,93 @@ func (c *ChannelStateDB) PruneLinkNodes() error {
return nil
}
// RepairLinkNodes scans all channels in the database and ensures that a
// link node exists for each remote peer. This should be called on startup to
// ensure that our database is consistent.
//
// NOTE: This function is designed to repair database inconsistencies that may
// have occurred due to the race condition in link node pruning (where link
// nodes could be incorrectly deleted while channels still existed). This can
// be removed once we move to native sql.
func (c *ChannelStateDB) RepairLinkNodes(network wire.BitcoinNet) error {
// In a single read transaction, build a list of all peers with open
// channels and check which ones are missing link nodes.
var missingPeers []*btcec.PublicKey
err := kvdb.View(c.backend, func(tx kvdb.RTx) error {
openChanBucket := tx.ReadBucket(openChannelBucket)
if openChanBucket == nil {
return ErrNoActiveChannels
}
var peersWithChannels []*btcec.PublicKey
err := openChanBucket.ForEach(func(nodePubBytes,
_ []byte) error {
nodePub, err := btcec.ParsePubKey(nodePubBytes)
if err != nil {
return err
}
channels, err := c.fetchOpenChannels(tx, nodePub)
if err != nil {
return err
}
if len(channels) > 0 {
peersWithChannels = append(
peersWithChannels, nodePub,
)
}
return nil
})
if err != nil {
return err
}
// Now check which peers are missing link nodes within the
// same transaction.
missingPeers, err = c.linkNodeDB.FindMissingLinkNodes(
tx, peersWithChannels,
)
return err
}, func() {
missingPeers = nil
})
if err != nil && !errors.Is(err, ErrNoActiveChannels) {
return fmt.Errorf("unable to fetch channels: %w", err)
}
// Early exit if no repairs needed.
if len(missingPeers) == 0 {
return nil
}
// Create all missing link nodes in a single write transaction
// using the LinkNodeDB abstraction.
linkNodesToCreate := make([]*LinkNode, 0, len(missingPeers))
for _, remotePub := range missingPeers {
linkNode := NewLinkNode(c.linkNodeDB, network, remotePub)
linkNodesToCreate = append(linkNodesToCreate, linkNode)
log.Infof("Repairing missing link node for peer %x",
remotePub.SerializeCompressed())
}
err = c.linkNodeDB.CreateLinkNodes(nil, linkNodesToCreate)
if err != nil {
return err
}
log.Infof("Repaired %d missing link nodes on startup",
len(missingPeers))
return nil
}
// ChannelShell is a shell of a channel that is meant to be used for channel
// recovery purposes. It contains a minimal OpenChannel instance along with
// addresses for that target node.

View file

@ -2,6 +2,8 @@ package channeldb
import (
"bytes"
"errors"
"fmt"
"io"
"net"
"time"
@ -134,6 +136,95 @@ type LinkNodeDB struct {
backend kvdb.Backend
}
// FindMissingLinkNodes checks which of the provided public keys do not have
// corresponding link nodes in the database. If tx is nil, a new read
// transaction will be created. Otherwise, the provided transaction is used,
// allowing this to be part of a larger batch operation.
func (l *LinkNodeDB) FindMissingLinkNodes(tx kvdb.RTx,
pubKeys []*btcec.PublicKey) ([]*btcec.PublicKey, error) {
var missing []*btcec.PublicKey
findMissing := func(readTx kvdb.RTx) error {
nodeMetaBucket := readTx.ReadBucket(nodeInfoBucket)
if nodeMetaBucket == nil {
// If the bucket doesn't exist, all peers are missing.
missing = pubKeys
return nil
}
for _, pubKey := range pubKeys {
_, err := fetchLinkNode(readTx, pubKey)
if err == nil {
// Link node exists.
continue
}
if !errors.Is(err, ErrNodeNotFound) {
return fmt.Errorf("unable to check link node "+
"for peer %x: %w",
pubKey.SerializeCompressed(), err)
}
// Link node doesn't exist.
missing = append(missing, pubKey)
}
return nil
}
// If no transaction provided, create our own.
if tx == nil {
err := kvdb.View(l.backend, findMissing, func() {
missing = nil
})
return missing, err
}
// Use the provided transaction.
err := findMissing(tx)
return missing, err
}
// CreateLinkNodes creates multiple link nodes. If tx is nil, a new write
// transaction will be created. Otherwise, the provided transaction is used,
// allowing this to be part of a larger batch operation.
func (l *LinkNodeDB) CreateLinkNodes(tx kvdb.RwTx,
linkNodes []*LinkNode) error {
createNodes := func(writeTx kvdb.RwTx) error {
nodeMetaBucket, err := writeTx.CreateTopLevelBucket(
nodeInfoBucket,
)
if err != nil {
return err
}
for _, linkNode := range linkNodes {
err := putLinkNode(nodeMetaBucket, linkNode)
if err != nil {
pubKey := linkNode.IdentityPub.
SerializeCompressed()
return fmt.Errorf("unable to create link "+
"node for peer %x: %w", pubKey, err)
}
}
return nil
}
// If no transaction provided, create our own.
if tx == nil {
return kvdb.Update(l.backend, createNodes, func() {})
}
// Use the provided transaction.
return createNodes(tx)
}
// DeleteLinkNode removes the link node with the given identity from the
// database.
func (l *LinkNodeDB) DeleteLinkNode(identity *btcec.PublicKey) error {

View file

@ -8,6 +8,7 @@ import (
"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/wire"
"github.com/lightningnetwork/lnd/kvdb"
"github.com/stretchr/testify/require"
)
@ -129,3 +130,245 @@ func TestDeleteLinkNode(t *testing.T) {
t.Fatal("should not have found link node in db, but did")
}
}
// TestRepairLinkNodes tests that the RepairLinkNodes function correctly
// identifies and repairs missing link nodes for channels that exist in the
// database.
func TestRepairLinkNodes(t *testing.T) {
t.Parallel()
fullDB, err := MakeTestDB(t)
require.NoError(t, err, "unable to make test database")
cdb := fullDB.ChannelStateDB()
// Create a test channel and save it to the database.
channel1 := createTestChannel(t, cdb)
// Manually create a link node for the channel.
linkNode1 := NewLinkNode(
cdb.linkNodeDB, wire.MainNet, channel1.IdentityPub,
)
err = linkNode1.Sync()
require.NoError(t, err, "unable to sync link node")
// Verify that link node was created.
fetchedLinkNode, err := cdb.linkNodeDB.FetchLinkNode(
channel1.IdentityPub,
)
require.NoError(t, err, "link node should exist")
require.NotNil(t, fetchedLinkNode, "link node should not be nil")
// Now, manually delete one of the link nodes to simulate the race
// condition scenario where a link node was incorrectly pruned.
err = cdb.linkNodeDB.DeleteLinkNode(channel1.IdentityPub)
require.NoError(t, err, "unable to delete link node")
// Verify the link node is gone.
_, err = cdb.linkNodeDB.FetchLinkNode(channel1.IdentityPub)
require.ErrorIs(
t, err, ErrNodeNotFound,
"link node should be deleted",
)
// Now run the repair function with the correct network.
err = cdb.RepairLinkNodes(wire.MainNet)
require.NoError(t, err, "repair should succeed")
// Verify that the link node has been restored.
repairedLinkNode, err := cdb.linkNodeDB.FetchLinkNode(
channel1.IdentityPub,
)
require.NoError(t, err, "repaired link node should exist")
require.NotNil(
t, repairedLinkNode, "repaired link node should not be nil",
)
require.Equal(
t, wire.MainNet, repairedLinkNode.Network,
"repaired link node should have correct network",
)
// Run repair again - it should be idempotent and not fail.
err = cdb.RepairLinkNodes(wire.MainNet)
require.NoError(t, err, "second repair should succeed")
// Test with different network to ensure network parameter is used.
err = cdb.linkNodeDB.DeleteLinkNode(channel1.IdentityPub)
require.NoError(t, err, "unable to delete link node")
err = cdb.RepairLinkNodes(wire.TestNet3)
require.NoError(t, err, "repair with testnet should succeed")
repairedLinkNode, err = cdb.linkNodeDB.FetchLinkNode(
channel1.IdentityPub,
)
require.NoError(t, err, "repaired link node should exist")
require.Equal(
t, wire.TestNet3, repairedLinkNode.Network,
"repaired link node should use provided network",
)
}
// TestFindMissingLinkNodes tests the FindMissingLinkNodes method with various
// scenarios.
func TestFindMissingLinkNodes(t *testing.T) {
t.Parallel()
fullDB, err := MakeTestDB(t)
require.NoError(t, err, "unable to make test database")
cdb := fullDB.ChannelStateDB()
// Create three test public keys.
_, pub1 := btcec.PrivKeyFromBytes(key[:])
_, pub2 := btcec.PrivKeyFromBytes(rev[:])
testKey := [32]byte{0x03}
_, pub3 := btcec.PrivKeyFromBytes(testKey[:])
// Test 1: All nodes missing (empty database).
allPubs := []*btcec.PublicKey{pub1, pub2, pub3}
missing, err := cdb.linkNodeDB.FindMissingLinkNodes(nil, allPubs)
require.NoError(t, err, "FindMissingLinkNodes should succeed")
require.Len(t, missing, 3, "all nodes should be missing")
// Test 2: Create one link node, verify only 2 are missing.
node1 := NewLinkNode(cdb.linkNodeDB, wire.MainNet, pub1)
err = node1.Sync()
require.NoError(t, err, "unable to sync link node")
missing, err = cdb.linkNodeDB.FindMissingLinkNodes(nil, allPubs)
require.NoError(t, err, "FindMissingLinkNodes should succeed")
require.Len(t, missing, 2, "two nodes should be missing")
require.Contains(t, missing, pub2, "pub2 should be missing")
require.Contains(t, missing, pub3, "pub3 should be missing")
require.NotContains(t, missing, pub1, "pub1 should exist")
// Test 3: Create remaining nodes, verify none are missing.
node2 := NewLinkNode(cdb.linkNodeDB, wire.MainNet, pub2)
err = node2.Sync()
require.NoError(t, err, "unable to sync link node")
node3 := NewLinkNode(cdb.linkNodeDB, wire.MainNet, pub3)
err = node3.Sync()
require.NoError(t, err, "unable to sync link node")
missing, err = cdb.linkNodeDB.FindMissingLinkNodes(nil, allPubs)
require.NoError(t, err, "FindMissingLinkNodes should succeed")
require.Len(t, missing, 0, "no nodes should be missing")
// Test 4: Use with a provided transaction.
err = cdb.linkNodeDB.DeleteLinkNode(pub2)
require.NoError(t, err, "unable to delete link node")
backend := fullDB.ChannelStateDB().backend
err = kvdb.View(backend, func(tx kvdb.RTx) error {
missing, err := cdb.linkNodeDB.FindMissingLinkNodes(
tx, allPubs,
)
require.NoError(t, err, "FindMissingLinkNodes should succeed")
require.Len(t, missing, 1, "one node should be missing")
require.Contains(t, missing, pub2, "pub2 should be missing")
return nil
}, func() {})
require.NoError(t, err, "transaction should succeed")
// Test 5: Empty input list.
missing, err = cdb.linkNodeDB.FindMissingLinkNodes(nil, nil)
require.NoError(t, err, "FindMissingLinkNodes should succeed")
require.Len(t, missing, 0, "no nodes should be missing for empty input")
}
// TestCreateLinkNodes tests the CreateLinkNodes method with various scenarios.
func TestCreateLinkNodes(t *testing.T) {
t.Parallel()
fullDB, err := MakeTestDB(t)
require.NoError(t, err, "unable to make test database")
cdb := fullDB.ChannelStateDB()
// Create three test public keys and link nodes.
_, pub1 := btcec.PrivKeyFromBytes(key[:])
_, pub2 := btcec.PrivKeyFromBytes(rev[:])
testKey := [32]byte{0x03}
_, pub3 := btcec.PrivKeyFromBytes(testKey[:])
node1 := NewLinkNode(cdb.linkNodeDB, wire.MainNet, pub1)
node2 := NewLinkNode(cdb.linkNodeDB, wire.TestNet3, pub2)
node3 := NewLinkNode(cdb.linkNodeDB, wire.SimNet, pub3)
// Test 1: Create multiple link nodes at once with nil transaction.
nodesToCreate := []*LinkNode{node1, node2, node3}
err = cdb.linkNodeDB.CreateLinkNodes(nil, nodesToCreate)
require.NoError(t, err, "CreateLinkNodes should succeed")
// Verify all nodes were created correctly.
fetchedNode1, err := cdb.linkNodeDB.FetchLinkNode(pub1)
require.NoError(t, err, "node1 should exist")
require.Equal(t, wire.MainNet, fetchedNode1.Network,
"node1 should have correct network")
fetchedNode2, err := cdb.linkNodeDB.FetchLinkNode(pub2)
require.NoError(t, err, "node2 should exist")
require.Equal(t, wire.TestNet3, fetchedNode2.Network,
"node2 should have correct network")
fetchedNode3, err := cdb.linkNodeDB.FetchLinkNode(pub3)
require.NoError(t, err, "node3 should exist")
require.Equal(t, wire.SimNet, fetchedNode3.Network,
"node3 should have correct network")
// Test 2: Create nodes within a provided transaction.
err = cdb.linkNodeDB.DeleteLinkNode(pub2)
require.NoError(t, err, "unable to delete link node")
// Verify node2 is deleted.
_, err = cdb.linkNodeDB.FetchLinkNode(pub2)
require.ErrorIs(t, err, ErrNodeNotFound, "node2 should be deleted")
// Recreate node2 using a provided transaction.
backend := fullDB.ChannelStateDB().backend
err = kvdb.Update(backend, func(tx kvdb.RwTx) error {
return cdb.linkNodeDB.CreateLinkNodes(tx, []*LinkNode{node2})
}, func() {})
require.NoError(t, err, "transaction should succeed")
// Verify node2 was recreated.
fetchedNode2, err = cdb.linkNodeDB.FetchLinkNode(pub2)
require.NoError(t, err, "node2 should exist after recreation")
require.Equal(t, wire.TestNet3, fetchedNode2.Network,
"node2 should have correct network")
// Test 3: Creating nodes that already exist should succeed
// (idempotent behavior).
err = cdb.linkNodeDB.CreateLinkNodes(nil, nodesToCreate)
require.NoError(t, err, "recreating existing nodes should succeed")
// Verify nodes still exist with correct data.
fetchedNode1, err = cdb.linkNodeDB.FetchLinkNode(pub1)
require.NoError(t, err, "node1 should still exist")
require.Equal(t, wire.MainNet, fetchedNode1.Network,
"node1 should still have correct network")
// Test 4: Empty input list.
err = cdb.linkNodeDB.CreateLinkNodes(nil, nil)
require.NoError(
t, err, "CreateLinkNodes with empty list should succeed",
)
// Test 5: Create single node.
testKey4 := [32]byte{0x04}
_, pub4 := btcec.PrivKeyFromBytes(testKey4[:])
node4 := NewLinkNode(cdb.linkNodeDB, wire.MainNet, pub4)
err = cdb.linkNodeDB.CreateLinkNodes(nil, []*LinkNode{node4})
require.NoError(
t, err, "CreateLinkNodes with single node should succeed",
)
fetchedNode4, err := cdb.linkNodeDB.FetchLinkNode(pub4)
require.NoError(t, err, "node4 should exist")
require.Equal(t, wire.MainNet, fetchedNode4.Network,
"node4 should have correct network")
}

View file

@ -1,6 +1,6 @@
module github.com/lightningnetwork/lnd/clock
go 1.19
go 1.24.11
require github.com/stretchr/testify v1.8.2

View file

@ -259,6 +259,58 @@ const (
defaultNoDisconnectOnPongFailure = false
)
// validateMaxOutgoingCltvExpiry validates the configured maximum outgoing CLTV
// expiry against the node's default time lock delta.
func validateMaxOutgoingCltvExpiry(maxCltvExpiry, timeLockDelta uint32) error {
if maxCltvExpiry < timeLockDelta {
return fmt.Errorf(
"max-cltv-expiry must be at least %v", timeLockDelta,
)
}
if maxCltvExpiry > MaxTimeLockDelta {
return fmt.Errorf(
"max-cltv-expiry must be at most %v", MaxTimeLockDelta,
)
}
return nil
}
// validateCltvDeltaBounds validates a CLTV delta against LND's absolute
// supported bounds.
func validateCltvDeltaBounds(delta uint32) error {
if delta < minTimeLockDelta {
return fmt.Errorf("time lock delta of %v is too small, "+
"minimum supported is %v", delta, minTimeLockDelta)
}
if delta > MaxTimeLockDelta {
return fmt.Errorf("time lock delta of %v is too big, "+
"maximum supported is %v", delta, MaxTimeLockDelta)
}
return nil
}
// validateChannelPolicyTimeLockDelta validates an advertised channel policy
// time lock delta against the node's supported forwarding bounds.
func validateChannelPolicyTimeLockDelta(timeLockDelta,
maxOutgoingCltvExpiry uint32) error {
if err := validateCltvDeltaBounds(timeLockDelta); err != nil {
return err
}
if timeLockDelta > maxOutgoingCltvExpiry {
return fmt.Errorf("time lock delta of %v exceeds "+
"max-cltv-expiry of %v", timeLockDelta,
maxOutgoingCltvExpiry)
}
return nil
}
var (
// DefaultLndDir is the default directory where lnd tries to find its
// configuration file and store its data. This is a directory in the
@ -1113,6 +1165,12 @@ func ValidateConfig(cfg Config, interceptor signal.Interceptor, fileParser,
cfg.MaxCommitFeeRateAnchors)
}
if err := validateMaxOutgoingCltvExpiry(
cfg.MaxOutgoingCltvExpiry, cfg.Bitcoin.TimeLockDelta,
); err != nil {
return nil, mkErr("%v", err)
}
// Validate the Tor config parameters.
socks, err := lncfg.ParseAddressString(
cfg.Tor.SOCKS, strconv.Itoa(defaultTorSOCKSPort),

View file

@ -5,6 +5,7 @@ import (
"testing"
"github.com/lightningnetwork/lnd/chainreg"
"github.com/lightningnetwork/lnd/htlcswitch"
"github.com/lightningnetwork/lnd/routing"
"github.com/stretchr/testify/require"
)
@ -117,3 +118,63 @@ func TestSupplyEnvValue(t *testing.T) {
})
}
}
// TestValidateMaxOutgoingCltvExpiry asserts that max-cltv-expiry accepts
// values within its supported bounds and rejects values outside them.
func TestValidateMaxOutgoingCltvExpiry(t *testing.T) {
t.Parallel()
cfg := DefaultConfig()
require.NoError(
t, validateMaxOutgoingCltvExpiry(
htlcswitch.DefaultMaxOutgoingCltvExpiry,
cfg.Bitcoin.TimeLockDelta,
),
)
require.NoError(t, validateMaxOutgoingCltvExpiry(
MaxTimeLockDelta, MaxTimeLockDelta,
))
err := validateMaxOutgoingCltvExpiry(
cfg.Bitcoin.TimeLockDelta-1,
cfg.Bitcoin.TimeLockDelta,
)
require.ErrorContains(t, err, "max-cltv-expiry must be at least")
err = validateMaxOutgoingCltvExpiry(
MaxTimeLockDelta+1, cfg.Bitcoin.TimeLockDelta,
)
require.ErrorContains(t, err, "max-cltv-expiry must be at most")
}
// TestValidateChannelPolicyTimeLockDelta asserts that advertised channel
// policy CLTV deltas stay within the node's supported forwarding bounds.
func TestValidateChannelPolicyTimeLockDelta(t *testing.T) {
t.Parallel()
cfg := DefaultConfig()
require.NoError(t, validateChannelPolicyTimeLockDelta(
cfg.Bitcoin.TimeLockDelta, cfg.MaxOutgoingCltvExpiry,
))
require.NoError(t, validateChannelPolicyTimeLockDelta(
cfg.MaxOutgoingCltvExpiry, cfg.MaxOutgoingCltvExpiry,
))
err := validateChannelPolicyTimeLockDelta(
minTimeLockDelta-1, cfg.MaxOutgoingCltvExpiry,
)
require.ErrorContains(t, err, "time lock delta of")
require.ErrorContains(t, err, "is too small")
err = validateChannelPolicyTimeLockDelta(
MaxTimeLockDelta+1, MaxTimeLockDelta,
)
require.ErrorContains(t, err, "is too big")
err = validateChannelPolicyTimeLockDelta(
cfg.MaxOutgoingCltvExpiry+1, cfg.MaxOutgoingCltvExpiry,
)
require.ErrorContains(t, err, "exceeds max-cltv-expiry")
}

View file

@ -278,9 +278,9 @@ func assertResolversEqual(t *testing.T, originalResolver ContractResolver,
t.Fatalf("expected %v, got %v", ogRes.resolved.Load(),
diskRes.resolved.Load())
}
if ogRes.broadcastHeight != diskRes.broadcastHeight {
if ogRes.confirmHeight != diskRes.confirmHeight {
t.Fatalf("expected %v, got %v",
ogRes.broadcastHeight, diskRes.broadcastHeight)
ogRes.confirmHeight, diskRes.confirmHeight)
}
if ogRes.chanPoint != diskRes.chanPoint {
t.Fatalf("expected %v, got %v", ogRes.chanPoint,
@ -341,8 +341,8 @@ func TestContractInsertionRetrieval(t *testing.T) {
SelfOutputSignDesc: testSignDesc,
MaturityDelay: 99,
},
broadcastHeight: 109,
chanPoint: testChanPoint1,
confirmHeight: 109,
chanPoint: testChanPoint1,
}
commitResolver.resolved.Store(false)

View file

@ -75,6 +75,11 @@ type ChainArbitratorConfig struct {
// htlcs. This value can be lower than the incoming broadcast delta.
OutgoingBroadcastDelta uint32
// CustomHtlcChecker optionally identifies HTLCs that should bypass the
// standard final-hop amount check because their amount validation is
// handled by auxiliary channel logic.
CustomHtlcChecker fn.Option[CustomHtlcChecker]
// NewSweepAddr is a function that returns a new address under control
// by the wallet. We'll use this to sweep any no-delay outputs as a
// result of unilateral channel closes.

View file

@ -1061,7 +1061,8 @@ func (c *chainWatcher) dispatchLocalForceClose(
"detected", c.cfg.chanState.FundingOutpoint)
forceClose, err := lnwallet.NewLocalForceCloseSummary(
c.cfg.chanState, c.cfg.signer, commitSpend.SpendingTx, stateNum,
c.cfg.chanState, c.cfg.signer, commitSpend.SpendingTx,
uint32(commitSpend.SpendingHeight), stateNum,
c.cfg.auxLeafStore, c.cfg.auxResolver,
)
if err != nil {

View file

@ -1437,7 +1437,7 @@ func (c *ChannelArbitrator) sweepAnchors(anchors *lnwallet.AnchorResolutions,
// HTLCs, or,
// - half of the least CLTV from incoming HTLCs if the preimage is available.
//
// We use half of the CTLV value to ensure that we have enough time to sweep
// We use half of the CLTV value to ensure that we have enough time to sweep
// the second-level HTLCs.
//
// It also finds the total value that are time-sensitive, which is the sum of

View file

@ -38,10 +38,10 @@ type commitSweepResolver struct {
// this HTLC on-chain.
commitResolution lnwallet.CommitOutputResolution
// broadcastHeight is the height that the original contract was
// broadcast to the main-chain at. We'll use this value to bound any
// historical queries to the chain for spends/confirmations.
broadcastHeight uint32
// confirmHeight is the block height that the commitment transaction was
// confirmed at. We'll use this value to bound any historical queries to
// the chain for spends/confirmations.
confirmHeight uint32
// chanPoint is the channel point of the original contract.
chanPoint wire.OutPoint
@ -74,13 +74,13 @@ type commitSweepResolver struct {
// newCommitSweepResolver instantiates a new direct commit output resolver.
func newCommitSweepResolver(res lnwallet.CommitOutputResolution,
broadcastHeight uint32, chanPoint wire.OutPoint,
confirmHeight uint32, chanPoint wire.OutPoint,
resCfg ResolverConfig) *commitSweepResolver {
r := &commitSweepResolver{
contractResolverKit: *newContractResolverKit(resCfg),
commitResolution: res,
broadcastHeight: broadcastHeight,
confirmHeight: confirmHeight,
chanPoint: chanPoint,
}
@ -123,37 +123,6 @@ func waitForSpend(op *wire.OutPoint, pkScript []byte, heightHint uint32,
}
}
// getCommitTxConfHeight waits for confirmation of the commitment tx and
// returns the confirmation height.
func (c *commitSweepResolver) getCommitTxConfHeight() (uint32, error) {
txID := c.commitResolution.SelfOutPoint.Hash
signDesc := c.commitResolution.SelfOutputSignDesc
pkScript := signDesc.Output.PkScript
const confDepth = 1
confChan, err := c.Notifier.RegisterConfirmationsNtfn(
&txID, pkScript, confDepth, c.broadcastHeight,
)
if err != nil {
return 0, err
}
defer confChan.Cancel()
select {
case txConfirmation, ok := <-confChan.Confirmed:
if !ok {
return 0, fmt.Errorf("cannot get confirmation "+
"for commit tx %v", txID)
}
return txConfirmation.BlockHeight, nil
case <-c.quit:
return 0, errResolverShuttingDown
}
}
// Resolve instructs the contract resolver to resolve the output on-chain. Once
// the output has been *fully* resolved, the function should return immediately
// with a nil ContractResolver value for the first return value. In the case
@ -268,7 +237,7 @@ func (c *commitSweepResolver) Encode(w io.Writer) error {
if err := binary.Write(w, endian, c.IsResolved()); err != nil {
return err
}
if err := binary.Write(w, endian, c.broadcastHeight); err != nil {
if err := binary.Write(w, endian, c.confirmHeight); err != nil {
return err
}
if _, err := w.Write(c.chanPoint.Hash[:]); err != nil {
@ -308,7 +277,7 @@ func newCommitSweepResolverFromReader(r io.Reader, resCfg ResolverConfig) (
c.markResolved()
}
if err := binary.Read(r, endian, &c.broadcastHeight); err != nil {
if err := binary.Read(r, endian, &c.confirmHeight); err != nil {
return nil, err
}
_, err := io.ReadFull(r, c.chanPoint.Hash[:])
@ -381,19 +350,14 @@ func (c *commitSweepResolver) Launch() error {
return nil
}
confHeight, err := c.getCommitTxConfHeight()
if err != nil {
return err
}
// Wait up until the CSV expires, unless we also have a CLTV that
// expires after.
unlockHeight := confHeight + c.commitResolution.MaturityDelay
unlockHeight := c.confirmHeight + c.commitResolution.MaturityDelay
if c.hasCLTV() {
unlockHeight = max(unlockHeight, c.leaseExpiry)
}
// Update report now that we learned the confirmation height.
// Update report with the calculated maturity height.
c.reportLock.Lock()
c.currentReport.MaturityHeight = unlockHeight
c.reportLock.Unlock()
@ -412,7 +376,7 @@ func (c *commitSweepResolver) Launch() error {
inp = input.NewCsvInputWithCltv(
&c.commitResolution.SelfOutPoint, witnessType,
&c.commitResolution.SelfOutputSignDesc,
c.broadcastHeight, c.commitResolution.MaturityDelay,
c.confirmHeight, c.commitResolution.MaturityDelay,
c.leaseExpiry, input.WithResolutionBlob(
c.commitResolution.ResolutionBlob,
),
@ -421,7 +385,7 @@ func (c *commitSweepResolver) Launch() error {
inp = input.NewCsvInput(
&c.commitResolution.SelfOutPoint, witnessType,
&c.commitResolution.SelfOutputSignDesc,
c.broadcastHeight, c.commitResolution.MaturityDelay,
c.confirmHeight, c.commitResolution.MaturityDelay,
input.WithResolutionBlob(
c.commitResolution.ResolutionBlob,
),

View file

@ -18,6 +18,10 @@ import (
"github.com/stretchr/testify/require"
)
const (
testCommitSweepConfHeight = 99
)
type commitSweepResolverTestContext struct {
resolver *commitSweepResolver
notifier *mock.ChainNotifier
@ -27,7 +31,8 @@ type commitSweepResolverTestContext struct {
}
func newCommitSweepResolverTestContext(t *testing.T,
resolution *lnwallet.CommitOutputResolution) *commitSweepResolverTestContext {
resolution *lnwallet.CommitOutputResolution,
confirmHeight uint32) *commitSweepResolverTestContext {
notifier := &mock.ChainNotifier{
EpochChan: make(chan *chainntnfs.BlockEpoch),
@ -68,7 +73,7 @@ func newCommitSweepResolverTestContext(t *testing.T,
}
resolver := newCommitSweepResolver(
*resolution, 0, wire.OutPoint{}, cfg,
*resolution, confirmHeight, wire.OutPoint{}, cfg,
)
return &commitSweepResolverTestContext{
@ -178,7 +183,9 @@ func TestCommitSweepResolverNoDelay(t *testing.T) {
},
}
ctx := newCommitSweepResolverTestContext(t, &res)
ctx := newCommitSweepResolverTestContext(
t, &res, testCommitSweepConfHeight,
)
// Replace our checkpoint with one which will push reports into a
// channel for us to consume. We replace this function on the resolver
@ -197,15 +204,12 @@ func TestCommitSweepResolverNoDelay(t *testing.T) {
ctx.resolve()
spendTx := &wire.MsgTx{}
spendHash := spendTx.TxHash()
ctx.notifier.ConfChan <- &chainntnfs.TxConfirmation{
Tx: spendTx,
}
// No csv delay, so the input should be swept immediately.
<-ctx.sweeper.sweptInputs
spendTx := &wire.MsgTx{}
spendHash := spendTx.TxHash()
amt := btcutil.Amount(res.SelfOutputSignDesc.Output.Value)
expectedReport := &channeldb.ResolverReport{
OutPoint: wire.OutPoint{},
@ -242,7 +246,10 @@ func testCommitSweepResolverDelay(t *testing.T, sweepErr error) {
SelfOutPoint: outpoint,
}
ctx := newCommitSweepResolverTestContext(t, &res)
// Use confirmHeight = 99, so maturityHeight = 99 + 3 = 102.
ctx := newCommitSweepResolverTestContext(
t, &res, testCommitSweepConfHeight,
)
// Replace our checkpoint with one which will push reports into a
// channel for us to consume. We replace this function on the resolver
@ -270,25 +277,18 @@ func testCommitSweepResolverDelay(t *testing.T, sweepErr error) {
Amount: btcutil.Amount(amt),
LimboBalance: btcutil.Amount(amt),
}
if *report != expectedReport {
t.Fatalf("unexpected resolver report. want=%v got=%v",
expectedReport, report)
}
require.Equal(t, expectedReport, *report)
ctx.resolve()
ctx.notifier.ConfChan <- &chainntnfs.TxConfirmation{
BlockHeight: testInitialBlockHeight - 1,
}
// Allow resolver to process confirmation.
// Allow resolver to launch and update the report.
time.Sleep(sweepProcessInterval)
// Expect report to be updated.
// confirmHeight(99) + maturityDelay(3) = 102.
report = ctx.resolver.report()
if report.MaturityHeight != testInitialBlockHeight+2 {
t.Fatal("report maturity height incorrect")
}
expectedMaturity := testCommitSweepConfHeight + res.MaturityDelay
require.Equal(t, expectedMaturity, report.MaturityHeight)
// Notify initial block height. Although the csv lock is still in
// effect, we expect the input being sent to the sweeper before the csv
@ -325,13 +325,10 @@ func testCommitSweepResolverDelay(t *testing.T, sweepErr error) {
Outpoint: outpoint,
Type: ReportOutputUnencumbered,
Amount: btcutil.Amount(amt),
MaturityHeight: testInitialBlockHeight + 2,
MaturityHeight: testCommitSweepConfHeight + res.MaturityDelay,
RecoveredBalance: expectedRecoveredBalance,
}
if *report != expectedReport {
t.Fatalf("unexpected resolver report. want=%v got=%v",
expectedReport, report)
}
require.Equal(t, expectedReport, *report)
}
// TestCommitSweepResolverDelay tests resolution of a direct commitment output

View file

@ -78,6 +78,31 @@ func (h *htlcIncomingContestResolver) processFinalHtlcFail() error {
return nil
}
// invalidFinalHtlc returns true if the HTLC is an exit-hop HTLC that fails
// final-hop validation.
func (h *htlcIncomingContestResolver) invalidFinalHtlc(
payload *hop.Payload, height uint32) bool {
if payload.FwdInfo.NextHop != hop.Exit {
return false
}
// Custom HTLCs still enforce final CLTV correctness, but leave amount
// validation to auxiliary channel logic.
validateAmount := !fn.MapOptionZ(
h.CustomHtlcChecker,
func(checker CustomHtlcChecker) bool {
return checker.IsCustomHTLC(h.htlc.CustomRecords)
},
)
return hop.ValidateFinalHtlc(
h.htlc.Amt, h.htlcExpiry, height,
invoices.MaxFinalCltvDelta, payload.FwdInfo,
validateAmount,
) != hop.FinalHtlcValid
}
// Launch will call the inner resolver's launch method if the preimage can be
// found, otherwise it's a no-op.
func (h *htlcIncomingContestResolver) Launch() error {
@ -101,7 +126,7 @@ func (h *htlcIncomingContestResolver) Launch() error {
return nil
}
h.log.Debugf("found preimage for htlc=%x, transforming into success "+
h.log.Debugf("found preimage for htlc=%x, transforming into success "+
"resolver and launching it", h.htlc.RHash)
// Once we've applied the preimage, we'll launch the inner resolver to
@ -177,6 +202,32 @@ func (h *htlcIncomingContestResolver) Resolve() (ContractResolver, error) {
log.Debugf("%T(%v): Resolving incoming HTLC(expiry=%v, height=%v)", h,
h.htlcResolution.ClaimOutpoint, h.htlcExpiry, currentHeight)
// If this final-hop HTLC does not match the expected final-hop details,
// keep the on-chain path aligned with link-level handling by recording
// a failed final outcome and leaving timeout resolution to the remote
// party.
if h.invalidFinalHtlc(payload, uint32(currentHeight)) {
log.Infof("%T(%v): final-hop HTLC did not match expected "+
"details (amt=%v, expected_amt=%v, expiry=%v, "+
"expected_expiry=%v, height=%v, max=%v), resolving as "+
"failed", h, h.htlcResolution.ClaimOutpoint,
h.htlc.Amt, payload.FwdInfo.AmountToForward,
h.htlcExpiry, payload.FwdInfo.OutgoingCLTV,
currentHeight, invoices.MaxFinalCltvDelta)
h.markResolved()
if err := h.processFinalHtlcFail(); err != nil {
return nil, err
}
report := h.report().resolverReport(
nil, channeldb.ResolverTypeIncomingHtlc,
channeldb.ResolverOutcomeAbandoned,
)
return nil, h.Checkpoint(h, report)
}
// We'll first check if this HTLC has been timed out, if so, we can
// return now and mark ourselves as resolved. If we're past the point of
// expiry of the HTLC, then at this point the sender can sweep it, so
@ -615,11 +666,21 @@ var _ htlcContractResolver = (*htlcIncomingContestResolver)(nil)
// NOTE: Since we have two places to query the preimage, we need to check both
// the preimage db and the invoice db to look up the preimage.
func (h *htlcIncomingContestResolver) findAndapplyPreimage() (bool, error) {
// Decode the hop payload up front; both the known-preimage path and the
// registry lookup below rely on the decoded final-hop details.
payload, _, err := h.decodePayload()
// Query to see if we already know the preimage.
preimage, ok := h.PreimageDB.LookupPreimage(h.htlc.RHash)
// If the preimage is known, we'll apply it.
if ok {
if err == nil &&
h.invalidFinalHtlc(payload, h.broadcastHeight) {
return false, nil
}
if err := h.applyPreimage(preimage); err != nil {
return false, err
}
@ -628,8 +689,7 @@ func (h *htlcIncomingContestResolver) findAndapplyPreimage() (bool, error) {
return true, nil
}
// First try to parse the payload.
payload, _, err := h.decodePayload()
// Without a preimage we need a valid payload to look up the invoice.
if err != nil {
h.log.Errorf("Cannot decode payload of htlc %v", h.HtlcPoint())
@ -639,11 +699,17 @@ func (h *htlcIncomingContestResolver) findAndapplyPreimage() (bool, error) {
}
// Exit early if this is not the exit hop, which means we are not the
// payment receiver and don't have preimage.
// payment receiver and don't have the preimage.
if payload.FwdInfo.NextHop != hop.Exit {
return false, nil
}
// If this final-hop HTLC does not match the expected final-hop details,
// let Resolve record the failed final outcome.
if h.invalidFinalHtlc(payload, h.broadcastHeight) {
return false, nil
}
// Notify registry that we are potentially resolving as an exit hop
// on-chain. If this HTLC indeed pays to an existing invoice, the
// invoice registry will tell us what to do with the HTLC. This is

View file

@ -9,6 +9,7 @@ import (
sphinx "github.com/lightningnetwork/lightning-onion"
"github.com/lightningnetwork/lnd/chainntnfs"
"github.com/lightningnetwork/lnd/channeldb"
"github.com/lightningnetwork/lnd/fn/v2"
"github.com/lightningnetwork/lnd/graph/db/models"
"github.com/lightningnetwork/lnd/htlcswitch/hop"
"github.com/lightningnetwork/lnd/input"
@ -260,8 +261,95 @@ func TestHtlcIncomingResolverExitCancelHodl(t *testing.T) {
ctx.waitForResult(false)
}
// TestHtlcIncomingResolverInvalidFinalHtlc asserts that an exit-hop HTLC with
// final-hop details outside the expected range resolves without querying the
// invoice registry for a preimage.
func TestHtlcIncomingResolverInvalidFinalHtlc(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
cachePreimage bool
mutate func(*incomingResolverTestContext)
}{{
name: "expiry too far",
mutate: func(ctx *incomingResolverTestContext) {
ctx.resolver.htlcExpiry = testInitialBlockHeight +
invoices.MaxFinalCltvDelta + 1
},
}, {
name: "cached preimage expiry too far",
cachePreimage: true,
mutate: func(ctx *incomingResolverTestContext) {
ctx.resolver.htlcExpiry = testInitialBlockHeight +
invoices.MaxFinalCltvDelta + 1
},
}, {
name: "amount too low",
mutate: func(ctx *incomingResolverTestContext) {
ctx.onionProcessor.forwardAmount = testHtlcAmount + 1
},
}, {
name: "final cltv too low",
mutate: func(ctx *incomingResolverTestContext) {
ctx.onionProcessor.outgoingCltv = testHtlcExpiry + 1
},
}}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()
defer timeout()()
ctx := newIncomingResolverTestContext(t, true)
if testCase.cachePreimage {
ctx.witnessBeacon.lookupPreimage[testResHash] =
testResPreimage
}
testCase.mutate(ctx)
resolution := invoices.NewSettleResolution(
testResPreimage, testResCircuitKey,
testAcceptHeight, invoices.ResultSettled,
)
ctx.registry.notifyResolution = resolution
ctx.resolve()
ctx.waitForResult(false)
require.EqualValues(
t, 0, ctx.registry.notifyCalls.Load(),
)
})
}
}
// TestHtlcIncomingResolverCustomHtlc asserts that a custom HTLC bypasses the
// standard final-hop amount check in contract court, matching the link flow.
func TestHtlcIncomingResolverCustomHtlc(t *testing.T) {
t.Parallel()
defer timeout()()
ctx := newIncomingResolverTestContext(t, true)
ctx.resolver.CustomHtlcChecker = fn.Some[CustomHtlcChecker](
mockCustomHtlcChecker{},
)
ctx.onionProcessor.forwardAmount = testHtlcAmount + 1
ctx.registry.notifyResolution = invoices.NewSettleResolution(
testResPreimage, testResCircuitKey, testAcceptHeight,
invoices.ResultSettled,
)
ctx.resolve()
ctx.waitForResult(true)
require.NotZero(t, ctx.registry.notifyCalls.Load())
}
type mockHopIterator struct {
isExit bool
isExit bool
forwardAmount int
outgoingCltv uint32
hop.Iterator
}
@ -271,11 +359,21 @@ func (h *mockHopIterator) HopPayload() (*hop.Payload, hop.RouteRole, error) {
nextAddress = [8]byte{0x01}
}
forwardAmount := h.forwardAmount
if forwardAmount == 0 {
forwardAmount = 100
}
outgoingCltv := h.outgoingCltv
if outgoingCltv == 0 {
outgoingCltv = 40
}
return hop.NewLegacyPayload(&sphinx.HopData{
Realm: [1]byte{},
NextAddress: nextAddress,
ForwardAmount: 100,
OutgoingCltv: 40,
ForwardAmount: uint64(forwardAmount),
OutgoingCltv: outgoingCltv,
ExtraBytes: [12]byte{},
}), hop.RouteRoleCleartext, nil
}
@ -286,6 +384,8 @@ func (h *mockHopIterator) EncodeNextHop(w io.Writer) error {
type mockOnionProcessor struct {
isExit bool
forwardAmount int
outgoingCltv uint32
offeredOnionBlob []byte
}
@ -298,7 +398,17 @@ func (o *mockOnionProcessor) ReconstructHopIterator(r io.Reader, rHash []byte,
}
o.offeredOnionBlob = data
return &mockHopIterator{isExit: o.isExit}, nil
return &mockHopIterator{
isExit: o.isExit,
forwardAmount: o.forwardAmount,
outgoingCltv: o.outgoingCltv,
}, nil
}
type mockCustomHtlcChecker struct{}
func (m mockCustomHtlcChecker) IsCustomHTLC(lnwire.CustomRecords) bool {
return true
}
type incomingResolverTestContext struct {

View file

@ -37,6 +37,15 @@ type Registry interface {
HodlUnsubscribeAll(subscriber chan<- interface{})
}
// CustomHtlcChecker identifies HTLCs whose final-hop amount validation is
// handled by auxiliary channel logic instead of the standard onion amount
// field.
type CustomHtlcChecker interface {
// IsCustomHTLC returns true if the HTLC carries custom records that
// make it subject to auxiliary HTLC handling.
IsCustomHTLC(htlcRecords lnwire.CustomRecords) bool
}
// OnionProcessor is an interface used to decode onion blobs.
type OnionProcessor interface {
// ReconstructHopIterator attempts to decode a valid sphinx packet from

View file

@ -2,6 +2,7 @@ package contractcourt
import (
"context"
"sync/atomic"
"github.com/lightningnetwork/lnd/graph/db/models"
"github.com/lightningnetwork/lnd/invoices"
@ -21,6 +22,7 @@ type mockRegistry struct {
notifyChan chan notifyExitHopData
notifyErr error
notifyResolution invoices.HtlcResolution
notifyCalls atomic.Int32
}
func (r *mockRegistry) NotifyExitHopHtlc(payHash lntypes.Hash,
@ -29,6 +31,8 @@ func (r *mockRegistry) NotifyExitHopHtlc(payHash lntypes.Hash,
wireCustomRecords lnwire.CustomRecords,
payload invoices.Payload) (invoices.HtlcResolution, error) {
r.notifyCalls.Add(1)
// Exit early if the notification channel is nil.
if hodlChan == nil {
return r.notifyResolution, r.notifyErr

View file

@ -1,6 +1,6 @@
# If you change this please also update GO_VERSION in Makefile (then run
# `make lint` to see where else it needs to be updated as well).
FROM golang:1.25.3-alpine AS builder
FROM golang:1.25.5-alpine AS builder
LABEL maintainer="Olaoluwa Osuntokun <laolu@lightning.engineering>"

View file

@ -365,6 +365,11 @@ func (d *DNSSeedBootstrapper) fallBackSRVLookup(soaShim string,
return nil, err
}
if len(addrs) == 0 {
return nil, fmt.Errorf("no addresses for fallback DNS seed "+
"shim %v", soaShim)
}
// Once we have the IP address, we'll establish a TCP connection using
// port 53.
dnsServer := net.JoinHostPort(addrs[0], "53")
@ -372,6 +377,7 @@ func (d *DNSSeedBootstrapper) fallBackSRVLookup(soaShim string,
if err != nil {
return nil, err
}
_ = conn.SetDeadline(time.Now().Add(d.timeout))
dnsHost := fmt.Sprintf("_nodes._tcp.%v.", targetEndPoint)
dnsConn := &dns.Conn{Conn: conn}
@ -399,7 +405,18 @@ func (d *DNSSeedBootstrapper) fallBackSRVLookup(soaShim string,
// that net.LookupSRV would normally return.
var rrs []*net.SRV
for _, rr := range resp.Answer {
srv := rr.(*dns.SRV)
// The answer section may contain records other than SRV
// (e.g. A or CNAME), so use the comma-ok form to skip any
// non-SRV record instead of panicking on a failed type
// assertion.
srv, ok := rr.(*dns.SRV)
if !ok {
log.Infof("Skipping non-SRV record %T in fallback "+
"DNS seed response for %v", rr, targetEndPoint)
continue
}
rrs = append(rrs, &net.SRV{
Target: srv.Target,
Port: srv.Port,
@ -408,6 +425,11 @@ func (d *DNSSeedBootstrapper) fallBackSRVLookup(soaShim string,
})
}
if len(rrs) == 0 {
return nil, fmt.Errorf("no SRV records in fallback DNS seed "+
"response for %v", targetEndPoint)
}
return rrs, nil
}
@ -481,7 +503,9 @@ search:
bechNodeHost := nodeSrv.Target
addrs, err := d.net.LookupHost(bechNodeHost)
if err != nil {
return nil, err
log.Tracef("Skipping node %v: %v",
bechNodeHost, err)
continue
}
if len(addrs) == 0 {
@ -506,7 +530,9 @@ search:
bechNode := strings.Split(bechNodeHost, ".")
_, nodeBytes5Bits, err := bech32.Decode(bechNode[0])
if err != nil {
return nil, err
log.Tracef("Skipping node %v: %v",
bechNodeHost, err)
continue
}
// Once we have the bech32 decoded pubkey, we'll need
@ -517,11 +543,15 @@ search:
nodeBytes5Bits, 5, 8, false,
)
if err != nil {
return nil, err
log.Tracef("Skipping node %v: %v",
bechNodeHost, err)
continue
}
nodeKey, err := btcec.ParsePubKey(nodeBytes)
if err != nil {
return nil, err
log.Tracef("Skipping node %v: %v",
bechNodeHost, err)
continue
}
// If we have an ignore list, and this node is in the

View file

@ -0,0 +1,225 @@
package discovery
import (
"fmt"
"net"
"testing"
"time"
"github.com/miekg/dns"
"github.com/stretchr/testify/require"
)
// fallbackNet is a tor.Net stub used to drive fallBackSRVLookup. LookupHost
// returns shimAddrs and Dial serves a single DNS response, written by
// serveResp, over an in-memory pipe so the fallback path can be exercised
// without a real DNS server.
type fallbackNet struct {
shimAddrs []string
serveResp func(question *dns.Msg) *dns.Msg
}
// Dial returns one end of an in-memory pipe and spins up a goroutine acting as
// the DNS server on the other end, which reads the SRV query and writes back
// the response produced by serveResp.
func (n *fallbackNet) Dial(_, _ string,
_ time.Duration) (net.Conn, error) {
client, server := net.Pipe()
// Act as the DNS server on the far end of the pipe: read the SRV
// query, then write back the crafted response.
go func() {
srvConn := &dns.Conn{Conn: server}
defer srvConn.Close()
query, err := srvConn.ReadMsg()
if err != nil {
return
}
_ = srvConn.WriteMsg(n.serveResp(query))
}()
return client, nil
}
// LookupHost returns the configured shim addresses used to reach the fallback
// DNS server.
func (n *fallbackNet) LookupHost(_ string) ([]string, error) {
return n.shimAddrs, nil
}
// LookupSRV is unsupported by this stub; the fallback path under test issues
// the SRV query manually over the Dial connection instead.
func (n *fallbackNet) LookupSRV(_, _, _ string,
_ time.Duration) (string, []*net.SRV, error) {
return "", nil, fmt.Errorf("unsupported")
}
// ResolveTCPAddr is unsupported by this stub as it is not exercised by the
// fallback SRV lookup path.
func (n *fallbackNet) ResolveTCPAddr(_, _ string) (*net.TCPAddr, error) {
return nil, fmt.Errorf("unsupported")
}
// TestFallBackSRVLookupSkipsNonSRV ensures a DNS response whose Answer section
// contains non-SRV records (which an on-path attacker or malicious seed can
// inject, since the response is unauthenticated) is filtered rather than
// triggering a type-assertion panic that would crash the daemon.
func TestFallBackSRVLookupSkipsNonSRV(t *testing.T) {
t.Parallel()
const target = "nodes.lightning.directory"
srvTarget := "ln1qexample._nodes._tcp." + target + "."
netStub := &fallbackNet{
shimAddrs: []string{"127.0.0.1"},
serveResp: func(q *dns.Msg) *dns.Msg {
resp := new(dns.Msg)
resp.SetReply(q)
resp.Rcode = dns.RcodeSuccess
// A hostile/malformed Answer section: an A record and a
// CNAME interleaved with a single valid SRV record.
resp.Answer = []dns.RR{
&dns.A{
Hdr: dns.RR_Header{
Name: q.Question[0].Name,
Rrtype: dns.TypeA,
},
A: net.ParseIP("1.2.3.4"),
},
&dns.CNAME{
Hdr: dns.RR_Header{
Name: q.Question[0].Name,
Rrtype: dns.TypeCNAME,
},
Target: "evil.example.",
},
&dns.SRV{
Hdr: dns.RR_Header{
Name: q.Question[0].Name,
Rrtype: dns.TypeSRV,
},
Target: srvTarget,
Port: 9735,
},
}
return resp
},
}
bs := NewDNSSeedBootstrapper(
[][2]string{{target, "soa.lightning.directory"}},
netStub, time.Second,
)
d, ok := bs.(*DNSSeedBootstrapper)
require.True(t, ok)
// The non-SRV records must be skipped, leaving only the valid SRV
// record. Crucially, this must not panic.
srvs, err := d.fallBackSRVLookup("soa.lightning.directory", target)
require.NoError(t, err)
require.Len(t, srvs, 1)
require.Equal(t, srvTarget, srvs[0].Target)
}
// TestFallBackSRVLookupNoSRVRecords ensures a successful DNS response whose
// Answer section holds no SRV records (only CNAME/A entries, or is empty)
// returns an error rather than (nil, nil), so the caller does not mistake "no
// usable records" for a successful query.
func TestFallBackSRVLookupNoSRVRecords(t *testing.T) {
t.Parallel()
const target = "nodes.lightning.directory"
netStub := &fallbackNet{
shimAddrs: []string{"127.0.0.1"},
serveResp: func(q *dns.Msg) *dns.Msg {
resp := new(dns.Msg)
resp.SetReply(q)
resp.Rcode = dns.RcodeSuccess
// Only a non-SRV record is present in the Answer
// section, leaving zero usable SRV targets.
resp.Answer = []dns.RR{
&dns.A{
Hdr: dns.RR_Header{
Name: q.Question[0].Name,
Rrtype: dns.TypeA,
},
A: net.ParseIP("1.2.3.4"),
},
}
return resp
},
}
bs := NewDNSSeedBootstrapper(
[][2]string{{target, "soa.lightning.directory"}},
netStub, time.Second,
)
d, ok := bs.(*DNSSeedBootstrapper)
require.True(t, ok)
srvs, err := d.fallBackSRVLookup("soa.lightning.directory", target)
require.Error(t, err)
require.Empty(t, srvs)
}
// TestFallBackSRVLookupEmptyAnswer ensures a successful DNS response with an
// entirely empty Answer section returns an error rather than (nil, nil), so the
// caller does not mistake an empty response for a successful query.
func TestFallBackSRVLookupEmptyAnswer(t *testing.T) {
t.Parallel()
const target = "nodes.lightning.directory"
netStub := &fallbackNet{
shimAddrs: []string{"127.0.0.1"},
serveResp: func(q *dns.Msg) *dns.Msg {
resp := new(dns.Msg)
resp.SetReply(q)
resp.Rcode = dns.RcodeSuccess
// Leave the Answer section empty.
resp.Answer = nil
return resp
},
}
bs := NewDNSSeedBootstrapper(
[][2]string{{target, "soa.lightning.directory"}},
netStub, time.Second,
)
d, ok := bs.(*DNSSeedBootstrapper)
require.True(t, ok)
srvs, err := d.fallBackSRVLookup("soa.lightning.directory", target)
require.Error(t, err)
require.Empty(t, srvs)
}
// TestFallBackSRVLookupNoShimAddrs ensures an empty LookupHost result for the
// fallback shim returns an error instead of panicking on an out-of-bounds
// index.
func TestFallBackSRVLookupNoShimAddrs(t *testing.T) {
t.Parallel()
netStub := &fallbackNet{shimAddrs: nil}
bs := NewDNSSeedBootstrapper(nil, netStub, time.Second)
d, ok := bs.(*DNSSeedBootstrapper)
require.True(t, ok)
_, err := d.fallBackSRVLookup(
"soa.lightning.directory", "nodes.lightning.directory",
)
require.Error(t, err)
}

View file

@ -5,6 +5,8 @@ import (
"context"
"errors"
"fmt"
"log/slog"
"runtime/debug"
"strings"
"sync"
"sync/atomic"
@ -874,7 +876,14 @@ func (d *AuthenticatedGossiper) stop() {
func (d *AuthenticatedGossiper) ProcessRemoteAnnouncement(ctx context.Context,
msg lnwire.Message, peer lnpeer.Peer) chan error {
errChan := make(chan error, 1)
// Buffer up to two messages on errChan since up to two messages may be
// written and not all callers of this function actually read from
// errChan. Without this buffer goroutines end up blocking on writes to
// errChan, which prevents the gossiper from shutting down cleanly.
//
// TODO(ziggie): Redesign this once the actor model pattern becomes
// available. See https://github.com/lightningnetwork/lnd/pull/9820.
errChan := make(chan error, 2)
// For messages in the known set of channel series queries, we'll
// dispatch the message directly to the GossipSyncer, and skip the main
@ -1515,19 +1524,33 @@ func (d *AuthenticatedGossiper) networkHandler(ctx context.Context) {
// Channel announcement signatures are amongst the only
// messages that we'll process serially.
case *lnwire.AnnounceSignatures1:
emittedAnnouncements, _ := d.processNetworkAnnouncement(
ctx, announcement,
)
log.Debugf("Processed network message %s, "+
"returned len(announcements)=%v",
announcement.msg.MsgType(),
len(emittedAnnouncements))
if emittedAnnouncements != nil {
announcements.AddMsgs(
emittedAnnouncements...,
// Process in an anonymous function so we can
// recover from any panics without crashing the
// main networkHandler goroutine. We pass nil
// for jobID since AnnounceSignatures bypass the
// validation barrier.
func() {
defer d.finalizeGossipProcessing(
ctx, "processing",
announcement, nil,
)
}
//nolint:ll
emittedAnnouncements, _ := d.processNetworkAnnouncement(
ctx, announcement,
)
log.Debugf("Processed network "+
"message %s, returned "+
"len(announcements)=%v",
announcement.msg.MsgType(),
len(emittedAnnouncements))
if emittedAnnouncements != nil {
announcements.AddMsgs(
emittedAnnouncements...,
)
}
}()
continue
}
@ -1610,7 +1633,7 @@ func (d *AuthenticatedGossiper) handleNetworkMessages(ctx context.Context,
nMsg *networkMsg, deDuped *deDupedAnnouncements, jobID JobID) {
defer d.wg.Done()
defer d.vb.CompleteJob()
defer d.finalizeGossipProcessing(ctx, "processing", nMsg, &jobID)
// We should only broadcast this message forward if it originated from
// us or it wasn't received as part of our initial historical sync.
@ -1666,6 +1689,83 @@ func (d *AuthenticatedGossiper) handleNetworkMessages(ctx context.Context,
}
}
// finalizeGossipProcessing handles cleanup for gossip message processing,
// including job completion and panic recovery. It guards gossip goroutines
// against panics to keep the daemon alive. On panic, it logs the error,
// signals dependents, and reports back to the caller if possible.
//
// NOTE: This function MUST be called via defer to recover from panics.
func (d *AuthenticatedGossiper) finalizeGossipProcessing(logCtx context.Context,
ctxStr string, nMsg *networkMsg, jobID *JobID) {
// Always complete the job when provided, regardless of panic state.
// This ensures job slots are returned even if callers forget or
// misordering occurs.
if jobID != nil {
d.vb.CompleteJob()
}
r := recover()
if r == nil {
return
}
msgType := "unknown"
if nMsg != nil && nMsg.msg != nil {
msgType = nMsg.msg.MsgType().String()
}
var peerPub string
if nMsg != nil && nMsg.peer != nil {
peerPub = route.NewVertex(nMsg.peer.IdentityKey()).String()
} else {
peerPub = "unknown"
}
log.ErrorS(logCtx, "Panic during gossip message processing",
fmt.Errorf("%v", r),
slog.String("context", ctxStr),
slog.String("msg_type", msgType),
slog.String("peer", peerPub),
)
// Truncate the stack trace to avoid filling up disk space if an
// attacker repeatedly triggers panics.
const maxStackSize = 8192
stack := debug.Stack()
if len(stack) > maxStackSize {
stack = stack[:maxStackSize]
}
log.DebugS(logCtx, "Panic stack trace",
slog.String("stack", string(stack)),
)
// Signal any dependents waiting on this message so they don't block
// forever.
if nMsg != nil && nMsg.msg != nil && jobID != nil {
if err := d.vb.SignalDependents(
nMsg.msg, *jobID,
); err != nil {
log.ErrorS(logCtx, "SignalDependents after panic failed",
err,
slog.String("msg_type", nMsg.msg.MsgType().String()),
)
}
}
// Send an error back to the caller if possible.
if nMsg != nil && nMsg.err != nil {
select {
case nMsg.err <- fmt.Errorf("panic while %s gossip "+
"message %s: %v", ctxStr, msgType, r):
default:
log.WarnS(logCtx, "Unable to send panic error, "+
"error channel blocked", nil,
slog.String("msg_type", msgType),
)
}
}
}
// TODO(roasbeef): d/c peers that send updates not on our chain
// InitSyncState is called by outside sub-systems when a connection is
@ -2488,6 +2588,22 @@ func (d *AuthenticatedGossiper) handleNodeAnnouncement(ctx context.Context,
"node=%x, source=%x", nMsg.peer, timestamp, nodeAnn.NodeID,
nMsg.source.SerializeCompressed())
// Although not explicitly required by BOLT 7 for node announcements
// (unlike channel updates), we still enforce non-zero timestamps as a
// sanity check. A timestamp of zero is likely indicative of a bug or
// uninitialized message.
if nodeAnn.Timestamp == 0 {
err := fmt.Errorf("rejecting node announcement with zero "+
"timestamp for node %x", nodeAnn.NodeID)
log.Warnf("Rejecting node announcement from peer=%v: %v",
nMsg.peer, err)
nMsg.err <- err
return nil, false
}
// We'll quickly ask the router if it already has a newer update for
// this node so we can skip validating signatures if not required.
if d.cfg.Graph.IsStaleNode(ctx, nodeAnn.NodeID, timestamp) {
@ -3033,6 +3149,27 @@ func (d *AuthenticatedGossiper) handleChanUpdate(ctx context.Context,
// quickly reject it.
timestamp := time.Unix(int64(upd.Timestamp), 0)
// Per BOLT 7, the timestamp MUST be greater than 0.
if upd.Timestamp == 0 {
err := fmt.Errorf("rejecting channel update with zero "+
"timestamp for short_chan_id(%v)", shortChanID)
// Only increase ban score for remote peers.
if nMsg.isRemote {
log.Warnf("Increasing ban score for peer=%v: %v",
nMsg.peer, err)
dcErr := d.handleBadPeer(nMsg.peer)
if dcErr != nil {
err = dcErr
}
}
nMsg.err <- err
return nil, false
}
// Fetch the SCID we should be using to lock the channelMtx and make
// graph queries with.
graphScid, err := d.cfg.FindBaseByAlias(upd.ShortChannelID)

View file

@ -2930,6 +2930,78 @@ func TestExtraDataNodeAnnouncementValidation(t *testing.T) {
require.NoError(t, err, "unable to process announcement")
}
// TestZeroTimestampNodeAnnouncementRejection tests that a NodeAnnouncement with
// a zero timestamp is rejected per BOLT 7.
func TestZeroTimestampNodeAnnouncementRejection(t *testing.T) {
t.Parallel()
ctx := t.Context()
tCtx, err := createTestCtx(t, 0, false)
require.NoError(t, err, "can't create context")
remotePeer := &mockPeer{
remoteKeyPriv1.PubKey(), nil, nil, atomic.Bool{},
}
// Create a node announcement with a zero timestamp.
nodeAnn, err := createNodeAnnouncement(remoteKeyPriv1, 0)
require.NoError(t, err, "can't create node announcement")
// Processing the announcement should fail with a zero timestamp error.
select {
case err = <-tCtx.gossiper.ProcessRemoteAnnouncement(
ctx, nodeAnn, remotePeer,
):
case <-time.After(2 * time.Second):
t.Fatal("did not process remote announcement")
}
require.Error(t, err)
require.Contains(t, err.Error(), "zero timestamp")
}
// TestZeroTimestampChannelUpdateRejection tests that a ChannelUpdate with a
// zero timestamp is rejected per BOLT 7.
func TestZeroTimestampChannelUpdateRejection(t *testing.T) {
t.Parallel()
ctx := t.Context()
tCtx, err := createTestCtx(t, 0, false)
require.NoError(t, err, "can't create context")
remotePeer := &mockPeer{
remoteKeyPriv1.PubKey(), nil, nil, atomic.Bool{},
}
// First, we need to process a channel announcement so that the channel
// update has a valid channel to refer to.
chanAnn, err := tCtx.createRemoteChannelAnnouncement(0)
require.NoError(t, err, "unable to create chan ann")
select {
case err = <-tCtx.gossiper.ProcessRemoteAnnouncement(
ctx, chanAnn, remotePeer,
):
case <-time.After(2 * time.Second):
t.Fatal("did not process remote announcement")
}
require.NoError(t, err, "unable to process chan ann")
// Now create a channel update with a zero timestamp.
chanUpdAnn, err := createUpdateAnnouncement(0, 0, remoteKeyPriv1, 0)
require.NoError(t, err, "unable to create chan update")
// Processing the update should fail with a zero timestamp error.
select {
case err = <-tCtx.gossiper.ProcessRemoteAnnouncement(
ctx, chanUpdAnn, remotePeer,
):
case <-time.After(2 * time.Second):
t.Fatal("did not process remote announcement")
}
require.Error(t, err)
require.Contains(t, err.Error(), "zero timestamp")
}
// assertBroadcast checks that num messages are being broadcasted from the
// gossiper. The broadcasted messages are returned.
func assertBroadcast(t *testing.T, ctx *testCtx, num int) []lnwire.Message {
@ -4854,3 +4926,373 @@ func assertChanChainRejection(t *testing.T, ctx *testCtx,
require.NoError(t, err)
require.True(t, isZombie, "edge should be marked as zombie")
}
// TestRecoverGossipPanic tests that the finalizeGossipProcessing function
// correctly handles panics in gossip goroutines by recovering, logging, and
// sending errors back to callers.
func TestRecoverGossipPanic(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
setupMsg func() (*networkMsg, chan error)
checkError bool
}{
{
name: "panic with full message context",
setupMsg: func() (*networkMsg, chan error) {
errChan := make(chan error, 1)
return &networkMsg{
msg: &lnwire.ChannelUpdate1{
Timestamp: testTimestamp,
},
peer: &mockPeer{
remoteKeyPub1, nil, nil,
atomic.Bool{},
},
err: errChan,
}, errChan
},
checkError: true,
},
{
name: "panic with nil message",
setupMsg: func() (*networkMsg, chan error) {
errChan := make(chan error, 1)
return &networkMsg{
msg: nil,
peer: nil,
err: errChan,
}, errChan
},
checkError: true,
},
{
name: "panic with nil error channel",
setupMsg: func() (*networkMsg, chan error) {
return &networkMsg{
msg: &lnwire.ChannelUpdate1{
Timestamp: testTimestamp,
},
peer: nil,
err: nil,
}, nil
},
checkError: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ctx, err := createTestCtx(t, proofMatureDelta, false)
require.NoError(t, err)
nMsg, errChan := tc.setupMsg()
// Initialize a proper job so CompleteJob has a slot
// to return.
var jobIDRef *JobID
if nMsg.msg != nil {
job, err := ctx.gossiper.vb.InitJobDependencies(
nMsg.msg,
)
require.NoError(t, err)
jobIDRef = &job
}
// Create a function that will panic and then recover.
panicked := make(chan struct{})
go func() {
defer ctx.gossiper.finalizeGossipProcessing(
t.Context(), "testing",
nMsg, jobIDRef,
)
defer close(panicked)
panic("test panic")
}()
// Wait for the goroutine to complete.
select {
case <-panicked:
case <-time.After(time.Second):
t.Fatal("timeout waiting for panic recovery")
}
// If we expect an error to be sent back, verify it.
if tc.checkError {
require.NotNil(t, errChan, "test expects "+
"error but errChan is nil")
}
if tc.checkError && errChan != nil {
select {
case err := <-errChan:
require.Error(t, err)
require.Contains(
t, err.Error(), "panic while",
)
require.Contains(
t, err.Error(), "test panic",
)
case <-time.After(time.Second):
t.Fatal("timeout waiting for error")
}
}
})
}
}
// TestRecoverGossipPanicBlockedErrorChannel verifies that the panic recovery
// does not hang when the error channel is unbuffered and not being read from.
// The recovery should use a non-blocking send with a default case.
func TestRecoverGossipPanicBlockedErrorChannel(t *testing.T) {
t.Parallel()
ctx, err := createTestCtx(t, proofMatureDelta, false)
require.NoError(t, err)
// Create an UNBUFFERED channel and don't read from it.
errChan := make(chan error)
nMsg := &networkMsg{
msg: &lnwire.ChannelUpdate1{Timestamp: testTimestamp},
peer: &mockPeer{remoteKeyPub1, nil, nil, atomic.Bool{}},
err: errChan,
}
// Initialize a proper job so CompleteJob has a slot to return.
jobID, err := ctx.gossiper.vb.InitJobDependencies(nMsg.msg)
require.NoError(t, err)
panicked := make(chan struct{})
go func() {
defer ctx.gossiper.finalizeGossipProcessing(
t.Context(), "testing", nMsg, &jobID,
)
defer close(panicked)
panic("test panic")
}()
// Should not hang - the default case should handle blocked channel.
select {
case <-panicked:
// Success - didn't hang.
case <-time.After(time.Second):
t.Fatal("panic recovery hung on blocked error channel")
}
}
// TestRecoverGossipPanicSignalsDependents verifies that when a parent job
// panics during gossip processing, the panic recovery correctly signals
// dependent jobs via the validation barrier so they don't block forever.
func TestRecoverGossipPanicSignalsDependents(t *testing.T) {
t.Parallel()
ctx, err := createTestCtx(t, proofMatureDelta, false)
require.NoError(t, err)
// Create a channel announcement directly without mocks. We only need
// it to register with the validation barrier.
chanAnn := &lnwire.ChannelAnnouncement1{
ShortChannelID: lnwire.NewShortChanIDFromInt(12345),
NodeID1: [33]byte{0x02},
NodeID2: [33]byte{0x03},
}
// Register the channel announcement as a parent job.
parentJobID, err := ctx.gossiper.vb.InitJobDependencies(chanAnn)
require.NoError(t, err)
// Create a channel update that depends on this channel announcement.
// Channel updates wait for their parent channel announcement.
chanUpdate := &lnwire.ChannelUpdate1{
ShortChannelID: chanAnn.ShortChannelID,
Timestamp: testTimestamp,
}
// Register the channel update as a child job.
childJobID, err := ctx.gossiper.vb.InitJobDependencies(chanUpdate)
require.NoError(t, err)
// Start a goroutine that waits for the parent job to complete.
childDone := make(chan error, 1)
go func() {
err := ctx.gossiper.vb.WaitForParents(childJobID, chanUpdate)
childDone <- err
}()
// Give the child goroutine time to start waiting.
time.Sleep(50 * time.Millisecond)
// Now simulate the parent job panicking and recovering.
// The recovery should call SignalDependents.
errChan := make(chan error, 1)
nMsg := &networkMsg{
msg: chanAnn,
peer: &mockPeer{
remoteKeyPub1, nil, nil, atomic.Bool{},
},
err: errChan,
}
panicked := make(chan struct{})
go func() {
defer ctx.gossiper.finalizeGossipProcessing(
t.Context(), "testing", nMsg, &parentJobID,
)
defer close(panicked)
panic("parent job panic")
}()
// Wait for the panic to be recovered.
select {
case <-panicked:
case <-time.After(time.Second):
t.Fatal("timeout waiting for panic recovery")
}
// Verify error was sent back on the parent's error channel.
select {
case err := <-errChan:
require.Error(t, err)
require.Contains(t, err.Error(), "panic while")
require.Contains(t, err.Error(), "parent job panic")
case <-time.After(time.Second):
t.Fatal("timeout waiting for error on parent")
}
// The child job should now be unblocked because SignalDependents
// was called during panic recovery.
select {
case err := <-childDone:
// Child should complete without error (or with nil if
// parent jobs are now empty).
require.NoError(t, err)
case <-time.After(2 * time.Second):
t.Fatal("child job still blocked - SignalDependents " +
"did not unblock waiting jobs")
}
// Clean up the child job. The parent job was already completed by
// finalizeGossipProcessing.
ctx.gossiper.vb.CompleteJob()
}
// TestRecoverGossipPanicNilJobID verifies that panic recovery works correctly
// when jobID is nil (e.g., for AnnounceSignatures which bypass the validation
// barrier).
func TestRecoverGossipPanicNilJobID(t *testing.T) {
t.Parallel()
ctx, err := createTestCtx(t, proofMatureDelta, false)
require.NoError(t, err)
// Create an announce signatures message (these bypass validation
// barrier and thus have nil jobID in the recovery path).
annSigs := &lnwire.AnnounceSignatures1{
ShortChannelID: lnwire.NewShortChanIDFromInt(12345),
}
errChan := make(chan error, 1)
nMsg := &networkMsg{
msg: annSigs,
peer: &mockPeer{
remoteKeyPub1, nil, nil, atomic.Bool{},
},
err: errChan,
}
// Call finalizeGossipProcessing with nil jobID (simulating the
// AnnounceSignatures serial processing path).
panicked := make(chan struct{})
go func() {
defer ctx.gossiper.finalizeGossipProcessing(
t.Context(), "processing", nMsg, nil,
)
defer close(panicked)
panic("announce signatures panic")
}()
// Wait for panic recovery.
select {
case <-panicked:
case <-time.After(time.Second):
t.Fatal("timeout waiting for panic recovery")
}
// Verify error was sent back.
select {
case err := <-errChan:
require.Error(t, err)
require.Contains(t, err.Error(), "panic while")
require.Contains(t, err.Error(), "announce signatures panic")
case <-time.After(time.Second):
t.Fatal("timeout waiting for error")
}
}
// TestGossiperShutdownWrongChainAnnouncement tests that the gossiper can shut
// down cleanly after processing a channel announcement with the wrong chain
// hash. This is a regression test for a bug where the gossiper would deadlock
// on shutdown because more errors were sent on the error channel than it would
// buffer, and no one was reading those error messages.
//
// In this test we trigger the sending of two error messages:
// 1. First send when rejecting the wrong-chain announcement
// 2. Second send when SignalDependents returns an error
//
// Since the error channel had a buffer of 1, the second send would block
// forever, preventing the goroutine from completing and causing Stop() to hang
// on wg.Wait().
func TestGossiperShutdownWrongChainAnnouncement(t *testing.T) {
t.Parallel()
// Create a test context with the gossiper configured for MainNet.
tCtx, err := createTestCtx(t, 0, false)
require.NoError(t, err)
// Create a channel announcement with:
// 1. Wrong chain hash (SimNet instead of MainNet)
// 2. NodeID1 == NodeID2
//
// The first condition triggers the first error message to be sent, and
// the second condition causes SignalDependents to attempt to remove the
// same dependent job twice, which then triggers the second error
// message to be sent.
wrongChainAnn := &lnwire.ChannelAnnouncement1{
ChainHash: *chaincfg.SimNetParams.GenesisHash,
ShortChannelID: lnwire.ShortChannelID{
BlockHeight: 1,
TxIndex: 0,
TxPosition: 0,
},
Features: testFeatures,
}
// Use the SAME public key for NodeID1 and NodeID2 to trigger the
// second error message.
copy(wrongChainAnn.NodeID1[:], remoteKeyPub1.SerializeCompressed())
copy(wrongChainAnn.NodeID2[:], remoteKeyPub1.SerializeCompressed())
copy(wrongChainAnn.BitcoinKey1[:], bitcoinKeyPub1.SerializeCompressed())
copy(wrongChainAnn.BitcoinKey2[:], bitcoinKeyPub2.SerializeCompressed())
nodePeer := &mockPeer{remoteKeyPub1, nil, nil, atomic.Bool{}}
// Process the announcement without reading from the error channel,
// exactly as Brontide does.
_ = tCtx.gossiper.ProcessRemoteAnnouncement(
t.Context(), wrongChainAnn, nodePeer,
)
// Give the gossiper time to process the announcement.
time.Sleep(100 * time.Millisecond)
// Now stop the gossiper. This should complete without hanging.
// If the bug is present, Stop() will hang forever because a goroutine
// is blocked trying to send to the error channel a second time.
require.NoError(t, tCtx.gossiper.Stop())
}

View file

@ -1,6 +1,6 @@
# If you change this please also update GO_VERSION in Makefile (then run
# `make lint` to see where else it needs to be updated as well).
FROM golang:1.25.3-alpine as builder
FROM golang:1.25.5-alpine as builder
LABEL maintainer="Olaoluwa Osuntokun <laolu@lightning.engineering>"

View file

@ -93,7 +93,7 @@ following build dependencies are required:
### Installing Go
`lnd` is written in Go, with a minimum version of `1.24.9` (or, in case this
`lnd` is written in Go, with a minimum version of `1.24.11` (or, in case this
document gets out of date, whatever the Go version in the main `go.mod` file
requires). To install, run one of the following commands for your OS:
@ -101,15 +101,15 @@ requires). To install, run one of the following commands for your OS:
<summary>Linux (x86-64)</summary>
```
wget https://dl.google.com/go/go1.24.9.linux-amd64.tar.gz
echo "5b7899591c2dd6e9da1809fde4a2fad842c45d3f6b9deb235ba82216e31e34a6 go1.24.9.linux-amd64.tar.gz" | sha256sum --check
wget https://dl.google.com/go/go1.24.11.linux-amd64.tar.gz
echo "bceca00afaac856bc48b4cc33db7cd9eb383c81811379faed3bdbc80edb0af65 go1.24.11.linux-amd64.tar.gz" | sha256sum --check
```
The command above should output `go1.24.9.linux-amd64.tar.gz: OK`. If it
The command above should output `go1.24.11.linux-amd64.tar.gz: OK`. If it
doesn't, then the target REPO HAS BEEN MODIFIED, and you shouldn't install
this version of Go. If it matches, then proceed to install Go:
```
sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf go1.24.9.linux-amd64.tar.gz
sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf go1.24.11.linux-amd64.tar.gz
export PATH=$PATH:/usr/local/go/bin
```
</details>
@ -118,15 +118,15 @@ requires). To install, run one of the following commands for your OS:
<summary>Linux (ARMv6)</summary>
```
wget https://dl.google.com/go/go1.24.9.linux-armv6l.tar.gz
echo "39dafc8e7e5e455995f87e1ffc6b0892302ea519c1f0e59c9e2e0fda41b8aa56 go1.24.9.linux-armv6l.tar.gz" | sha256sum --check
wget https://dl.google.com/go/go1.24.11.linux-armv6l.tar.gz
echo "24d712a7e8ea2f429c05bc67287249e0291f2fe0ea6d6ff268f11b7343ad0f47 go1.24.11.linux-armv6l.tar.gz" | sha256sum --check
```
The command above should output `go1.24.9.linux-armv6l.tar.gz: OK`. If it
The command above should output `go1.24.11.linux-armv6l.tar.gz: OK`. If it
isn't, then the target REPO HAS BEEN MODIFIED, and you shouldn't install
this version of Go. If it matches, then proceed to install Go:
```
sudo rm -rf /usr/local/go && tar -C /usr/local -xzf go1.24.9.linux-armv6l.tar.gz
sudo rm -rf /usr/local/go && tar -C /usr/local -xzf go1.24.11.linux-armv6l.tar.gz
export PATH=$PATH:/usr/local/go/bin
```

View file

@ -150,52 +150,58 @@ probing.
The heuristic examines the structure of route hints provided in the invoice to
identify characteristic LSP patterns. The detection operates on the principle
that LSPs typically maintain private channels to their users and appear as the
penultimate hop in payment routing.
that LSPs typically maintain private channels to their users and appear as
public nodes in the network, while the final destination is private.
```mermaid
flowchart TD
Start([Route Hints Received]) --> Empty{Empty Hints?}
Empty -->|Yes| NotLSP([Not LSP])
Empty -->|No| GetFirst[Get First Hint's Last Hop]
GetFirst --> CheckPub1{Is Channel<br/>Public?}
CheckPub1 -->|Yes| NotLSP
CheckPub1 -->|No| SaveNode[Save Node ID]
SaveNode --> MoreHints{More Hints?}
MoreHints -->|No| IsLSP([Detected as LSP])
Empty -->|No| CheckTarget{Invoice Target<br/>in Graph?}
CheckTarget -->|Yes| NotLSP
CheckTarget -->|No| GetFirstDest[Get First Hint's<br/>Destination Hop]
GetFirstDest --> CheckPub1{Destination Node<br/>in Graph?}
CheckPub1 -->|Yes| IsLSP([Detected as LSP])
CheckPub1 -->|No| MoreHints{More Hints?}
MoreHints -->|No| NotLSP
MoreHints -->|Yes| NextHint[Check Next Hint]
NextHint --> GetLast[Get Last Hop]
GetLast --> CheckPub2{Is Channel<br/>Public?}
CheckPub2 -->|Yes| NotLSP
CheckPub2 -->|No| SameNode{Same Node ID<br/>as First?}
SameNode -->|No| NotLSP
SameNode -->|Yes| MoreHints
NextHint --> GetNextDest[Get Destination Hop]
GetNextDest --> CheckPub2{Destination Node<br/>in Graph?}
CheckPub2 -->|Yes| IsLSP
CheckPub2 -->|No| MoreHints
```
The detection criteria are:
The detection follows three simple rules applied sequentially:
- **All route hints must terminate at the same node ID** - This indicates a
single destination behind potentially multiple LSP entry points
**Rule 1: Public Invoice Target → NOT an LSP**
- If the invoice target (destination) is a public node that exists in the
channel graph, the payment can be routed directly to it
- This means it's not an LSP setup, regardless of what route hints are provided
- Example: A well-connected merchant node with route hints for liquidity
signaling
- **Final hop channels must be private** - The channels in the last hop of
each route hint must not exist in the public channel graph
**Rule 2: Public Destination Hop → IS an LSP**
- If at least one route hint has a destination hop (last hop in the route hint)
that is a public node in the graph, LSP detection is triggered
- This indicates the destination hop is an LSP serving a private client
- The private client is reached through the LSP's private channel
- **No public channels in final hops** - If any route hint contains a public
channel in its final hop, LSP detection is disabled entirely
- **Multiple route hints strengthen detection** - While not required,
multiple hints converging on the same destination strongly suggest an LSP
configuration
**Rule 3: All Private Destination Hops → NOT an LSP**
- If all destination hops in all route hints are private nodes (not in the
public graph), this is not treated as an LSP setup
- The payment will be routed directly to the invoice destination using the
route hints as additional path information
- This is the standard case for private channel payments
This pattern effectively distinguishes LSP configurations from other routing
scenarios. For instance, some Lightning implementations like CLN include route
hints even for public nodes to signal liquidity availability or preferred
routing paths. The heuristic correctly identifies these as non-LSP scenarios by
detecting the presence of public channels.
routing paths. The heuristic correctly identifies these as non-LSP scenarios
by Rule 1 (detecting that the invoice target itself is public).
### How Probing Differs When an LSP is Detected
@ -432,10 +438,6 @@ appropriately.
The `EstimateRouteFee` implementation continues to evolve based on real-world
usage patterns. Ongoing discussions in the LND community focus on:
**Improved LSP Detection**: Developing more sophisticated heuristics that
accurately identify LSP configurations while avoiding false positives for
regular private channels.
**Multi-Path Payment Support**: Extending fee estimation to support MPP
scenarios where payments split across multiple routes.

View file

@ -42,6 +42,16 @@ db.postgres.timeout=0
Connection timeout is disabled, to account for situations where the database
might be slow for unexpected reasons.
Moreover for particular kv tables we also add the option to access the
tables via a global lock (single wirter). This is a temorpary measure until
these particular tables have a native sql schema. This helps to mitigate
resource exhaustion in case LND experiencing high concurrent load:
* `db.postgres.walletdb-with-global-lock=true` to run LND with a single writer
for the walletdb_kv table (default is true).
* `db.postgres.channeldb-with-global-lock=false` to run the channeldb_kv table
with a single writer (default is false).
## Important note about replication
In case a replication architecture is planned, streaming replication should be avoided, as the master does not verify the replica is indeed identical, but it will only forward the edits queue, and let the slave catch up autonomously; synchronous mode, albeit slower, is paramount for `lnd` data integrity across the copies, as it will finalize writes only after the slave confirmed successful replication.

View file

@ -0,0 +1,162 @@
# Release Notes
- [Bug Fixes](#bug-fixes)
- [New Features](#new-features)
- [Functional Enhancements](#functional-enhancements)
- [RPC Additions](#rpc-additions)
- [lncli Additions](#lncli-additions)
- [Improvements](#improvements)
- [Functional Updates](#functional-updates)
- [RPC Updates](#rpc-updates)
- [lncli Updates](#lncli-updates)
- [Breaking Changes](#breaking-changes)
- [Performance Improvements](#performance-improvements)
- [Deprecations](#deprecations)
- [Technical and Architectural Updates](#technical-and-architectural-updates)
- [BOLT Spec Updates](#bolt-spec-updates)
- [Testing](#testing)
- [Database](#database)
- [Code Health](#code-health)
- [Tooling and Documentation](#tooling-and-documentation)
- [Contributors (Alphabetical Order)](#contributors)
# Bug Fixes
* Fix bug where channels with both [policies disabled at startup could never
be used for routing](https://github.com/lightningnetwork/lnd/pull/10378)
* [Fix a case where resolving the
to_local/to_remote output](https://github.com/lightningnetwork/lnd/pull/10387)
might take too long.
* Fix a bug where [repeated network
addresses](https://github.com/lightningnetwork/lnd/pull/10341) were added to
the node announcement and `getinfo` output.
* [Fix source node race
condition](https://github.com/lightningnetwork/lnd/pull/10371) which could
prevent a node from starting up if two goroutines attempt to update the
node's announcement at the same time.
* [Fix timestamp comparison in source node
updates](https://github.com/lightningnetwork/lnd/pull/10449) that could still
cause "sql: no rows in result set" startup errors. The previous fix (#10371)
addressed concurrent updates with equal timestamps, but the seconds-only
comparison could still fail when restarting with different minute/hour values.
* [Fix a startup issue in LND when encountering a
deserialization issue](https://github.com/lightningnetwork/lnd/pull/10383)
in the mission control store. Now we skip over potential errors and also
delete them from the store.
* [Fixed an issue](https://github.com/lightningnetwork/lnd/pull/10399) where the
TLS manager would fail to start if only one of the TLS pair files (certificate
or key) existed. The manager now correctly regenerates both files when either
is missing, preventing "file not found" errors on startup.
* [Fixed race conditions](https://github.com/lightningnetwork/lnd/pull/10433) in
the channel graph database. The `Node.PubKey()` and
`ChannelEdgeInfo.NodeKey1/NodeKey2()` methods had check-then-act races when
caching parsed public keys. Additionally, `DisconnectBlockAtHeight` was
accessing the reject and channel caches without proper locking. The caching
has been removed from the public key parsing methods, and proper mutex
protection has been added to the cache access in `DisconnectBlockAtHeight`.
* [Fix potential sql tx exhaustion
issue](https://github.com/lightningnetwork/lnd/pull/10428) in LND which might
happen when running postgres with a limited number of connections configured.
* Fix a bug where [missing edges for own channels could not be added to the
graph DB](https://github.com/lightningnetwork/lnd/pull/10443)
due to validation checks in the graph Builder that were resurfaced after the
graph refactor work.
* [Add missing payment address/secret when probing an
invoice](https://github.com/lightningnetwork/lnd/pull/10439). This makes sure
the EstimateRouteFee API can probe Eclair and LDK nodes which enforce the
payment address/secret.
* [Fix backwards compatibility for channel edge feature
deserialization](https://github.com/lightningnetwork/lnd/pull/10529). Nodes
upgrading from pre-v0.20 versions could fail to read channel edges from their
graph database due to a format change in how channel features are serialized.
The fix adds automatic format detection to handle both legacy (raw feature
bits) and new (length-prefixed) formats.
* [Fixed a shutdown
deadlock](https://github.com/lightningnetwork/lnd/pull/10540) in the gossiper.
Certain gossip messages could cause multiple error messages to be sent on a
channel that was only expected to be used for a single message. The erring
goroutine would block on the second send, leading to a deadlock at shutdown.
# New Features
## Functional Enhancements
## RPC Additions
## lncli Additions
# Improvements
## Functional Updates
* [Added panic recovery](https://github.com/lightningnetwork/lnd/pull/10470) to
the gossiper's message processing goroutines. This increases the robustness
of the gossiper subsystem by allowing it to continue operating even if a
logic error causes a panic during message processing. The recovery mechanism
ensures dependencies are properly freed and logs the panic trace for
debugging.
## RPC Updates
* The `EstimateRouteFee` RPC now implements an [LSP detection
heuristic](https://github.com/lightningnetwork/lnd/pull/10396) that probes up
to 3 unique Lightning Service Providers when route hints indicate an LSP
setup. The implementation returns worst-case (most expensive) fee estimates
for conservative budgeting and includes griefing protection by limiting the
number of probed LSPs. It enhances the previous LSP design by being more
generic and more flexible.
## lncli Updates
## Breaking Changes
## Performance Improvements
* [Added new Postgres configuration
options](https://github.com/lightningnetwork/lnd/pull/10394)
`db.postgres.channeldb-with-global-lock` and
`db.postgres.walletdb-with-global-lock` to allow fine-grained control over
database concurrency. The channeldb global lock defaults to `false` to enable
concurrent access, while the wallet global lock defaults to `true` to maintain
safe single-writer behavior until the wallet subsystem is fully
concurrent-safe.
* [Modified the query for `IsPublicV1Node`](https://github.com/lightningnetwork/lnd/pull/10356)
to use `UNION ALL` instead of `OR` conditions in the `WHERE` clause, improving
performance when checking for public nodes especially in large graphs when using `SQL` backends.
## Deprecations
# Technical and Architectural Updates
## BOLT Spec Updates
* [Enforce non-zero timestamps](https://github.com/lightningnetwork/lnd/pull/10469)
for `channel_update` (as required by BOLT 7) and `node_announcement` messages.
Gossip messages with zero timestamps are now rejected. For `channel_update`
messages, remote peers sending such invalid messages will have their ban score
incremented.
## Testing
## Database
## Code Health
## Tooling and Documentation
# Contributors (Alphabetical Order)
* Abdulkbk
* bitromortac
* Matt Morehouse
* Ziggie

View file

@ -0,0 +1,85 @@
# Release Notes
- [Bug Fixes](#bug-fixes)
- [New Features](#new-features)
- [Functional Enhancements](#functional-enhancements)
- [RPC Additions](#rpc-additions)
- [lncli Additions](#lncli-additions)
- [Improvements](#improvements)
- [Functional Updates](#functional-updates)
- [RPC Updates](#rpc-updates)
- [lncli Updates](#lncli-updates)
- [Breaking Changes](#breaking-changes)
- [Performance Improvements](#performance-improvements)
- [Deprecations](#deprecations)
- [Technical and Architectural Updates](#technical-and-architectural-updates)
- [BOLT Spec Updates](#bolt-spec-updates)
- [Testing](#testing)
- [Database](#database)
- [Code Health](#code-health)
- [Tooling and Documentation](#tooling-and-documentation)
- [Contributors (Alphabetical Order)](#contributors)
# Bug Fixes
* [Fixed a panic](https://github.com/lightningnetwork/lnd/pull/10914) in the
DNS fallback SRV lookup, which unconditionally type-asserted each DNS Answer
record to `*dns.SRV` and crashed the daemon when the response contained a
non-SRV record. Non-SRV records are now skipped, and an empty `LookupHost`
result for the shim no longer triggers an out-of-bounds index.
- [Fixed on-chain forward interceptor
settlement](https://github.com/lightningnetwork/lnd/pull/10895) after the
incoming channel force closes. Held forwards are now tracked as off-chain or
on-chain entries, allowing an on-chain re-offer to replace the old off-chain
hold so settlement reaches the witness beacon. Go callers of the exported
`htlcswitch.InterceptedPacket` type should use the new `Deadline` field to
distinguish off-chain auto-fail heights from on-chain settlement deadlines,
or `AutoFailHeight()` if they only need the legacy flattened value.
# New Features
## Functional Enhancements
## RPC Additions
## lncli Additions
# Improvements
## Functional Updates
* lnd now [validates the CLTV expiry of HTLCs at the final
hop](https://github.com/lightningnetwork/lnd/pull/10927). A final HTLC whose
CLTV expiry falls outside the node's receive policy is failed back, bringing
the final hop in line with the CLTV delta limits already enforced on the
forwarding path.
As part of this change, the channel policy `TimeLockDelta` is now validated
against LND's supported forwarding bounds: any node that previously set a
per-channel `TimeLockDelta` greater than `2016` (the maximum default value)
will now have its `UpdateChannelPolicy` request rejected, and must lower the
value accordingly below the specified maximum.
## RPC Updates
## lncli Updates
## Breaking Changes
## Performance Improvements
## Deprecations
# Technical and Architectural Updates
## BOLT Spec Updates
## Testing
## Database
## Code Health
## Tooling and Documentation
# Contributors (Alphabetical Order)
* Erick Cestari
* Ziggie

View file

@ -1,6 +1,6 @@
module github.com/lightningnetwork/lnd/fn/v2
go 1.23
go 1.24.11
require (
github.com/stretchr/testify v1.8.1

View file

@ -20,11 +20,11 @@ func TestSomeToOk(t *testing.T) {
}
func TestSomeToOkf(t *testing.T) {
errStr := "err"
require.Equal(t, Some(1).SomeToOkf(errStr), Ok(1))
const errFmt = "missing value: %s"
require.Equal(t, Some(1).SomeToOkf(errFmt, "test"), Ok(1))
require.Equal(
t, None[uint8]().SomeToOkf(errStr),
Err[uint8](fmt.Errorf(errStr)),
t, None[uint8]().SomeToOkf(errFmt, "test"),
Err[uint8](fmt.Errorf(errFmt, "test")),
)
}

View file

@ -1361,7 +1361,7 @@ func (f *Manager) advancePendingChannelState(channel *channeldb.OpenChannel,
}
txid := &channel.FundingOutpoint.Hash
fundingScript, err := makeFundingScript(channel)
fundingScript, err := MakeFundingScript(channel)
if err != nil {
log.Errorf("unable to create funding script for "+
"ChannelPoint(%v): %v",
@ -3037,9 +3037,9 @@ func (f *Manager) waitForFundingWithTimeout(
}
}
// makeFundingScript re-creates the funding script for the funding transaction
// MakeFundingScript re-creates the funding script for the funding transaction
// of the target channel.
func makeFundingScript(channel *channeldb.OpenChannel) ([]byte, error) {
func MakeFundingScript(channel *channeldb.OpenChannel) ([]byte, error) {
localKey := channel.LocalChanCfg.MultiSigKey.PubKey
remoteKey := channel.RemoteChanCfg.MultiSigKey.PubKey
@ -3086,7 +3086,7 @@ func (f *Manager) waitForFundingConfirmation(
// Register with the ChainNotifier for a notification once the funding
// transaction reaches `numConfs` confirmations.
txid := completeChan.FundingOutpoint.Hash
fundingScript, err := makeFundingScript(completeChan)
fundingScript, err := MakeFundingScript(completeChan)
if err != nil {
log.Errorf("unable to create funding script for "+
"ChannelPoint(%v): %v", completeChan.FundingOutpoint,
@ -3802,7 +3802,7 @@ func (f *Manager) annAfterSixConfs(completeChan *channeldb.OpenChannel,
shortChanID.ToUint64(), completeChan.FundingOutpoint,
numConfs)
fundingScript, err := makeFundingScript(completeChan)
fundingScript, err := MakeFundingScript(completeChan)
if err != nil {
return fmt.Errorf("unable to create funding script "+
"for ChannelPoint(%v): %v",

4
go.mod
View file

@ -39,7 +39,7 @@ require (
github.com/lightningnetwork/lnd/healthcheck v1.2.6
github.com/lightningnetwork/lnd/kvdb v1.4.16
github.com/lightningnetwork/lnd/queue v1.1.1
github.com/lightningnetwork/lnd/sqldb v1.0.11
github.com/lightningnetwork/lnd/sqldb v1.0.12-0.20260113193010-8565d12e40b1
github.com/lightningnetwork/lnd/ticker v1.1.1
github.com/lightningnetwork/lnd/tlv v1.3.2
github.com/lightningnetwork/lnd/tor v1.1.6
@ -216,6 +216,6 @@ replace google.golang.org/protobuf => github.com/lightninglabs/protobuf-go-hex-d
// If you change this please also update docs/INSTALL.md and GO_VERSION in
// Makefile (then run `make lint` to see where else it needs to be updated as
// well).
go 1.24.9
go 1.24.11
retract v0.0.2

4
go.sum
View file

@ -382,8 +382,8 @@ github.com/lightningnetwork/lnd/kvdb v1.4.16 h1:9BZgWdDfjmHRHLS97cz39bVuBAqMc4/p
github.com/lightningnetwork/lnd/kvdb v1.4.16/go.mod h1:HW+bvwkxNaopkz3oIgBV6NEnV4jCEZCACFUcNg4xSjM=
github.com/lightningnetwork/lnd/queue v1.1.1 h1:99ovBlpM9B0FRCGYJo6RSFDlt8/vOkQQZznVb18iNMI=
github.com/lightningnetwork/lnd/queue v1.1.1/go.mod h1:7A6nC1Qrm32FHuhx/mi1cieAiBZo5O6l8IBIoQxvkz4=
github.com/lightningnetwork/lnd/sqldb v1.0.11 h1:X8J3OvdIhJVniQG78Qsp3niErl1zdGMTPvzgiLMWOOo=
github.com/lightningnetwork/lnd/sqldb v1.0.11/go.mod h1:oOdZ7vjmAUmI9He+aFHTunnxKVefHZAfJttZdz16hSg=
github.com/lightningnetwork/lnd/sqldb v1.0.12-0.20260113193010-8565d12e40b1 h1:PkEppKL17cZh0Dr9h/T9BEVJUbd/p2tjJ/x8ffG3R0M=
github.com/lightningnetwork/lnd/sqldb v1.0.12-0.20260113193010-8565d12e40b1/go.mod h1:tB2jlqu79TIOR9uhAZOmPxpVFUhB2s+oxKnqRRL1oc0=
github.com/lightningnetwork/lnd/ticker v1.1.1 h1:J/b6N2hibFtC7JLV77ULQp++QLtCwT6ijJlbdiZFbSM=
github.com/lightningnetwork/lnd/ticker v1.1.1/go.mod h1:waPTRAAcwtu7Ji3+3k+u/xH5GHovTsCoSVpho0KDvdA=
github.com/lightningnetwork/lnd/tlv v1.3.2 h1:MO4FCk7F4k5xPMqVZF6Nb/kOpxlwPrUQpYjmyKny5s0=

View file

@ -121,13 +121,9 @@ func (c *GraphCache) AddChannel(info *models.CachedEdgeInfo,
return
}
if policy1 != nil && policy1.IsDisabled() &&
policy2 != nil && policy2.IsDisabled() {
return
}
// Create the edge entry for both nodes.
// Create the edge entry for both nodes. We always add the channel
// structure to the cache, even if both policies are currently disabled,
// so that later policy updates can find and update the channel entry.
c.mtx.Lock()
c.updateOrAddEdge(info.NodeKey1Bytes, &DirectedChannel{
ChannelID: info.ChannelID,
@ -143,6 +139,19 @@ func (c *GraphCache) AddChannel(info *models.CachedEdgeInfo,
})
c.mtx.Unlock()
// Skip adding policies if both are disabled, as the channel is
// currently unusable for routing. However, we still add the channel
// structure above so that policy updates can later enable it.
if policy1 != nil && policy1.IsDisabled() &&
policy2 != nil && policy2.IsDisabled() {
log.Debugf("Skipping policies for channel %v: both "+
"policies are disabled (channel structure still "+
"cached for future updates)", info.ChannelID)
return
}
// The policy's node is always the to_node. So if policy 1 has to_node
// of node 2 then we have the policy 1 as seen from node 1.
if policy1 != nil {
@ -183,14 +192,14 @@ func (c *GraphCache) UpdatePolicy(policy *models.CachedEdgePolicy, fromNode,
updatePolicy := func(nodeKey route.Vertex) {
if len(c.nodeChannels[nodeKey]) == 0 {
log.Warnf("Node=%v not found in graph cache", nodeKey)
log.Debugf("Node=%v not found in graph cache", nodeKey)
return
}
channel, ok := c.nodeChannels[nodeKey][policy.ChannelID]
if !ok {
log.Warnf("Channel=%v not found in graph cache",
log.Debugf("Channel=%v not found in graph cache",
policy.ChannelID)
return

View file

@ -139,3 +139,113 @@ func assertCachedPolicyEqual(t *testing.T, original,
require.Equal(t, original.ToNodePubKey(), cached.ToNodePubKey())
}
}
// TestGraphCacheDisabledPoliciesRegression is a regression test for the bug
// where channels with both policies disabled were not added to the graph cache
// during population, preventing future policy updates from working.
//
// The bug flow was:
// 1. Channel with both policies disabled exists in DB.
// 2. populateCache skips adding it to graph cache entirely.
// 3. Later, a policy update arrives enabling one direction.
// 4. UpdateEdgePolicy updates the DB successfully.
// 5. UpdateEdgePolicy tries to update graph cache but channel not found.
// 6. Channel never becomes usable for routing.
func TestGraphCacheDisabledPoliciesRegression(t *testing.T) {
t.Parallel()
// Create a simple cache instance.
cache := NewGraphCache(10)
// Simulate a channel with both policies disabled.
chanID := uint64(12345)
node1 := pubKey1
node2 := pubKey2
edgeInfo := &models.CachedEdgeInfo{
ChannelID: chanID,
NodeKey1Bytes: node1,
NodeKey2Bytes: node2,
Capacity: 1000000,
}
// Create two disabled policies.
disabledPolicy1 := &models.CachedEdgePolicy{
ChannelID: chanID,
ChannelFlags: lnwire.ChanUpdateDisabled,
}
disabledPolicy2 := &models.CachedEdgePolicy{
ChannelID: chanID,
ChannelFlags: lnwire.ChanUpdateDisabled |
lnwire.ChanUpdateDirection,
}
// Add the channel with both policies disabled (simulating
// populateCache).
cache.AddChannel(edgeInfo, disabledPolicy1, disabledPolicy2)
// Verify the channel structure was added to cache.
var foundChannels []*DirectedChannel
err := cache.ForEachChannel(node1, func(c *DirectedChannel) error {
if c.ChannelID == chanID {
foundChannels = append(foundChannels, c)
}
return nil
})
require.NoError(t, err)
require.Len(t, foundChannels, 1,
"channel structure should be in cache even when both "+
"policies are disabled")
// Verify policies were NOT added (both disabled).
require.False(t, foundChannels[0].OutPolicySet,
"disabled outgoing policy should not be set in cache")
require.Nil(t, foundChannels[0].InPolicy,
"disabled incoming policy should not be set in cache")
// Now simulate receiving a fresh update enabling one direction.
enabledPolicy1 := &models.CachedEdgePolicy{
ChannelID: chanID,
ChannelFlags: 0, // NOT disabled anymore
TimeLockDelta: 40,
MinHTLC: lnwire.MilliSatoshi(1000),
}
// Update the policy (simulating what UpdateEdgePolicy does).
cache.UpdatePolicy(enabledPolicy1, node1, node2)
// Verify the policy update succeeded. Before the fix, UpdatePolicy
// would log "Channel not found in graph cache" and return early,
// so the policy would never be added.
foundChannels = nil
err = cache.ForEachChannel(node1, func(c *DirectedChannel) error {
if c.ChannelID == chanID {
foundChannels = append(foundChannels, c)
}
return nil
})
require.NoError(t, err)
require.Len(t, foundChannels, 1)
// The policy should now be set.
require.True(t, foundChannels[0].OutPolicySet,
"REGRESSION: policy update should work even for channels that "+
"had both policies disabled initially")
// Verify we can also see it from node2's perspective.
foundChannels = nil
err = cache.ForEachChannel(node2, func(c *DirectedChannel) error {
if c.ChannelID == chanID {
foundChannels = append(foundChannels, c)
}
return nil
})
require.NoError(t, err)
require.Len(t, foundChannels, 1)
require.NotNil(t, foundChannels[0].InPolicy,
"incoming policy should be set after policy update")
require.Equal(t, uint16(40), foundChannels[0].InPolicy.TimeLockDelta)
}

View file

@ -406,6 +406,63 @@ func TestSourceNode(t *testing.T) {
compareNodes(t, testNode, sourceNode)
}
// TestSetSourceNodeSameTimestamp tests that SetSourceNode accepts updates
// with the same timestamp. This is necessary because multiple code paths
// (setSelfNode, createNewHiddenService, RPC updates) can race during startup,
// reading the same old timestamp and independently incrementing it to the same
// new value. For our own node, we want parameter changes to persist even with
// timestamp collisions (unlike network gossip where same timestamp means same
// content).
func TestSetSourceNodeSameTimestamp(t *testing.T) {
t.Parallel()
ctx := t.Context()
graph := MakeTestGraph(t)
// Create and set the initial source node.
testNode := createTestVertex(t)
require.NoError(t, graph.SetSourceNode(ctx, testNode))
// Verify the source node was set correctly.
sourceNode, err := graph.SourceNode(ctx)
require.NoError(t, err)
compareNodes(t, testNode, sourceNode)
// Create a modified version of the node with the same timestamp but
// different parameters (e.g., different alias and color). This
// simulates the race condition where multiple goroutines read the
// same old timestamp, independently increment it, and try to update
// with different changes.
modifiedNode := &models.Node{
PubKeyBytes: testNode.PubKeyBytes,
HaveNodeAnnouncement: true,
// Same timestamp.
LastUpdate: testNode.LastUpdate,
// Different alias.
Alias: "different-alias",
Color: color.RGBA{R: 100, G: 200, B: 50, A: 0},
Addresses: testNode.Addresses,
Features: testNode.Features,
AuthSigBytes: testNode.AuthSigBytes,
}
// Attempt to set the source node with the same timestamp but
// different parameters. This should now succeed for both SQL and KV
// stores. The SQL store uses UpsertSourceNode which removes the
// strict timestamp constraint, allowing last-write-wins semantics.
require.NoError(t, graph.SetSourceNode(ctx, modifiedNode))
// Verify that the parameter changes actually persisted.
updatedNode, err := graph.SourceNode(ctx)
require.NoError(t, err)
require.Equal(t, "different-alias", updatedNode.Alias)
require.Equal(
t, color.RGBA{R: 100, G: 200, B: 50, A: 0},
updatedNode.Color,
)
require.Equal(t, testNode.LastUpdate, updatedNode.LastUpdate)
}
// TestEdgeInsertionDeletion tests the basic CRUD operations for channel edges.
func TestEdgeInsertionDeletion(t *testing.T) {
t.Parallel()
@ -1593,6 +1650,8 @@ func TestGraphCacheTraversal(t *testing.T) {
require.Equal(t, numChannels*2*(numNodes-1), numNodeChans)
}
// fillTestGraph fills the graph with a given number of nodes and create a given
// number of channels between each node.
func fillTestGraph(t testing.TB, graph *ChannelGraph, numNodes,
numChannels int) (map[uint64]struct{}, []*models.Node) {
@ -3995,6 +4054,28 @@ func TestNodeIsPublic(t *testing.T) {
)
}
// BenchmarkIsPublicNode measures the performance of IsPublicNode when checking
// a large number of nodes.
func BenchmarkIsPublicNode(b *testing.B) {
graph := MakeTestGraph(b)
// Create a graph with a reasonable number of nodes and channels.
numNodes := 100
numChans := 4
_, nodes := fillTestGraph(b, graph, numNodes, numChans)
// Use deterministic random number generator for reproducible results.
rng := prand.New(prand.NewSource(42))
for b.Loop() {
// Query random nodes to avoid query caching and better
// represent real-world query patterns.
nodePub := nodes[rng.Intn(len(nodes))].PubKeyBytes
_, err := graph.IsPublicNode(nodePub)
require.NoError(b, err)
}
}
// TestDisabledChannelIDs ensures that the disabled channels within the
// disabledEdgePolicyBucket are managed properly and the list returned from
// DisabledChannelIDs is correct.

View file

@ -2116,6 +2116,13 @@ func (c *KVStore) fetchNextChanUpdateBatch(
batch []ChannelEdge
hasMore bool
)
// Acquire read lock before starting transaction to ensure
// consistent lock ordering (cacheMu -> DB) and prevent
// deadlock with write operations.
c.cacheMu.RLock()
defer c.cacheMu.RUnlock()
err := kvdb.View(c.db, func(tx kvdb.RTx) error {
edges := tx.ReadBucket(edgeBucket)
if edges == nil {
@ -2195,9 +2202,7 @@ func (c *KVStore) fetchNextChanUpdateBatch(
continue
}
// Before we read the edge info, we'll see if this
// element is already in the cache or not.
c.cacheMu.RLock()
// Check cache (we already hold shared read lock).
if channel, ok := c.chanCache.get(chanIDInt); ok {
state.edgesSeen[chanIDInt] = struct{}{}
@ -2208,11 +2213,8 @@ func (c *KVStore) fetchNextChanUpdateBatch(
indexKey, _ = updateCursor.Next()
c.cacheMu.RUnlock()
continue
}
c.cacheMu.RUnlock()
// The edge wasn't in the cache, so we'll fetch it along
// w/ the edge policies and nodes.
@ -3439,11 +3441,10 @@ func (c *KVStore) fetchLightningNode(tx kvdb.RTx,
return node, nil
}
// HasLightningNode determines if the graph has a vertex identified by the
// target node identity public key. If the node exists in the database, a
// timestamp of when the data for the node was lasted updated is returned along
// with a true boolean. Otherwise, an empty time.Time is returned with a false
// boolean.
// HasNode determines if the graph has a vertex identified by the target node
// identity public key. If the node exists in the database, a timestamp of when
// the data for the node was lasted updated is returned along with a true
// boolean. Otherwise, an empty time.Time is returned with a false boolean.
func (c *KVStore) HasNode(_ context.Context,
nodePub [33]byte) (time.Time, bool, error) {
@ -4722,6 +4723,78 @@ func fetchChanEdgeInfo(edgeIndex kvdb.RBucket,
return deserializeChanEdgeInfo(edgeInfoReader)
}
// deserializeChanEdgeFeatures deserializes channel edge features from bytes,
// handling both the legacy format (raw feature bits) and the current format
// (2-byte length prefix followed by feature bits).
//
// Legacy format (pre-v0.20): VarBytes containing raw feature bits directly.
// Current format (v0.20+): VarBytes containing a 2-byte big-endian length
// followed by the feature bits.
//
// The format is detected by checking if the first 2 bytes, interpreted as a
// big-endian uint16 length, equals len(featureBytes)-2. Since this length
// check alone could have false positives (e.g., a 258-byte legacy vector
// starting with 0x01, 0x00), we additionally verify by decoding and
// re-encoding the payload to confirm it produces the exact same bytes
// (canonical encoding check).
func deserializeChanEdgeFeatures(featureBytes []byte) (*lnwire.FeatureVector,
error) {
features := lnwire.NewRawFeatureVector()
// Empty features are valid in both formats.
if len(featureBytes) == 0 {
return lnwire.NewFeatureVector(features, lnwire.Features), nil
}
// Check if this looks like the new format with a 2-byte length prefix.
// In the new format, the first 2 bytes encode the length of the
// remaining feature bytes.
if len(featureBytes) >= 2 {
encodedLen := binary.BigEndian.Uint16(featureBytes[:2])
if int(encodedLen) == len(featureBytes)-2 {
// This looks like it could be the new format. To be
// certain, we decode and re-encode to verify canonical
// encoding, as a legacy feature vector could
// accidentally match the length check (e.g., a 258-byte
// legacy vector starting with 0x01, 0x00 would have
// 256 == 258 - 2).
payload := featureBytes[2:]
tempFeatures := lnwire.NewRawFeatureVector()
err := tempFeatures.DecodeBase256(
bytes.NewReader(payload), int(encodedLen),
)
var checkBuf bytes.Buffer
if err == nil {
err = tempFeatures.EncodeBase256(&checkBuf)
}
// If there were no errors and the re-encoded payload
// matches the original, we are confident it's the new
// format.
isCanonical := bytes.Equal(checkBuf.Bytes(), payload)
if err == nil && isCanonical {
return lnwire.NewFeatureVector(
tempFeatures, lnwire.Features,
), nil
}
}
}
// Legacy format: the bytes are raw feature bits without a length
// prefix.
err := features.DecodeBase256(
bytes.NewReader(featureBytes), len(featureBytes),
)
if err != nil {
return nil, fmt.Errorf("unable to decode features "+
"(legacy format): %w", err)
}
return lnwire.NewFeatureVector(features, lnwire.Features), nil
}
func deserializeChanEdgeInfo(r io.Reader) (models.ChannelEdgeInfo, error) {
var (
err error
@ -4746,13 +4819,10 @@ func deserializeChanEdgeInfo(r io.Reader) (models.ChannelEdgeInfo, error) {
return models.ChannelEdgeInfo{}, err
}
features := lnwire.NewRawFeatureVector()
err = features.Decode(bytes.NewReader(featureBytes))
edgeInfo.Features, err = deserializeChanEdgeFeatures(featureBytes)
if err != nil {
return models.ChannelEdgeInfo{}, fmt.Errorf("unable to decode "+
"features: %w", err)
return models.ChannelEdgeInfo{}, err
}
edgeInfo.Features = lnwire.NewFeatureVector(features, lnwire.Features)
proof := &models.ChannelAuthProof{}

View file

@ -0,0 +1,449 @@
package graphdb
import (
"bytes"
"encoding/binary"
"testing"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/stretchr/testify/require"
"pgregory.net/rapid"
)
// TestDeserializeChanEdgeFeaturesEmpty tests that empty feature bytes are
// handled correctly for both legacy and new formats.
func TestDeserializeChanEdgeFeaturesEmpty(t *testing.T) {
t.Parallel()
// Empty bytes should result in empty features.
features, err := deserializeChanEdgeFeatures(nil)
require.NoError(t, err)
require.True(t, features.IsEmpty())
features, err = deserializeChanEdgeFeatures([]byte{})
require.NoError(t, err)
require.True(t, features.IsEmpty())
// New format with zero-length features: [0x00, 0x00].
features, err = deserializeChanEdgeFeatures([]byte{0x00, 0x00})
require.NoError(t, err)
require.True(t, features.IsEmpty())
}
// TestDeserializeChanEdgeFeaturesLegacyFormat tests deserialization of
// feature bytes written in the legacy format (pre-v0.20), which contains
// raw feature bits without a 2-byte length prefix.
func TestDeserializeChanEdgeFeaturesLegacyFormat(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
legacyBytes []byte
expectedFeats []lnwire.FeatureBit
}{
{
name: "single byte - bit 0",
legacyBytes: []byte{0x01}, // bit 0 set
expectedFeats: []lnwire.FeatureBit{0},
},
{
name: "single byte - bit 7",
legacyBytes: []byte{0x80}, // bit 7 set
expectedFeats: []lnwire.FeatureBit{7},
},
{
name: "single byte - multiple bits",
legacyBytes: []byte{0x25}, // bits 0, 2, 5 set
expectedFeats: []lnwire.FeatureBit{0, 2, 5},
},
{
name: "two bytes - bit 8",
legacyBytes: []byte{0x01, 0x00}, // bit 8 set
expectedFeats: []lnwire.FeatureBit{8},
},
{
name: "two bytes - bits 0 and 15",
legacyBytes: []byte{0x80, 0x01}, // bits 0 and 15 set
expectedFeats: []lnwire.FeatureBit{0, 15},
},
{
// bit 1 (DataLossProtectOptional).
name: "common features - data loss protect",
legacyBytes: []byte{0x02},
expectedFeats: []lnwire.FeatureBit{
lnwire.DataLossProtectOptional,
},
},
{
// bits 1, 7, 9, 13, 15 = DataLossProtect,
// GossipQueries, TLVOnion, StaticRemoteKey,
// PaymentAddr.
name: "multiple common features",
legacyBytes: []byte{0xA2, 0x82},
expectedFeats: []lnwire.FeatureBit{1, 7, 9, 13, 15},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
features, err := deserializeChanEdgeFeatures(
tc.legacyBytes,
)
require.NoError(t, err)
for _, bit := range tc.expectedFeats {
require.True(t, features.IsSet(bit),
"expected bit %d to be set", bit)
}
// Verify no extra bits are set by creating expected
// feature vector and comparing.
expectedRaw := lnwire.NewRawFeatureVector(
tc.expectedFeats...,
)
require.True(t, expectedRaw.Equals(
features.RawFeatureVector),
"feature vectors don't match")
})
}
}
// TestDeserializeChanEdgeFeaturesNewFormat tests deserialization of
// feature bytes written in the new format (v0.20+), which contains
// a 2-byte big-endian length prefix followed by raw feature bits.
func TestDeserializeChanEdgeFeaturesNewFormat(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
expectedFeats []lnwire.FeatureBit
}{
{
name: "empty features",
expectedFeats: nil,
},
{
name: "single feature bit 0",
expectedFeats: []lnwire.FeatureBit{0},
},
{
name: "single feature bit 15",
expectedFeats: []lnwire.FeatureBit{15},
},
{
name: "multiple features",
expectedFeats: []lnwire.FeatureBit{1, 5, 9, 13, 17},
},
{
name: "common lightning features",
expectedFeats: []lnwire.FeatureBit{
lnwire.DataLossProtectOptional,
lnwire.GossipQueriesOptional,
lnwire.TLVOnionPayloadOptional,
lnwire.StaticRemoteKeyOptional,
lnwire.PaymentAddrOptional,
},
},
{
name: "high bit features",
expectedFeats: []lnwire.FeatureBit{
lnwire.AMPOptional, // 31
lnwire.KeysendOptional, // 55
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Create feature vector and encode in new format.
rawFeatures := lnwire.NewRawFeatureVector(
tc.expectedFeats...,
)
fv := lnwire.NewFeatureVector(
rawFeatures, lnwire.Features,
)
// Encode using the new format (with length prefix).
var buf bytes.Buffer
err := fv.Encode(&buf)
require.NoError(t, err)
// Deserialize and verify.
features, err := deserializeChanEdgeFeatures(
buf.Bytes(),
)
require.NoError(t, err)
for _, bit := range tc.expectedFeats {
require.True(t, features.IsSet(bit),
"expected bit %d to be set", bit)
}
// Verify feature equality.
require.True(t, rawFeatures.Equals(
features.RawFeatureVector),
)
})
}
}
// TestDeserializeChanEdgeFeaturesFormatDetection tests that the format
// detection correctly distinguishes between legacy and new formats.
func TestDeserializeChanEdgeFeaturesFormatDetection(t *testing.T) {
t.Parallel()
// Test that legacy format bytes that could theoretically be confused
// with new format are handled correctly. This shouldn't happen in
// practice because in legacy format the first byte always has at least
// one bit set (the highest feature bit determines the byte length).
// Create a feature vector with bit 8 set (requires 2 bytes in legacy).
// Legacy format: [0x01, 0x00] (big-endian, high byte first).
// As a length, 0x0100 = 256, which != 0 (len-2), so correctly detected
// as legacy.
legacyBit8 := []byte{0x01, 0x00}
features, err := deserializeChanEdgeFeatures(legacyBit8)
require.NoError(t, err)
require.True(t, features.IsSet(8))
require.False(t, features.IsSet(0))
// New format with bit 8: [0x00, 0x02, 0x01, 0x00]
// Length prefix 0x0002 = 2, remaining 2 bytes = feature bits.
newFormatBit8 := []byte{0x00, 0x02, 0x01, 0x00}
features, err = deserializeChanEdgeFeatures(newFormatBit8)
require.NoError(t, err)
require.True(t, features.IsSet(8))
require.False(t, features.IsSet(0))
// Test single byte legacy format - cannot be confused with new format
// since new format minimum is 2 bytes (the length prefix).
legacyBit0 := []byte{0x01}
features, err = deserializeChanEdgeFeatures(legacyBit0)
require.NoError(t, err)
require.True(t, features.IsSet(0))
}
// TestDeserializeChanEdgeFeaturesRoundTrip tests that features can be
// serialized and deserialized correctly using the new format.
func TestDeserializeChanEdgeFeaturesRoundTrip(t *testing.T) {
t.Parallel()
testFeatureSets := [][]lnwire.FeatureBit{
{},
{0},
{7},
{8},
{15},
{0, 1, 2, 3, 4, 5, 6, 7},
{8, 9, 10, 11, 12, 13, 14, 15},
{0, 8, 16, 24, 32},
{
lnwire.DataLossProtectOptional,
lnwire.GossipQueriesOptional,
lnwire.TLVOnionPayloadOptional,
lnwire.StaticRemoteKeyOptional,
lnwire.PaymentAddrOptional,
lnwire.MPPOptional,
lnwire.AnchorsZeroFeeHtlcTxOptional,
},
}
for _, featureBits := range testFeatureSets {
// Create and encode.
rawFeatures := lnwire.NewRawFeatureVector(featureBits...)
fv := lnwire.NewFeatureVector(rawFeatures, lnwire.Features)
var buf bytes.Buffer
err := fv.Encode(&buf)
require.NoError(t, err)
// Deserialize.
decoded, err := deserializeChanEdgeFeatures(buf.Bytes())
require.NoError(t, err)
// Verify equality.
require.True(t, rawFeatures.Equals(decoded.RawFeatureVector),
"mismatch for features %v", featureBits)
}
}
// TestDeserializeChanEdgeFeaturesPropertyBased uses property-based testing
// to verify that the deserialization works correctly for arbitrary feature
// combinations in both legacy and new formats.
func TestDeserializeChanEdgeFeaturesPropertyBased(t *testing.T) {
t.Parallel()
// Test legacy format: raw feature bytes without length prefix.
rapid.Check(t, func(t *rapid.T) {
// Generate random feature bits (max 256 to keep reasonable).
numFeatures := rapid.IntRange(0, 20).Draw(t, "numFeatures")
featureBits := make([]lnwire.FeatureBit, numFeatures)
for i := 0; i < numFeatures; i++ {
featureBits[i] = lnwire.FeatureBit(
rapid.IntRange(0, 255).Draw(t, "featureBit"),
)
}
// Create feature vector.
rawFeatures := lnwire.NewRawFeatureVector(featureBits...)
// Encode without length prefix (legacy format).
var buf bytes.Buffer
err := rawFeatures.EncodeBase256(&buf)
require.NoError(t, err)
// Deserialize.
decoded, err := deserializeChanEdgeFeatures(buf.Bytes())
require.NoError(t, err)
// Verify equality.
require.True(t, rawFeatures.Equals(decoded.RawFeatureVector))
})
// Test new format: with length prefix.
rapid.Check(t, func(t *rapid.T) {
// Generate random feature bits.
numFeatures := rapid.IntRange(0, 20).Draw(t, "numFeatures")
featureBits := make([]lnwire.FeatureBit, numFeatures)
for i := 0; i < numFeatures; i++ {
featureBits[i] = lnwire.FeatureBit(
rapid.IntRange(0, 255).Draw(t, "featureBit"),
)
}
// Create feature vector.
rawFeatures := lnwire.NewRawFeatureVector(featureBits...)
fv := lnwire.NewFeatureVector(rawFeatures, lnwire.Features)
// Encode with length prefix (new format).
var buf bytes.Buffer
err := fv.Encode(&buf)
require.NoError(t, err)
// Deserialize.
decoded, err := deserializeChanEdgeFeatures(buf.Bytes())
require.NoError(t, err)
// Verify equality.
require.True(t, rawFeatures.Equals(decoded.RawFeatureVector))
})
}
// TestDeserializeChanEdgeFeaturesLegacyFormatNoCollision verifies that
// the format detection cannot have false positives where legacy format
// bytes are incorrectly detected as new format.
func TestDeserializeChanEdgeFeaturesLegacyFormatNoCollision(t *testing.T) {
t.Parallel()
// The detection works through canonical encoding verification.
// Even if a legacy vector accidentally matches the length check
// (e.g., a 258-byte vector starting with 0x01, 0x00), the decode/
// re-encode check will fail because the legacy encoding won't be
// canonical when interpreted as new format payload.
rapid.Check(t, func(t *rapid.T) {
// Generate feature bits with higher range to catch more edge
// cases, including vectors that could match the length check.
maxBit := rapid.IntRange(0, 2200).Draw(t, "maxBit")
numExtra := rapid.IntRange(0, 10).Draw(t, "numExtra")
featureBits := []lnwire.FeatureBit{lnwire.FeatureBit(maxBit)}
for i := 0; i < numExtra; i++ {
bit := rapid.IntRange(0, maxBit).Draw(t, "extraBit")
featureBits = append(featureBits,
lnwire.FeatureBit(bit))
}
rawFeatures := lnwire.NewRawFeatureVector(featureBits...)
// Encode in legacy format.
var buf bytes.Buffer
err := rawFeatures.EncodeBase256(&buf)
require.NoError(t, err)
legacyBytes := buf.Bytes()
if len(legacyBytes) < 2 {
// Single byte can't be confused with new format.
return
}
// Verify deserialization still works correctly regardless of
// whether the length check happens to match.
decoded, err := deserializeChanEdgeFeatures(legacyBytes)
require.NoError(t, err)
require.True(t, rawFeatures.Equals(decoded.RawFeatureVector),
"mismatch for legacy bytes %x with maxBit %d",
legacyBytes, maxBit)
})
}
// TestDeserializeChanEdgeFeaturesLengthCheckCollision specifically tests the
// edge case where a legacy feature vector accidentally satisfies the length
// check condition (first 2 bytes as uint16 == len - 2). This can happen with
// a 258-byte vector starting with 0x01, 0x00, where 256 == 258 - 2.
// The canonical encoding verification should correctly identify this as legacy
// format.
func TestDeserializeChanEdgeFeaturesLengthCheckCollision(t *testing.T) {
t.Parallel()
// Create a legacy feature vector that will produce bytes where the
// first two bytes, interpreted as a length, equal len - 2.
//
// To get 258 bytes in legacy format, we need bit 2063 set (258*8-1).
// The first byte will be 0x01 (bit 2056 is in byte 0, and we need
// bit 2063 which is 0x80, but the bytes are big-endian so byte 0
// contains the high bits). Actually let's work this out:
//
// For 258 bytes, bits 2056-2063 are in byte 0.
// Setting bit 2056 gives byte[0] = 0x01.
// If byte[0] = 0x01 and byte[1] = 0x00, then as uint16 = 256 = 258-2.
//
// So we need: bit 2056 set (gives 0x01 in byte 0), and no bits in
// byte 1 set (bits 2048-2055), and at least one bit set below to
// ensure we have full 258 bytes (bit 0 to ensure byte 257 is non-zero
// won't work since it affects the last byte...).
//
// Actually the encoding is that the first byte contains the HIGHEST
// bits. So for 258 bytes:
// - byte[0] contains bits 2056-2063
// - byte[1] contains bits 2048-2055
// - ...
// - byte[257] contains bits 0-7
//
// To get byte[0] = 0x01 and byte[1] = 0x00:
// - Set bit 2056 (gives 0x01 in byte 0)
// - Don't set bits 2048-2055 (keeps byte 1 = 0x00)
//
// We also need to set some lower bit to have meaningful features.
featureBits := []lnwire.FeatureBit{
2056, // This gives 0x01 in first byte (258 bytes total)
0, // Set bit 0 for a meaningful feature
}
rawFeatures := lnwire.NewRawFeatureVector(featureBits...)
// Encode in legacy format.
var buf bytes.Buffer
err := rawFeatures.EncodeBase256(&buf)
require.NoError(t, err)
legacyBytes := buf.Bytes()
require.Len(t, legacyBytes, 258, "expected 258 bytes for bit 2056")
// Verify the collision condition: first 2 bytes as uint16 == len - 2.
encodedLen := binary.BigEndian.Uint16(legacyBytes[:2])
require.Equal(t, uint16(256), encodedLen,
"expected first 2 bytes to encode 256")
require.Equal(t, 256, len(legacyBytes)-2,
"expected length check to match")
// Despite the length check matching, deserialization should still
// correctly identify this as legacy format (via canonical encoding
// verification) and decode it properly.
decoded, err := deserializeChanEdgeFeatures(legacyBytes)
require.NoError(t, err)
require.True(t, decoded.IsSet(2056), "bit 2056 should be set")
require.True(t, decoded.IsSet(0), "bit 0 should be set")
require.True(t, rawFeatures.Equals(decoded.RawFeatureVector),
"feature vectors should match")
}

View file

@ -123,7 +123,7 @@ type ForwardingPolicy struct {
// create the time-lock value for the forwarded outgoing HTLC. The
// following constraint MUST hold for an HTLC to be forwarded:
//
// * incomingHtlc.timeLock - timeLockDelta = fwdInfo.OutgoingCTLV
// * incomingHtlc.timeLock - timeLockDelta = fwdInfo.OutgoingCLTV
//
// where fwdInfo is the forwarding information extracted from the
// per-hop payload of the incoming HTLC's onion packet.

View file

@ -33,11 +33,9 @@ type ChannelEdgeInfo struct {
// NodeKey1Bytes is the raw public key of the first node.
NodeKey1Bytes [33]byte
nodeKey1 *btcec.PublicKey
// NodeKey2Bytes is the raw public key of the first node.
NodeKey2Bytes [33]byte
nodeKey2 *btcec.PublicKey
// BitcoinKey1Bytes is the raw public key of the first node.
BitcoinKey1Bytes [33]byte
@ -84,10 +82,8 @@ type ChannelEdgeInfo struct {
func (c *ChannelEdgeInfo) AddNodeKeys(nodeKey1, nodeKey2, bitcoinKey1,
bitcoinKey2 *btcec.PublicKey) {
c.nodeKey1 = nodeKey1
copy(c.NodeKey1Bytes[:], c.nodeKey1.SerializeCompressed())
copy(c.NodeKey1Bytes[:], nodeKey1.SerializeCompressed())
c.nodeKey2 = nodeKey2
copy(c.NodeKey2Bytes[:], nodeKey2.SerializeCompressed())
c.bitcoinKey1 = bitcoinKey1
@ -101,42 +97,16 @@ func (c *ChannelEdgeInfo) AddNodeKeys(nodeKey1, nodeKey2, bitcoinKey1,
// the creation of this channel. A node is considered "first" if the
// lexicographical ordering the its serialized public key is "smaller" than
// that of the other node involved in channel creation.
//
// NOTE: By having this method to access an attribute, we ensure we only need
// to fully deserialize the pubkey if absolutely necessary.
func (c *ChannelEdgeInfo) NodeKey1() (*btcec.PublicKey, error) {
if c.nodeKey1 != nil {
return c.nodeKey1, nil
}
key, err := btcec.ParsePubKey(c.NodeKey1Bytes[:])
if err != nil {
return nil, err
}
c.nodeKey1 = key
return key, nil
return btcec.ParsePubKey(c.NodeKey1Bytes[:])
}
// NodeKey2 is the identity public key of the "second" node that was involved in
// the creation of this channel. A node is considered "second" if the
// lexicographical ordering the its serialized public key is "larger" than that
// of the other node involved in channel creation.
//
// NOTE: By having this method to access an attribute, we ensure we only need
// to fully deserialize the pubkey if absolutely necessary.
func (c *ChannelEdgeInfo) NodeKey2() (*btcec.PublicKey, error) {
if c.nodeKey2 != nil {
return c.nodeKey2, nil
}
key, err := btcec.ParsePubKey(c.NodeKey2Bytes[:])
if err != nil {
return nil, err
}
c.nodeKey2 = key
return key, nil
return btcec.ParsePubKey(c.NodeKey2Bytes[:])
}
// BitcoinKey1 is the Bitcoin multi-sig key belonging to the first node, that

View file

@ -18,7 +18,6 @@ import (
type Node struct {
// PubKeyBytes is the raw bytes of the public key of the target node.
PubKeyBytes [33]byte
pubKey *btcec.PublicKey
// HaveNodeAnnouncement indicates whether we received a node
// announcement for this particular node. If true, the remaining fields
@ -62,21 +61,8 @@ type Node struct {
// PubKey is the node's long-term identity public key. This key will be used to
// authenticated any advertisements/updates sent by the node.
//
// NOTE: By having this method to access an attribute, we ensure we only need
// to fully deserialize the pubkey if absolutely necessary.
func (l *Node) PubKey() (*btcec.PublicKey, error) {
if l.pubKey != nil {
return l.pubKey, nil
}
key, err := btcec.ParsePubKey(l.PubKeyBytes[:])
if err != nil {
return nil, err
}
l.pubKey = key
return key, nil
func (n *Node) PubKey() (*btcec.PublicKey, error) {
return btcec.ParsePubKey(n.PubKeyBytes[:])
}
// AuthSig is a signature under the advertised public key which serves to
@ -84,45 +70,44 @@ func (l *Node) PubKey() (*btcec.PublicKey, error) {
//
// NOTE: By having this method to access an attribute, we ensure we only need
// to fully deserialize the signature if absolutely necessary.
func (l *Node) AuthSig() (*ecdsa.Signature, error) {
return ecdsa.ParseSignature(l.AuthSigBytes)
func (n *Node) AuthSig() (*ecdsa.Signature, error) {
return ecdsa.ParseSignature(n.AuthSigBytes)
}
// AddPubKey is a setter-link method that can be used to swap out the public
// key for a node.
func (l *Node) AddPubKey(key *btcec.PublicKey) {
l.pubKey = key
copy(l.PubKeyBytes[:], key.SerializeCompressed())
func (n *Node) AddPubKey(key *btcec.PublicKey) {
copy(n.PubKeyBytes[:], key.SerializeCompressed())
}
// NodeAnnouncement retrieves the latest node announcement of the node.
func (l *Node) NodeAnnouncement(signed bool) (*lnwire.NodeAnnouncement1,
func (n *Node) NodeAnnouncement(signed bool) (*lnwire.NodeAnnouncement1,
error) {
if !l.HaveNodeAnnouncement {
if !n.HaveNodeAnnouncement {
return nil, fmt.Errorf("node does not have node announcement")
}
alias, err := lnwire.NewNodeAlias(l.Alias)
alias, err := lnwire.NewNodeAlias(n.Alias)
if err != nil {
return nil, err
}
nodeAnn := &lnwire.NodeAnnouncement1{
Features: l.Features.RawFeatureVector,
NodeID: l.PubKeyBytes,
RGBColor: l.Color,
Features: n.Features.RawFeatureVector,
NodeID: n.PubKeyBytes,
RGBColor: n.Color,
Alias: alias,
Addresses: l.Addresses,
Timestamp: uint32(l.LastUpdate.Unix()),
ExtraOpaqueData: l.ExtraOpaqueData,
Addresses: n.Addresses,
Timestamp: uint32(n.LastUpdate.Unix()),
ExtraOpaqueData: n.ExtraOpaqueData,
}
if !signed {
return nodeAnn, nil
}
sig, err := lnwire.NewSigFromECDSARawSignature(l.AuthSigBytes)
sig, err := lnwire.NewSigFromECDSARawSignature(n.AuthSigBytes)
if err != nil {
return nil, err
}

View file

@ -55,6 +55,7 @@ type SQLQueries interface {
Node queries.
*/
UpsertNode(ctx context.Context, arg sqlc.UpsertNodeParams) (int64, error)
UpsertSourceNode(ctx context.Context, arg sqlc.UpsertSourceNodeParams) (int64, error)
GetNodeByPubKey(ctx context.Context, arg sqlc.GetNodeByPubKeyParams) (sqlc.GraphNode, error)
GetNodesByIDs(ctx context.Context, ids []int64) ([]sqlc.GraphNode, error)
GetNodeIDByPubKey(ctx context.Context, arg sqlc.GetNodeIDByPubKeyParams) (int64, error)
@ -532,7 +533,14 @@ func (s *SQLStore) SetSourceNode(ctx context.Context,
node *models.Node) error {
return s.db.ExecTx(ctx, sqldb.WriteTxOpt(), func(db SQLQueries) error {
id, err := upsertNode(ctx, db, node)
// For the source node, we use a less strict upsert that allows
// updates even when the timestamp hasn't changed. This handles
// the race condition where multiple goroutines (e.g.,
// setSelfNode, createNewHiddenService, RPC updates) read the
// same old timestamp, independently increment it, and try to
// write concurrently. We want all parameter changes to persist,
// even if timestamps collide.
id, err := upsertSourceNode(ctx, db, node)
if err != nil {
return fmt.Errorf("unable to upsert source node: %w",
err)
@ -1126,6 +1134,11 @@ func (s *SQLStore) ChanUpdatesInHorizon(startTime, endTime time.Time,
for hasMore {
var batch []ChannelEdge
// Acquire read lock before starting transaction to
// ensure consistent lock ordering (cacheMu -> DB) and
// prevent deadlock with write operations.
s.cacheMu.RLock()
err := s.db.ExecTx(ctx, sqldb.ReadTxOpt(),
func(db SQLQueries) error {
//nolint:ll
@ -1178,11 +1191,11 @@ func (s *SQLStore) ChanUpdatesInHorizon(startTime, endTime time.Time,
continue
}
s.cacheMu.RLock()
// Check cache (we already hold
// shared read lock).
channel, ok := s.chanCache.get(
chanIDInt,
)
s.cacheMu.RUnlock()
if ok {
hits++
total++
@ -1216,6 +1229,9 @@ func (s *SQLStore) ChanUpdatesInHorizon(startTime, endTime time.Time,
)
})
// Release read lock after transaction completes.
s.cacheMu.RUnlock()
if err != nil {
log.Errorf("ChanUpdatesInHorizon "+
"batch error: %v", err)
@ -2908,10 +2924,12 @@ func (s *SQLStore) DisconnectBlockAtHeight(height uint32) (
"height: %w", err)
}
s.cacheMu.Lock()
for _, channel := range removedChans {
s.rejectCache.remove(channel.ChannelID)
s.chanCache.remove(channel.ChannelID)
}
s.cacheMu.Unlock()
return removedChans, nil
}
@ -3602,6 +3620,135 @@ func getNodeFeatures(ctx context.Context, db SQLQueries,
return features, nil
}
// upsertNodeAncillaryData updates the node's features, addresses, and extra
// signed fields. This is common logic shared by upsertNode and
// upsertSourceNode.
func upsertNodeAncillaryData(ctx context.Context, db SQLQueries,
nodeID int64, node *models.Node) error {
// Update the node's features.
err := upsertNodeFeatures(ctx, db, nodeID, node.Features)
if err != nil {
return fmt.Errorf("inserting node features: %w", err)
}
// Update the node's addresses.
err = upsertNodeAddresses(ctx, db, nodeID, node.Addresses)
if err != nil {
return fmt.Errorf("inserting node addresses: %w", err)
}
// Convert the flat extra opaque data into a map of TLV types to
// values.
extra, err := marshalExtraOpaqueData(node.ExtraOpaqueData)
if err != nil {
return fmt.Errorf("unable to marshal extra opaque data: %w",
err)
}
// Update the node's extra signed fields.
err = upsertNodeExtraSignedFields(ctx, db, nodeID, extra)
if err != nil {
return fmt.Errorf("inserting node extra TLVs: %w", err)
}
return nil
}
// populateNodeParams populates the common node parameters from a models.Node.
// This is a helper for building UpsertNodeParams and UpsertSourceNodeParams.
func populateNodeParams(node *models.Node,
setParams func(lastUpdate sql.NullInt64, alias,
colorStr sql.NullString, signature []byte)) {
if !node.HaveNodeAnnouncement {
return
}
lastUpdate := sqldb.SQLInt64(node.LastUpdate.Unix())
alias := sqldb.SQLStrValid(node.Alias)
colorStr := sqldb.SQLStrValid(EncodeHexColor(node.Color))
setParams(lastUpdate, alias, colorStr, node.AuthSigBytes)
}
// buildNodeUpsertParams builds the parameters for upserting a node using the
// strict UpsertNode query (requires timestamp to be increasing).
func buildNodeUpsertParams(node *models.Node) sqlc.UpsertNodeParams {
params := sqlc.UpsertNodeParams{
Version: int16(ProtocolV1),
PubKey: node.PubKeyBytes[:],
}
populateNodeParams(
node, func(lastUpdate sql.NullInt64, alias,
colorStr sql.NullString,
signature []byte) {
params.LastUpdate = lastUpdate
params.Alias = alias
params.Color = colorStr
params.Signature = signature
},
)
return params
}
// buildSourceNodeUpsertParams builds the parameters for upserting the source
// node using the lenient UpsertSourceNode query (allows same timestamp).
func buildSourceNodeUpsertParams(
node *models.Node) sqlc.UpsertSourceNodeParams {
params := sqlc.UpsertSourceNodeParams{
Version: int16(ProtocolV1),
PubKey: node.PubKeyBytes[:],
}
populateNodeParams(
node, func(lastUpdate sql.NullInt64, alias,
colorStr sql.NullString, signature []byte) {
params.LastUpdate = lastUpdate
params.Alias = alias
params.Color = colorStr
params.Signature = signature
},
)
return params
}
// upsertSourceNode upserts the source node record into the database using a
// less strict upsert that allows updates even when the timestamp hasn't
// changed. This is necessary to handle concurrent updates to our own node
// during startup and runtime. The node's features, addresses and extra TLV
// types are also updated. The node's DB ID is returned.
func upsertSourceNode(ctx context.Context, db SQLQueries,
node *models.Node) (int64, error) {
params := buildSourceNodeUpsertParams(node)
nodeID, err := db.UpsertSourceNode(ctx, params)
if err != nil {
return 0, fmt.Errorf("upserting source node(%x): %w",
node.PubKeyBytes, err)
}
// We can exit here if we don't have the announcement yet.
if !node.HaveNodeAnnouncement {
return nodeID, nil
}
// Update the ancillary node data (features, addresses, extra fields).
err = upsertNodeAncillaryData(ctx, db, nodeID, node)
if err != nil {
return 0, err
}
return nodeID, nil
}
// upsertNode upserts the node record into the database. If the node already
// exists, then the node's information is updated. If the node doesn't exist,
// then a new node is created. The node's features, addresses and extra TLV
@ -3609,17 +3756,7 @@ func getNodeFeatures(ctx context.Context, db SQLQueries,
func upsertNode(ctx context.Context, db SQLQueries,
node *models.Node) (int64, error) {
params := sqlc.UpsertNodeParams{
Version: int16(ProtocolV1),
PubKey: node.PubKeyBytes[:],
}
if node.HaveNodeAnnouncement {
params.LastUpdate = sqldb.SQLInt64(node.LastUpdate.Unix())
params.Color = sqldb.SQLStrValid(EncodeHexColor(node.Color))
params.Alias = sqldb.SQLStrValid(node.Alias)
params.Signature = node.AuthSigBytes
}
params := buildNodeUpsertParams(node)
nodeID, err := db.UpsertNode(ctx, params)
if err != nil {
@ -3632,30 +3769,10 @@ func upsertNode(ctx context.Context, db SQLQueries,
return nodeID, nil
}
// Update the node's features.
err = upsertNodeFeatures(ctx, db, nodeID, node.Features)
// Update the ancillary node data (features, addresses, extra fields).
err = upsertNodeAncillaryData(ctx, db, nodeID, node)
if err != nil {
return 0, fmt.Errorf("inserting node features: %w", err)
}
// Update the node's addresses.
err = upsertNodeAddresses(ctx, db, nodeID, node.Addresses)
if err != nil {
return 0, fmt.Errorf("inserting node addresses: %w", err)
}
// Convert the flat extra opaque data into a map of TLV types to
// values.
extra, err := marshalExtraOpaqueData(node.ExtraOpaqueData)
if err != nil {
return 0, fmt.Errorf("unable to marshal extra opaque data: %w",
err)
}
// Update the node's extra signed fields.
err = upsertNodeExtraSignedFields(ctx, db, nodeID, extra)
if err != nil {
return 0, fmt.Errorf("inserting node extra TLVs: %w", err)
return 0, err
}
return nodeID, nil

View file

@ -24,4 +24,4 @@ require (
gopkg.in/yaml.v3 v3.0.1 // indirect
)
go 1.24.9
go 1.24.11

View file

@ -5,62 +5,333 @@ import (
"fmt"
"github.com/lightningnetwork/lnd/graph/db/models"
"github.com/lightningnetwork/lnd/lnwire"
)
// heldHtlcSet keeps track of outstanding intercepted forwards. It exposes
// several methods to manipulate the underlying map structure in a consistent
// way.
var (
// ErrCannotResumeOnChain is returned when an on-chain held HTLC is
// resolved with a resume action.
ErrCannotResumeOnChain = errors.New(
"cannot resume held htlc in the on-chain flow",
)
// ErrCannotFailOnChain is returned when an on-chain held HTLC is
// resolved with a fail action.
ErrCannotFailOnChain = errors.New(
"cannot fail held htlc in the on-chain flow",
)
// errNilHeldForward is returned when the held HTLC constructors or
// add helpers are given a nil InterceptedForward.
errNilHeldForward = errors.New("nil held htlc forward")
// errInvalidHeldDeadline is returned when a held HTLC has an
// interceptor deadline that is not a positive block height.
errInvalidHeldDeadline = errors.New(
"invalid held htlc interceptor deadline",
)
// errInvalidHeldDeadlineType is returned when a held HTLC entry is
// created with the wrong deadline type for its source.
errInvalidHeldDeadlineType = errors.New(
"invalid held htlc interceptor deadline type",
)
)
// heldEntry models the behavior of a held HTLC based on whether it is still
// controlled by the off-chain link flow or the on-chain contractcourt flow.
type heldEntry interface {
// interceptedForward returns the forward that should be replayed to the
// external interceptor.
interceptedForward() InterceptedForward
// resolve applies an interceptor resolution to the held entry.
resolve(*FwdResolution) error
// expire expires the held entry at the given block height. The boolean
// return value indicates whether the entry should be removed.
expire(height uint32) (bool, error)
}
// offChainHeld is a held HTLC that is still controlled by the off-chain link
// flow.
type offChainHeld struct {
fwd InterceptedForward
// autoFailHeight is the block height at which the held off-chain HTLC
// must be failed back to avoid forcing the incoming channel closed.
autoFailHeight uint32
}
// Assert that offChainHeld implements heldEntry.
var _ heldEntry = (*offChainHeld)(nil)
// newOffChainHeld creates a held off-chain HTLC entry and validates that it has
// a positive auto-fail height.
func newOffChainHeld(fwd InterceptedForward) (*offChainHeld, error) {
if fwd == nil {
return nil, errNilHeldForward
}
autoFailHeight, err := fwd.Packet().Deadline.LeftToSome().UnwrapOrErr(
errInvalidHeldDeadlineType,
)
if err != nil {
return nil, err
}
if autoFailHeight <= 0 {
return nil, fmt.Errorf("%w: %v", errInvalidHeldDeadline,
autoFailHeight)
}
return &offChainHeld{
fwd: fwd,
autoFailHeight: uint32(autoFailHeight),
}, nil
}
// interceptedForward returns the intercepted forward backing the off-chain
// entry.
func (h *offChainHeld) interceptedForward() InterceptedForward {
return h.fwd
}
// release resumes the held off-chain HTLC into the normal link forwarding
// flow.
func (h *offChainHeld) release() error {
return h.fwd.Resume()
}
// resolve applies an interceptor resolution to the held off-chain HTLC.
func (h *offChainHeld) resolve(res *FwdResolution) error {
switch res.Action {
case FwdActionResume:
return h.fwd.Resume()
case FwdActionResumeModified:
return h.fwd.ResumeModified(
res.InAmountMsat, res.OutAmountMsat,
res.OutWireCustomRecords,
)
case FwdActionSettle:
return h.fwd.Settle(res.Preimage)
case FwdActionFail:
if len(res.FailureMessage) > 0 {
return h.fwd.Fail(res.FailureMessage)
}
return h.fwd.FailWithCode(res.FailureCode)
default:
return fmt.Errorf("unrecognized action %v", res.Action)
}
}
// expire fails back the held off-chain HTLC once its auto-fail height has been
// reached.
func (h *offChainHeld) expire(height uint32) (bool, error) {
if h.autoFailHeight > height {
return false, nil
}
err := h.fwd.FailWithCode(lnwire.CodeTemporaryChannelFailure)
if err != nil {
return false, err
}
return true, nil
}
// onChainHeld is a held HTLC that is controlled by the on-chain contractcourt
// flow.
type onChainHeld struct {
fwd InterceptedForward
// settleDeadline is the on-chain HTLC expiry. Once this height is
// reached, the remote party can also sweep the HTLC using the timeout
// path, so any late preimage would race that spend. At that point the
// interceptor entry is pruned locally instead of failed back through
// the link.
settleDeadline uint32
}
// Assert that onChainHeld implements heldEntry.
var _ heldEntry = (*onChainHeld)(nil)
// newOnChainHeld creates a held on-chain HTLC entry and validates that it has a
// positive settlement deadline.
func newOnChainHeld(fwd InterceptedForward) (*onChainHeld, error) {
if fwd == nil {
return nil, errNilHeldForward
}
settleDeadline, err := fwd.Packet().Deadline.RightToSome().UnwrapOrErr(
errInvalidHeldDeadlineType,
)
if err != nil {
return nil, err
}
if settleDeadline <= 0 {
return nil, fmt.Errorf("%w: %v", errInvalidHeldDeadline,
settleDeadline)
}
return &onChainHeld{
fwd: fwd,
settleDeadline: uint32(settleDeadline),
}, nil
}
// interceptedForward returns the intercepted forward backing the on-chain
// entry.
func (h *onChainHeld) interceptedForward() InterceptedForward {
return h.fwd
}
// resolve applies an interceptor resolution to the held on-chain HTLC.
func (h *onChainHeld) resolve(res *FwdResolution) error {
switch res.Action {
case FwdActionSettle:
return h.fwd.Settle(res.Preimage)
case FwdActionFail:
return ErrCannotFailOnChain
case FwdActionResume:
return ErrCannotResumeOnChain
case FwdActionResumeModified:
return ErrCannotResumeOnChain
default:
return fmt.Errorf("unrecognized action %v", res.Action)
}
}
// expire reports whether the held on-chain HTLC should be pruned locally
// because its settlement deadline has been reached.
func (h *onChainHeld) expire(height uint32) (bool, error) {
return h.settleDeadline <= height, nil
}
// heldHtlcExpireError records an error returned while expiring a held HTLC.
type heldHtlcExpireError struct {
key models.CircuitKey
err error
}
// heldHtlcReleaseError records an error returned while releasing a held HTLC.
type heldHtlcReleaseError struct {
key models.CircuitKey
err error
}
// heldHtlcSet keeps track of outstanding intercepted forwards. It models
// whether each forward is still controlled by the off-chain link flow or has
// moved to the on-chain contractcourt flow.
type heldHtlcSet struct {
set map[models.CircuitKey]InterceptedForward
set map[models.CircuitKey]heldEntry
}
func newHeldHtlcSet() *heldHtlcSet {
return &heldHtlcSet{
set: make(map[models.CircuitKey]InterceptedForward),
set: make(map[models.CircuitKey]heldEntry),
}
}
// forEach iterates over all held forwards and calls the given callback for each
// of them.
func (h *heldHtlcSet) forEach(cb func(InterceptedForward)) {
for _, fwd := range h.set {
cb(fwd)
for _, entry := range h.set {
cb(entry.interceptedForward())
}
}
// popAll calls the callback for each forward and removes them from the set.
func (h *heldHtlcSet) popAll(cb func(InterceptedForward)) {
for _, fwd := range h.set {
cb(fwd)
}
// releaseAllOffChainHeld releases off-chain entries when the optional
// interceptor disconnects. On-chain entries are kept because there is no link
// flow to resume, preserving the replay/settle handle while contractcourt waits
// for the preimage or on-chain expiry.
func (h *heldHtlcSet) releaseAllOffChainHeld() []heldHtlcReleaseError {
var errs []heldHtlcReleaseError
h.set = make(map[models.CircuitKey]InterceptedForward)
}
// popAutoFails calls the callback for each forward that has an auto-fail height
// equal or less then the specified pop height and removes them from the set.
func (h *heldHtlcSet) popAutoFails(height uint32, cb func(InterceptedForward)) {
for key, fwd := range h.set {
if uint32(fwd.Packet().AutoFailHeight) > height {
for key, entry := range h.set {
offChain, ok := entry.(*offChainHeld)
if !ok {
continue
}
cb(fwd)
if err := offChain.release(); err != nil {
errs = append(errs, heldHtlcReleaseError{
key: key,
err: err,
})
// Keep the entry tracked so it can still be resolved or
// failed back by the normal expiry path.
continue
}
delete(h.set, key)
}
return errs
}
// pop returns the specified forward and removes it from the set.
func (h *heldHtlcSet) pop(key models.CircuitKey) (InterceptedForward, error) {
intercepted, ok := h.set[key]
if !ok {
return nil, fmt.Errorf("fwd %v not found", key)
// removeOnChainHeld removes an on-chain held entry by circuit key. Off-chain
// entries are left untouched because their lifecycle is owned by the link flow,
// not contractcourt.
func (h *heldHtlcSet) removeOnChainHeld(key models.CircuitKey) bool {
if _, ok := h.set[key].(*onChainHeld); !ok {
return false
}
delete(h.set, key)
return intercepted, nil
return true
}
// expire expires held forwards whose deadline has passed.
func (h *heldHtlcSet) expire(height uint32) []heldHtlcExpireError {
var errs []heldHtlcExpireError
for key, entry := range h.set {
remove, err := entry.expire(height)
if err != nil {
errs = append(errs, heldHtlcExpireError{
key: key,
err: err,
})
continue
}
if remove {
delete(h.set, key)
}
}
return errs
}
// resolve applies the given resolution and removes the forward from the set if
// the resolution succeeds.
func (h *heldHtlcSet) resolve(res *FwdResolution) error {
entry, ok := h.set[res.Key]
if !ok {
return fmt.Errorf("%w: %v", ErrFwdNotExists, res.Key)
}
if err := entry.resolve(res); err != nil {
return err
}
delete(h.set, res.Key)
return nil
}
// exists tests whether the specified forward is part of the set.
@ -70,20 +341,56 @@ func (h *heldHtlcSet) exists(key models.CircuitKey) bool {
return ok
}
// push adds the specified forward to the set. An error is returned if the
// forward exists already.
func (h *heldHtlcSet) push(key models.CircuitKey,
fwd InterceptedForward) error {
// addOffChain adds an off-chain forward to the set. If the forward already
// exists, the duplicate is ignored because callers should have handled it
// before insertion.
func (h *heldHtlcSet) addOffChain(fwd InterceptedForward) error {
if fwd == nil {
return errors.New("nil fwd pushed")
return errNilHeldForward
}
key := fwd.Packet().IncomingCircuit
if h.exists(key) {
return errors.New("htlc already exists in set")
log.Warnf("Ignoring duplicate off-chain held htlc %v", key)
return nil
}
h.set[key] = fwd
entry, err := newOffChainHeld(fwd)
if err != nil {
return err
}
h.set[key] = entry
return nil
}
// addOnChain adds an on-chain forward to the set. If the same HTLC is currently
// held off-chain, it is replaced so future resolutions go to the witness beacon
// instead of the old link mailbox path.
func (h *heldHtlcSet) addOnChain(fwd InterceptedForward) error {
if fwd == nil {
return errNilHeldForward
}
key := fwd.Packet().IncomingCircuit
if _, ok := h.set[key].(*onChainHeld); ok {
return nil
}
if _, ok := h.set[key].(*offChainHeld); ok {
log.Infof("Promoting held htlc %v from off-chain to "+
"on-chain resolution", key)
}
entry, err := newOnChainHeld(fwd)
if err != nil {
return err
}
h.set[key] = entry
return nil
}

View file

@ -1,126 +1,602 @@
package htlcswitch
import (
"errors"
"testing"
"time"
"github.com/lightningnetwork/lnd/chainntnfs"
"github.com/lightningnetwork/lnd/fn/v2"
"github.com/lightningnetwork/lnd/graph/db/models"
lntestmock "github.com/lightningnetwork/lnd/lntest/mock"
"github.com/lightningnetwork/lnd/lntypes"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
var errTestForward = errors.New("test forward error")
// mockInterceptedForward is an InterceptedForward test double that records
// resolution calls and returns configured errors.
type mockInterceptedForward struct {
mock.Mock
packet InterceptedPacket
}
// newMockInterceptedForward creates a mock intercepted forward with the given
// circuit key and auto-fail deadline.
func newMockInterceptedForward(key models.CircuitKey,
deadline int32) *mockInterceptedForward {
return &mockInterceptedForward{
packet: InterceptedPacket{
IncomingCircuit: key,
Deadline: fn.NewLeft[
OffChainAutoFailHeight, OnChainSettleDeadline,
](OffChainAutoFailHeight(deadline)),
},
}
}
// newMockOnChainInterceptedForward creates a mock on-chain intercepted forward
// with the given circuit key and settlement deadline.
func newMockOnChainInterceptedForward(key models.CircuitKey,
deadline int32) *mockInterceptedForward {
return &mockInterceptedForward{
packet: InterceptedPacket{
IncomingCircuit: key,
Deadline: fn.NewRight[
OffChainAutoFailHeight, OnChainSettleDeadline,
](OnChainSettleDeadline(deadline)),
},
}
}
// Packet returns the intercepted packet represented by the mock.
func (m *mockInterceptedForward) Packet() InterceptedPacket {
return m.packet
}
// Resume records a resume call and returns the configured return error.
func (m *mockInterceptedForward) Resume() error {
args := m.Called()
return args.Error(0)
}
// ResumeModified records a modified resume call and returns the configured
// return error.
func (m *mockInterceptedForward) ResumeModified(
_ fn.Option[lnwire.MilliSatoshi],
_ fn.Option[lnwire.MilliSatoshi],
_ fn.Option[lnwire.CustomRecords]) error {
args := m.Called()
return args.Error(0)
}
// Settle records a settle call and returns the configured return error.
func (m *mockInterceptedForward) Settle(preimage lntypes.Preimage) error {
args := m.Called(preimage)
return args.Error(0)
}
// Fail records an encrypted failure call and returns the configured return
// error.
func (m *mockInterceptedForward) Fail(reason []byte) error {
args := m.Called(reason)
return args.Error(0)
}
// FailWithCode records a failure-code call and returns the configured
// return error.
func (m *mockInterceptedForward) FailWithCode(code lnwire.FailCode) error {
args := m.Called(code)
return args.Error(0)
}
// testCircuitKey returns a stable circuit key for held HTLC set tests.
func testCircuitKey() models.CircuitKey {
return models.CircuitKey{
ChanID: lnwire.NewShortChanIDFromInt(1),
HtlcID: 2,
}
}
// TestHeldHtlcSetEmpty verifies empty held HTLC set behavior.
func TestHeldHtlcSetEmpty(t *testing.T) {
set := newHeldHtlcSet()
// Test operations on an empty set.
require.False(t, set.exists(models.CircuitKey{}))
_, err := set.pop(models.CircuitKey{})
require.Error(t, err)
set.popAll(
func(_ InterceptedForward) {
require.Fail(t, "unexpected fwd")
},
)
require.ErrorIs(t, set.resolve(&FwdResolution{}), ErrFwdNotExists)
require.Empty(t, set.releaseAllOffChainHeld())
}
func TestHeldHtlcSet(t *testing.T) {
// TestHeldHtlcSetRejectsInvalidDeadline verifies invalid deadlines are
// rejected for both off-chain and on-chain held entries.
func TestHeldHtlcSetRejectsInvalidDeadline(t *testing.T) {
set := newHeldHtlcSet()
key := testCircuitKey()
key := models.CircuitKey{
ChanID: lnwire.NewShortChanIDFromInt(1),
HtlcID: 2,
require.Error(t, set.addOffChain(newMockInterceptedForward(key, 0)))
require.Error(t, set.addOffChain(newMockInterceptedForward(key, -1)))
require.Error(t, set.addOffChain(
newMockOnChainInterceptedForward(key, 100),
))
require.Error(t, set.addOnChain(
newMockOnChainInterceptedForward(key, 0),
))
require.Error(t, set.addOnChain(
newMockOnChainInterceptedForward(key, -1),
))
require.Error(t, set.addOnChain(newMockInterceptedForward(key, 100)))
}
// TestHeldHtlcSetOffChainResolve verifies off-chain resolutions call through
// to the backing intercepted forward and remove the held entry.
func TestHeldHtlcSetOffChainResolve(t *testing.T) {
set := newHeldHtlcSet()
key := testCircuitKey()
fwd := newMockInterceptedForward(key, 100)
fwd.On("Resume").Return(nil).Once()
require.NoError(t, set.addOffChain(fwd))
require.NoError(t, set.resolve(&FwdResolution{
Key: key,
Action: FwdActionResume,
}))
fwd.AssertExpectations(t)
require.False(t, set.exists(key))
}
// TestHeldHtlcSetAddOffChainKeepsExisting verifies that duplicate off-chain
// forwards keep the existing held entry.
func TestHeldHtlcSetAddOffChainKeepsExisting(t *testing.T) {
set := newHeldHtlcSet()
key := testCircuitKey()
first := newMockInterceptedForward(key, 100)
second := newMockInterceptedForward(key, 100)
first.On("Resume").Return(nil).Once()
require.NoError(t, set.addOffChain(first))
require.NoError(t, set.addOffChain(second))
require.NoError(t, set.resolve(&FwdResolution{
Key: key,
Action: FwdActionResume,
}))
first.AssertExpectations(t)
second.AssertNotCalled(t, "Resume")
}
// TestInterceptableSwitchForwardOffChainAlreadyHeld verifies that normal
// off-chain forwarding handles duplicates before adding them to the held set.
func TestInterceptableSwitchForwardOffChainAlreadyHeld(t *testing.T) {
key := testCircuitKey()
fwd := newMockInterceptedForward(key, 100)
s := &InterceptableSwitch{
heldHtlcSet: newHeldHtlcSet(),
}
// Test pushing a nil forward.
require.Error(t, set.push(key, nil))
require.NoError(t, s.heldHtlcSet.addOffChain(fwd))
// Test pushing a forward.
fwd := &interceptedForward{
htlc: &lnwire.UpdateAddHTLC{},
}
require.NoError(t, set.push(key, fwd))
// Re-pushing should fail.
require.Error(t, set.push(key, fwd))
// Test popping the fwd.
poppedFwd, err := set.pop(key)
handled, err := s.forwardOffChain(
newMockInterceptedForward(key, 100), true,
)
require.NoError(t, err)
require.Equal(t, fwd, poppedFwd)
_, err = set.pop(key)
require.Error(t, err)
// Pushing the forward again.
require.NoError(t, set.push(key, fwd))
// Test for each.
var cbCalled bool
set.forEach(func(_ InterceptedForward) {
cbCalled = true
require.Equal(t, fwd, poppedFwd)
})
require.True(t, cbCalled)
// Test popping all forwards.
cbCalled = false
set.popAll(
func(_ InterceptedForward) {
cbCalled = true
require.Equal(t, fwd, poppedFwd)
},
)
require.True(t, cbCalled)
_, err = set.pop(key)
require.Error(t, err)
require.True(t, handled)
}
func TestHeldHtlcSetAutoFails(t *testing.T) {
// TestHeldHtlcSetResolveKeepsEntryOnError verifies failed resolutions keep the
// held entry available for retry.
func TestHeldHtlcSetResolveKeepsEntryOnError(t *testing.T) {
set := newHeldHtlcSet()
key := testCircuitKey()
fwd := newMockInterceptedForward(key, 100)
fwd.On("Settle", lntypes.Preimage{}).Return(errTestForward).Once()
key := models.CircuitKey{
ChanID: lnwire.NewShortChanIDFromInt(1),
HtlcID: 2,
}
require.NoError(t, set.addOffChain(fwd))
require.ErrorIs(t, set.resolve(&FwdResolution{
Key: key,
Action: FwdActionSettle,
}), errTestForward)
const autoFailHeight = 100
fwd := &interceptedForward{
packet: &htlcPacket{},
htlc: &lnwire.UpdateAddHTLC{},
autoFailHeight: autoFailHeight,
}
require.NoError(t, set.push(key, fwd))
// Test popping auto fails up to one block before the auto-fail height
// of our forward.
set.popAutoFails(
autoFailHeight-1,
func(_ InterceptedForward) {
require.Fail(t, "unexpected fwd")
},
)
// Popping succeeds at the auto-fail height.
cbCalled := false
set.popAutoFails(
autoFailHeight,
func(poppedFwd InterceptedForward) {
cbCalled = true
require.Equal(t, fwd, poppedFwd)
},
)
require.True(t, cbCalled)
// After this, there should be nothing more to pop.
set.popAutoFails(
autoFailHeight,
func(_ InterceptedForward) {
require.Fail(t, "unexpected fwd")
},
)
require.True(t, set.exists(key))
fwd.AssertExpectations(t)
}
// TestHeldHtlcSetReleaseAllOffChainHeld verifies an optional interceptor
// disconnect resumes off-chain entries and clears them from the set.
func TestHeldHtlcSetReleaseAllOffChainHeld(t *testing.T) {
set := newHeldHtlcSet()
key := testCircuitKey()
fwd := newMockInterceptedForward(key, 100)
fwd.On("Resume").Return(nil).Once()
require.NoError(t, set.addOffChain(fwd))
require.Empty(t, set.releaseAllOffChainHeld())
require.False(t, set.exists(key))
fwd.AssertExpectations(t)
}
// TestHeldHtlcSetReleaseAllOffChainHeldKeepsOnChain verifies an optional
// interceptor disconnect keeps on-chain entries available for replay if the
// interceptor reconnects before expiry.
func TestHeldHtlcSetReleaseAllOffChainHeldKeepsOnChain(t *testing.T) {
set := newHeldHtlcSet()
key := testCircuitKey()
fwd := newMockOnChainInterceptedForward(key, 100)
require.NoError(t, set.addOnChain(fwd))
require.Empty(t, set.releaseAllOffChainHeld())
require.True(t, set.exists(key))
fwd.AssertNotCalled(t, "Resume")
}
// TestHeldHtlcSetReleaseAllOffChainHeldKeepsReleaseErrors verifies release
// errors leave off-chain entries available for later resolution or expiry.
func TestHeldHtlcSetReleaseAllOffChainHeldKeepsReleaseErrors(t *testing.T) {
set := newHeldHtlcSet()
key := testCircuitKey()
fwd := newMockInterceptedForward(key, 100)
fwd.On("Resume").Return(errTestForward).Once()
require.NoError(t, set.addOffChain(fwd))
errs := set.releaseAllOffChainHeld()
require.Len(t, errs, 1)
require.Equal(t, key, errs[0].key)
require.ErrorIs(t, errs[0].err, errTestForward)
require.True(t, set.exists(key))
fwd.AssertExpectations(t)
}
// TestHeldHtlcSetRemoveOnChainHeld verifies contractcourt teardown only removes
// on-chain entries.
func TestHeldHtlcSetRemoveOnChainHeld(t *testing.T) {
set := newHeldHtlcSet()
key := testCircuitKey()
offChain := newMockInterceptedForward(key, 100)
require.NoError(t, set.addOffChain(offChain))
require.False(t, set.removeOnChainHeld(key))
require.True(t, set.exists(key))
onChain := newMockOnChainInterceptedForward(key, 100)
require.NoError(t, set.addOnChain(onChain))
require.True(t, set.removeOnChainHeld(key))
require.False(t, set.exists(key))
require.False(t, set.removeOnChainHeld(key))
}
// TestHeldHtlcSetOffChainExpire verifies off-chain expiry fails the HTLC back.
func TestHeldHtlcSetOffChainExpire(t *testing.T) {
set := newHeldHtlcSet()
key := testCircuitKey()
fwd := newMockInterceptedForward(key, 100)
require.NoError(t, set.addOffChain(fwd))
require.Empty(t, set.expire(99))
require.True(t, set.exists(key))
fwd.AssertNotCalled(t, "FailWithCode", mock.Anything)
fwd.On(
"FailWithCode",
lnwire.CodeTemporaryChannelFailure,
).Return(nil).Once()
require.Empty(t, set.expire(100))
require.False(t, set.exists(key))
fwd.AssertExpectations(t)
}
// TestHeldHtlcSetOffChainExpireKeepsEntryOnError verifies expiry errors keep
// the off-chain entry available for retry.
func TestHeldHtlcSetOffChainExpireKeepsEntryOnError(t *testing.T) {
set := newHeldHtlcSet()
key := testCircuitKey()
fwd := newMockInterceptedForward(key, 100)
fwd.On(
"FailWithCode", lnwire.CodeTemporaryChannelFailure,
).Return(errTestForward).Once()
require.NoError(t, set.addOffChain(fwd))
errs := set.expire(100)
require.Len(t, errs, 1)
require.ErrorIs(t, errs[0].err, errTestForward)
require.Equal(t, key, errs[0].key)
require.True(t, set.exists(key))
fwd.AssertExpectations(t)
}
// TestHeldHtlcSetOnChainResolve verifies on-chain entries reject non-settle
// resolutions directly and remain held until settlement.
func TestHeldHtlcSetOnChainResolve(t *testing.T) {
set := newHeldHtlcSet()
key := testCircuitKey()
fwd := newMockOnChainInterceptedForward(key, 100)
fwd.On("Settle", lntypes.Preimage{}).Return(nil).Once()
require.NoError(t, set.addOnChain(fwd))
require.ErrorIs(t, set.resolve(&FwdResolution{
Key: key,
Action: FwdActionFail,
FailureCode: lnwire.CodeTemporaryChannelFailure,
}), ErrCannotFailOnChain)
require.True(t, set.exists(key))
require.ErrorIs(t, set.resolve(&FwdResolution{
Key: key,
Action: FwdActionResume,
}), ErrCannotResumeOnChain)
require.True(t, set.exists(key))
require.ErrorIs(t, set.resolve(&FwdResolution{
Key: key,
Action: FwdActionResumeModified,
}), ErrCannotResumeOnChain)
require.True(t, set.exists(key))
require.NoError(t, set.resolve(&FwdResolution{
Key: key,
Action: FwdActionSettle,
}))
fwd.AssertExpectations(t)
fwd.AssertNotCalled(t, "Fail", mock.Anything)
fwd.AssertNotCalled(t, "FailWithCode", mock.Anything)
fwd.AssertNotCalled(t, "Resume")
fwd.AssertNotCalled(t, "ResumeModified", mock.Anything, mock.Anything,
mock.Anything)
require.False(t, set.exists(key))
}
// TestHeldHtlcSetOnChainExpirePrunes verifies on-chain expiry only prunes the
// local held entry.
func TestHeldHtlcSetOnChainExpirePrunes(t *testing.T) {
set := newHeldHtlcSet()
key := testCircuitKey()
fwd := newMockOnChainInterceptedForward(key, 100)
require.NoError(t, set.addOnChain(fwd))
require.Empty(t, set.expire(99))
require.True(t, set.exists(key))
require.Empty(t, set.expire(100))
require.False(t, set.exists(key))
fwd.AssertNotCalled(t, "FailWithCode", mock.Anything)
}
// TestHeldHtlcSetOnChainReplacesOffChain verifies on-chain entries replace
// earlier off-chain entries with the same circuit key.
func TestHeldHtlcSetOnChainReplacesOffChain(t *testing.T) {
set := newHeldHtlcSet()
key := testCircuitKey()
offChain := newMockInterceptedForward(key, 100)
onChain := newMockOnChainInterceptedForward(key, 100)
onChain.On("Settle", lntypes.Preimage{}).Return(nil).Once()
require.NoError(t, set.addOffChain(offChain))
require.NoError(t, set.addOnChain(onChain))
require.NoError(t, set.resolve(&FwdResolution{
Key: key,
Action: FwdActionSettle,
}))
offChain.AssertNotCalled(t, "Settle", mock.Anything)
onChain.AssertExpectations(t)
}
// TestInterceptableSwitchForwardOnChain verifies on-chain intercept handling
// for fresh and already-held HTLCs.
func TestInterceptableSwitchForwardOnChain(t *testing.T) {
key := testCircuitKey()
var intercepted []InterceptedPacket
interceptor := func(packet InterceptedPacket) error {
intercepted = append(intercepted, packet)
return nil
}
t.Run("fresh on-chain htlc is sent", func(t *testing.T) {
s := &InterceptableSwitch{
heldHtlcSet: newHeldHtlcSet(),
interceptor: interceptor,
}
fwd := newMockOnChainInterceptedForward(key, 100)
require.NoError(t, s.interceptOnChain(fwd))
require.Len(t, intercepted, 1)
require.Equal(t, key, intercepted[0].IncomingCircuit)
})
t.Run("on-chain htlc replaces off-chain htlc and notifies",
func(t *testing.T) {
intercepted = nil
s := &InterceptableSwitch{
heldHtlcSet: newHeldHtlcSet(),
interceptor: interceptor,
}
offChain := newMockInterceptedForward(key, 80)
onChain := newMockOnChainInterceptedForward(key, 100)
onChain.On("Settle", lntypes.Preimage{}).Return(
nil,
).Once()
require.NoError(t, s.heldHtlcSet.addOffChain(offChain))
require.NoError(t, s.interceptOnChain(onChain))
require.Len(t, intercepted, 1)
require.Equal(t, key, intercepted[0].IncomingCircuit)
require.Equal(
t, int32(100), intercepted[0].AutoFailHeight(),
)
require.NoError(t, s.resolve(&FwdResolution{
Key: key,
Action: FwdActionSettle,
}))
offChain.AssertNotCalled(t, "Settle", mock.Anything)
onChain.AssertExpectations(t)
})
t.Run("on-chain htlc replays after disconnect", func(t *testing.T) {
intercepted = nil
s := &InterceptableSwitch{
heldHtlcSet: newHeldHtlcSet(),
interceptor: interceptor,
}
fwd := newMockOnChainInterceptedForward(key, 100)
fwd.On("Settle", lntypes.Preimage{}).Return(nil).Once()
require.NoError(t, s.interceptOnChain(fwd))
require.Len(t, intercepted, 1)
s.setInterceptor(nil)
require.True(t, s.heldHtlcSet.exists(key))
intercepted = nil
s.setInterceptor(interceptor)
require.Len(t, intercepted, 1)
require.Equal(t, key, intercepted[0].IncomingCircuit)
require.NoError(t, s.resolve(&FwdResolution{
Key: key,
Action: FwdActionSettle,
}))
fwd.AssertExpectations(t)
require.False(t, s.heldHtlcSet.exists(key))
})
t.Run("duplicate on-chain htlc is not sent", func(t *testing.T) {
intercepted = nil
s := &InterceptableSwitch{
heldHtlcSet: newHeldHtlcSet(),
interceptor: interceptor,
}
fwd := newMockOnChainInterceptedForward(key, 100)
require.NoError(t, s.interceptOnChain(fwd))
require.Len(t, intercepted, 1)
intercepted = nil
require.NoError(t, s.interceptOnChain(fwd))
require.Empty(t, intercepted)
})
t.Run("on-chain htlc removed after teardown", func(t *testing.T) {
intercepted = nil
s := &InterceptableSwitch{
heldHtlcSet: newHeldHtlcSet(),
interceptor: interceptor,
}
fwd := newMockOnChainInterceptedForward(key, 100)
require.NoError(t, s.interceptOnChain(fwd))
require.Len(t, intercepted, 1)
s.removeOnChainIntercept(key)
require.False(t, s.heldHtlcSet.exists(key))
intercepted = nil
s.setInterceptor(interceptor)
require.Empty(t, intercepted)
})
}
// TestInterceptableSwitchRemoveOnChainIntercept verifies that the public
// teardown path removes an on-chain hold through the switch run loop.
func TestInterceptableSwitchRemoveOnChainIntercept(t *testing.T) {
notifier := &lntestmock.ChainNotifier{
EpochChan: make(chan *chainntnfs.BlockEpoch, 1),
}
notifier.EpochChan <- &chainntnfs.BlockEpoch{Height: 1}
s, err := NewInterceptableSwitch(&InterceptableSwitchConfig{
Notifier: notifier,
CltvRejectDelta: 10,
CltvInterceptDelta: 13,
})
require.NoError(t, err)
require.NoError(t, s.Start())
defer func() {
require.NoError(t, s.Stop())
}()
intercepted := make(chan InterceptedPacket, 2)
s.SetInterceptor(func(packet InterceptedPacket) error {
intercepted <- packet
return nil
})
key := testCircuitKey()
require.NoError(t, s.ForwardPacket(
newMockOnChainInterceptedForward(key, 100),
))
select {
case packet := <-intercepted:
require.Equal(t, key, packet.IncomingCircuit)
case <-time.After(time.Second):
require.Fail(t, "on-chain hold not intercepted")
}
require.NoError(t, s.RemoveOnChainIntercept(key))
// Re-registering the interceptor replays all currently held HTLCs.
// The removed on-chain hold should not be replayed.
s.SetInterceptor(func(packet InterceptedPacket) error {
intercepted <- packet
return nil
})
// Synchronize with the switch event loop so any replay triggered by the
// interceptor registration above has already run.
require.NoError(t, s.RemoveOnChainIntercept(models.CircuitKey{}))
select {
case packet := <-intercepted:
require.Failf(t, "unexpected replay", "packet=%v", packet)
default:
}
}
// TestInterceptableSwitchForwardPacketReturnsHoldError verifies that
// ForwardPacket returns the error produced while adding the on-chain hold.
func TestInterceptableSwitchForwardPacketReturnsHoldError(t *testing.T) {
notifier := &lntestmock.ChainNotifier{
EpochChan: make(chan *chainntnfs.BlockEpoch, 1),
}
notifier.EpochChan <- &chainntnfs.BlockEpoch{Height: 1}
s, err := NewInterceptableSwitch(&InterceptableSwitchConfig{
Notifier: notifier,
CltvRejectDelta: 10,
CltvInterceptDelta: 13,
})
require.NoError(t, err)
require.NoError(t, s.Start())
defer func() {
require.NoError(t, s.Stop())
}()
key := testCircuitKey()
err = s.ForwardPacket(newMockInterceptedForward(key, 100))
require.ErrorIs(t, err, errInvalidHeldDeadlineType)
require.False(t, s.heldHtlcSet.exists(key))
err = s.ForwardPacket(nil)
require.ErrorIs(t, err, errNilHeldForward)
}

View file

@ -20,9 +20,9 @@ type ForwardingInfo struct {
// node should forward to the next hop.
AmountToForward lnwire.MilliSatoshi
// OutgoingCTLV is the specified value of the CTLV timelock to be used
// OutgoingCLTV is the specified value of the CLTV timelock to be used
// in the outgoing HTLC.
OutgoingCTLV uint32
OutgoingCLTV uint32
// NextBlinding is an optional blinding point to be passed to the next
// node in UpdateAddHtlc. This field is set if the htlc is part of a
@ -34,3 +34,52 @@ type ForwardingInfo struct {
// correct context.
PathID *chainhash.Hash
}
// FinalHtlcValidationResult describes the result of checking a final-hop
// HTLC against the onion payload and supported final-hop CLTV range.
type FinalHtlcValidationResult uint8
const (
// FinalHtlcValid indicates that the HTLC matches the final-hop payload
// and supported final-hop CLTV range.
FinalHtlcValid FinalHtlcValidationResult = iota
// FinalHtlcInvalidAmount indicates that the HTLC amount is below the
// final amount requested by the onion payload.
FinalHtlcInvalidAmount
// FinalHtlcInvalidCltv indicates that the HTLC expiry is below the
// final CLTV requested by the onion payload.
FinalHtlcInvalidCltv
// FinalHtlcExpiryTooFar indicates that the HTLC expiry is outside the
// supported final-hop CLTV range.
FinalHtlcExpiryTooFar
)
// ValidateFinalHtlc checks final-hop HTLC amount and CLTV details before
// invoice resolution.
func ValidateFinalHtlc(amt lnwire.MilliSatoshi, expiry, heightNow,
maxFinalCltvDelta uint32, fwdInfo ForwardingInfo,
validateAmount bool) FinalHtlcValidationResult {
switch {
// The HTLC amount is below the final amount requested by the
// onion payload.
case validateAmount && amt < fwdInfo.AmountToForward:
return FinalHtlcInvalidAmount
// The HTLC expiry is below the final CLTV requested by the onion
// payload.
case expiry < fwdInfo.OutgoingCLTV:
return FinalHtlcInvalidCltv
// The HTLC expiry is outside the supported final-hop CLTV range.
case expiry > heightNow && expiry-heightNow > maxFinalCltvDelta:
return FinalHtlcExpiryTooFar
default:
return FinalHtlcValid
}
}

View file

@ -0,0 +1,137 @@
package hop
import (
"testing"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/stretchr/testify/require"
)
// TestValidateFinalHtlc exercises the final-hop HTLC validation helper.
func TestValidateFinalHtlc(t *testing.T) {
t.Parallel()
const (
amount = lnwire.MilliSatoshi(1000)
expiry = uint32(150)
height = uint32(100)
maxCltvDelta = uint32(50)
)
fwdInfo := ForwardingInfo{
AmountToForward: amount,
OutgoingCLTV: expiry,
NextHop: Exit,
}
testCases := []struct {
name string
amount lnwire.MilliSatoshi
expiry uint32
height uint32
maxCltvDelta uint32
fwdInfo ForwardingInfo
validateAmount bool
expected FinalHtlcValidationResult
}{{
name: "valid",
amount: amount,
expiry: expiry,
height: height + 1,
maxCltvDelta: maxCltvDelta,
fwdInfo: fwdInfo,
validateAmount: true,
expected: FinalHtlcValid,
}, {
name: "amount too low",
amount: amount - 1,
expiry: expiry,
height: height,
maxCltvDelta: maxCltvDelta,
fwdInfo: fwdInfo,
validateAmount: true,
expected: FinalHtlcInvalidAmount,
}, {
name: "amount check disabled",
amount: amount - 1,
expiry: expiry,
height: height,
maxCltvDelta: maxCltvDelta,
fwdInfo: fwdInfo,
validateAmount: false,
expected: FinalHtlcValid,
}, {
name: "final cltv too low",
amount: amount,
expiry: expiry - 1,
height: height,
maxCltvDelta: maxCltvDelta,
fwdInfo: fwdInfo,
validateAmount: true,
expected: FinalHtlcInvalidCltv,
}, {
name: "expiry too far",
amount: amount,
expiry: expiry + 1,
height: height,
maxCltvDelta: maxCltvDelta,
fwdInfo: fwdInfo,
validateAmount: true,
expected: FinalHtlcExpiryTooFar,
}, {
name: "expiry at maximum",
amount: amount,
expiry: expiry,
height: height,
maxCltvDelta: maxCltvDelta,
fwdInfo: fwdInfo,
validateAmount: true,
expected: FinalHtlcValid,
}, {
name: "height above expiry",
amount: amount,
expiry: expiry,
height: expiry + 1,
maxCltvDelta: maxCltvDelta,
fwdInfo: fwdInfo,
validateAmount: true,
expected: FinalHtlcValid,
}, {
name: "amount failure takes precedence",
amount: amount - 1,
expiry: expiry - 1,
height: height,
maxCltvDelta: maxCltvDelta,
fwdInfo: fwdInfo,
validateAmount: true,
expected: FinalHtlcInvalidAmount,
}, {
name: "cltv failure takes precedence over " +
"expiry too far",
amount: amount,
expiry: expiry + maxCltvDelta + 1,
height: height,
maxCltvDelta: maxCltvDelta,
fwdInfo: ForwardingInfo{
AmountToForward: amount,
OutgoingCLTV: expiry + maxCltvDelta + 2,
NextHop: Exit,
},
validateAmount: true,
expected: FinalHtlcInvalidCltv,
}}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()
result := ValidateFinalHtlc(
testCase.amount, testCase.expiry,
testCase.height, testCase.maxCltvDelta,
testCase.fwdInfo, testCase.validateAmount,
)
require.Equal(t, testCase.expected, result)
})
}
}

View file

@ -84,7 +84,7 @@ func FuzzOnionPacket(f *testing.F) {
func hopFromPayload(p *Payload) (*route.Hop, uint64) {
return &route.Hop{
AmtToForward: p.FwdInfo.AmountToForward,
OutgoingTimeLock: p.FwdInfo.OutgoingCTLV,
OutgoingTimeLock: p.FwdInfo.OutgoingCLTV,
MPP: p.MPP,
AMP: p.AMP,
Metadata: p.metadata,

View file

@ -327,7 +327,7 @@ func deriveBlindedRouteForwardingInfo(r *sphinxHopIterator,
payload.FwdInfo = ForwardingInfo{
NextHop: nextSCID.Val,
AmountToForward: fwdAmt,
OutgoingCTLV: r.blindingKit.IncomingCltv - uint32(
OutgoingCLTV: r.blindingKit.IncomingCltv - uint32(
relayInfo.Val.CltvExpiryDelta,
),
// Remap from blinding override type to blinding point type.

View file

@ -35,7 +35,7 @@ func TestSphinxHopIteratorForwardingInstructions(t *testing.T) {
expectedFwdInfo := ForwardingInfo{
NextHop: lnwire.NewShortChanIDFromInt(nextAddrInt),
AmountToForward: lnwire.MilliSatoshi(hopData.ForwardAmount),
OutgoingCTLV: hopData.OutgoingCltv,
OutgoingCLTV: hopData.OutgoingCltv,
}
// For our TLV payload, we'll serialize the hop into into a TLV stream

View file

@ -128,7 +128,7 @@ func NewLegacyPayload(f *sphinx.HopData) *Payload {
FwdInfo: ForwardingInfo{
NextHop: lnwire.NewShortChanIDFromInt(nextHop),
AmountToForward: lnwire.MilliSatoshi(f.ForwardAmount),
OutgoingCTLV: f.OutgoingCltv,
OutgoingCLTV: f.OutgoingCltv,
},
customRecords: make(record.CustomSet),
}
@ -203,7 +203,7 @@ func ParseTLVPayload(r io.Reader) (*Payload, map[tlv.Type][]byte, error) {
FwdInfo: ForwardingInfo{
NextHop: lnwire.NewShortChanIDFromInt(cid),
AmountToForward: lnwire.MilliSatoshi(amt),
OutgoingCTLV: cltv,
OutgoingCLTV: cltv,
},
MPP: mpp,
AMP: amp,

View file

@ -50,7 +50,11 @@ type InterceptableSwitch struct {
// interceptor client.
resolutionChan chan *fwdResolution
onchainIntercepted chan InterceptedForward
onchainIntercepted chan *onchainInterceptRequest
// onchainInterceptDone receives circuit keys for on-chain intercepted
// forwards whose contractcourt resolver has finished.
onchainInterceptDone chan models.CircuitKey
// interceptorRegistration is a channel that we use to synchronize
// client connect and disconnect.
@ -99,6 +103,11 @@ type interceptedPackets struct {
isReplay bool
}
type onchainInterceptRequest struct {
fwd InterceptedForward
errChan chan error
}
// FwdAction defines the various resolution types.
type FwdAction int
@ -195,7 +204,8 @@ func NewInterceptableSwitch(cfg *InterceptableSwitchConfig) (
return &InterceptableSwitch{
htlcSwitch: cfg.Switch,
intercepted: make(chan *interceptedPackets),
onchainIntercepted: make(chan InterceptedForward),
onchainIntercepted: make(chan *onchainInterceptRequest),
onchainInterceptDone: make(chan models.CircuitKey),
interceptorRegistration: make(chan ForwardInterceptor),
heldHtlcSet: newHeldHtlcSet(),
resolutionChan: make(chan *fwdResolution),
@ -317,18 +327,20 @@ func (s *InterceptableSwitch) run() error {
log.Errorf("Cannot forward packets: %v", err)
}
case fwd := <-s.onchainIntercepted:
// For on-chain interceptions, we don't know if it has
// already been offered before. This information is in
// the forwarding package which isn't easily accessible
// from contractcourt. It is likely though that it was
// already intercepted in the off-chain flow. And even
// if not, it is safe to signal replay so that we won't
// unexpectedly skip over this htlc.
if _, err := s.forward(fwd, true); err != nil {
return err
case req := <-s.onchainIntercepted:
notify, err := s.holdOnChain(req.fwd)
req.errChan <- err
if err != nil {
continue
}
if s.interceptor != nil && notify {
s.sendForward(req.fwd)
}
case key := <-s.onchainInterceptDone:
s.removeOnChainIntercept(key)
case res := <-s.resolutionChan:
res.errChan <- s.resolve(res.resolution)
@ -339,8 +351,10 @@ func (s *InterceptableSwitch) run() error {
s.currentHeight = currentBlock.Height
// A new block is appended. Fail any held htlcs that
// expire at this height to prevent channel force-close.
// A new block is appended. Expire any held HTLCs whose
// deadline has passed. Off-chain HTLCs fail back, while
// on-chain HTLCs are only pruned from the local hold
// set.
s.failExpiredHtlcs()
case <-s.quit:
@ -350,17 +364,11 @@ func (s *InterceptableSwitch) run() error {
}
func (s *InterceptableSwitch) failExpiredHtlcs() {
s.heldHtlcSet.popAutoFails(
uint32(s.currentHeight),
func(fwd InterceptedForward) {
err := fwd.FailWithCode(
lnwire.CodeTemporaryChannelFailure,
)
if err != nil {
log.Errorf("Cannot fail packet: %v", err)
}
},
)
errs := s.heldHtlcSet.expire(uint32(s.currentHeight))
for _, expireErr := range errs {
log.Errorf("Cannot expire held htlc %v: %v", expireErr.key,
expireErr.err)
}
}
func (s *InterceptableSwitch) sendForward(fwd InterceptedForward) {
@ -394,48 +402,20 @@ func (s *InterceptableSwitch) setInterceptor(interceptor ForwardInterceptor) {
return
}
// Interceptor is not required. Release held forwards.
// Interceptor is not required. Release off-chain held forwards.
log.Infof("Interceptor disconnected, resolving held packets")
s.heldHtlcSet.popAll(func(fwd InterceptedForward) {
err := fwd.Resume()
if err != nil {
log.Errorf("Failed to resume hold forward %v", err)
}
})
errs := s.heldHtlcSet.releaseAllOffChainHeld()
for _, releaseErr := range errs {
log.Errorf("Failed to resume hold forward %v: %v",
releaseErr.key, releaseErr.err)
}
}
// resolve processes a HTLC given the resolution type specified by the
// intercepting client.
func (s *InterceptableSwitch) resolve(res *FwdResolution) error {
intercepted, err := s.heldHtlcSet.pop(res.Key)
if err != nil {
return err
}
switch res.Action {
case FwdActionResume:
return intercepted.Resume()
case FwdActionResumeModified:
return intercepted.ResumeModified(
res.InAmountMsat, res.OutAmountMsat,
res.OutWireCustomRecords,
)
case FwdActionSettle:
return intercepted.Settle(res.Preimage)
case FwdActionFail:
if len(res.FailureMessage) > 0 {
return intercepted.Fail(res.FailureMessage)
}
return intercepted.FailWithCode(res.FailureCode)
default:
return fmt.Errorf("unrecognized action %v", res.Action)
}
return s.heldHtlcSet.resolve(res)
}
// Resolve resolves an intercepted packet.
@ -487,12 +467,38 @@ func (s *InterceptableSwitch) ForwardPackets(linkQuit <-chan struct{},
return nil
}
// ForwardPacket forwards a single htlc to the external interceptor.
// ForwardPacket records a single on-chain HTLC for interception. It returns
// once the switch run loop has accepted or rejected the held entry.
func (s *InterceptableSwitch) ForwardPacket(
fwd InterceptedForward) error {
errChan := make(chan error, 1)
select {
case s.onchainIntercepted <- fwd:
case s.onchainIntercepted <- &onchainInterceptRequest{
fwd: fwd,
errChan: errChan,
}:
case <-s.quit:
return errors.New("interceptable switch quit")
}
select {
case err := <-errChan:
return err
case <-s.quit:
return errors.New("interceptable switch quit")
}
}
// RemoveOnChainIntercept removes an on-chain intercepted forward from the held
// set once its contractcourt resolver has finished.
func (s *InterceptableSwitch) RemoveOnChainIntercept(
key models.CircuitKey) error {
select {
case s.onchainInterceptDone <- key:
case <-s.quit:
return errors.New("interceptable switch quit")
@ -542,15 +548,16 @@ func (s *InterceptableSwitch) interceptForward(packet *htlcPacket,
return true, nil
}
return s.forward(intercepted, isReplay)
return s.forwardOffChain(intercepted, isReplay)
default:
return false, nil
}
}
// forward records the intercepted htlc and forwards it to the interceptor.
func (s *InterceptableSwitch) forward(
// forwardOffChain records an off-chain intercepted htlc and forwards it to the
// interceptor if needed.
func (s *InterceptableSwitch) forwardOffChain(
fwd InterceptedForward, isReplay bool) (bool, error) {
inKey := fwd.Packet().IncomingCircuit
@ -585,16 +592,16 @@ func (s *InterceptableSwitch) forward(
// This packet is a replay. It is not safe to fail back, because the
// interceptor may still signal otherwise upon reconnect. Keep the
// packet in the queue until then.
if err := s.heldHtlcSet.push(inKey, fwd); err != nil {
if err := s.heldHtlcSet.addOffChain(fwd); err != nil {
return false, err
}
return true, nil
}
// There is an interceptor registered. We can forward the packet right now.
// Hold it in the queue too to track what is outstanding.
if err := s.heldHtlcSet.push(inKey, fwd); err != nil {
// There is an interceptor registered. We can notify it right now. Hold
// the packet in the queue too to track what is outstanding.
if err := s.heldHtlcSet.addOffChain(fwd); err != nil {
return false, err
}
@ -603,6 +610,57 @@ func (s *InterceptableSwitch) forward(
return true, nil
}
// interceptOnChain records an on-chain intercepted htlc. This doesn't resume or
// forward the htlc through the link. If this HTLC is not already held on-chain,
// the interceptor is notified so the client can settle it. If it is currently
// held off-chain, the stored entry is replaced and the client is notified again
// with the on-chain deadline and settle-only semantics.
func (s *InterceptableSwitch) interceptOnChain(fwd InterceptedForward) error {
notify, err := s.holdOnChain(fwd)
if err != nil {
return err
}
if s.interceptor != nil && notify {
s.sendForward(fwd)
}
return nil
}
// holdOnChain records an on-chain intercepted HTLC and reports whether it
// should be offered to the external interceptor.
func (s *InterceptableSwitch) holdOnChain(
fwd InterceptedForward) (bool, error) {
if fwd == nil {
return false, errNilHeldForward
}
inKey := fwd.Packet().IncomingCircuit
// An already on-chain held HTLC has already been offered with its
// on-chain deadline. Treat duplicate contractcourt offers as no-ops to
// avoid re-notifying the interceptor for the same on-chain state.
if _, ok := s.heldHtlcSet.set[inKey].(*onChainHeld); ok {
return false, nil
}
if err := s.heldHtlcSet.addOnChain(fwd); err != nil {
return false, err
}
return true, nil
}
// removeOnChainIntercept removes an on-chain held HTLC after contractcourt no
// longer needs the interceptor replay handle.
func (s *InterceptableSwitch) removeOnChainIntercept(key models.CircuitKey) {
if s.heldHtlcSet.removeOnChainHeld(key) {
log.Debugf("Removed on-chain held htlc %v", key)
}
}
// handleExpired checks that the htlc isn't too close to the channel
// force-close broadcast height. If it is, it is cancelled back.
func (s *InterceptableSwitch) handleExpired(fwd *interceptedForward) (
@ -654,8 +712,10 @@ func (f *interceptedForward) Packet() InterceptedPacket {
IncomingExpiry: f.packet.incomingTimeout,
InOnionCustomRecords: f.packet.inOnionCustomRecords,
OnionBlob: f.htlc.OnionBlob,
AutoFailHeight: f.autoFailHeight,
InWireCustomRecords: f.packet.inWireCustomRecords,
Deadline: fn.NewLeft[
OffChainAutoFailHeight, OnChainSettleDeadline,
](OffChainAutoFailHeight(f.autoFailHeight)),
InWireCustomRecords: f.packet.inWireCustomRecords,
}
}

View file

@ -419,9 +419,34 @@ type InterceptedPacket struct {
// were defined by the peer that forwarded this HTLC to us.
InWireCustomRecords lnwire.CustomRecords
// AutoFailHeight is the block height at which this intercept will be
// failed back automatically.
AutoFailHeight int32
// Deadline describes how long this intercepted HTLC remains actionable.
// Off-chain forwards are auto-failed at this height, while on-chain
// forwards can be settled until this height.
Deadline fn.Either[OffChainAutoFailHeight, OnChainSettleDeadline]
}
// OffChainAutoFailHeight is the block height at which an off-chain intercepted
// HTLC will be failed back automatically to prevent the incoming channel from
// force-closing.
type OffChainAutoFailHeight int32
// OnChainSettleDeadline is the block height until which an on-chain
// intercepted HTLC can be settled before the timeout path becomes available.
type OnChainSettleDeadline int32
// AutoFailHeight returns the legacy RPC auto_fail_height projection for an
// intercepted packet. For on-chain packets, the value is the settlement
// deadline exposed through the existing RPC field for compatibility.
func (p InterceptedPacket) AutoFailHeight() int32 {
return fn.ElimEither(
p.Deadline,
func(h OffChainAutoFailHeight) int32 {
return int32(h)
},
func(d OnChainSettleDeadline) int32 {
return int32(d)
},
)
}
// InterceptedForward is passed to the ForwardInterceptor for every forwarded

View file

@ -2517,13 +2517,17 @@ func (l *channelLink) CheckHtlcForward(payHash [32]byte, incomingHtlcAmt,
// Finally, we'll ensure that the time-lock on the outgoing HTLC meets
// the following constraint: the incoming time-lock minus our time-lock
// delta should equal the outgoing time lock. Otherwise, whether the
// delta should equal the outgoing time lock. Otherwise, either the
// sender messed up, or an intermediate node tampered with the HTLC.
timeDelta := policy.TimeLockDelta
if incomingTimeout < outgoingTimeout+timeDelta {
var incomingDelta uint32
if incomingTimeout >= outgoingTimeout {
incomingDelta = incomingTimeout - outgoingTimeout
}
if incomingTimeout < outgoingTimeout || incomingDelta < timeDelta {
l.log.Warnf("incoming htlc(%x) has incorrect time-lock value: "+
"expected at least %v block delta, got %v block delta",
payHash[:], timeDelta, incomingTimeout-outgoingTimeout)
payHash[:], timeDelta, incomingDelta)
// Grab the latest routing policy so the sending node is up to
// date with our current policy.
@ -2536,6 +2540,17 @@ func (l *channelLink) CheckHtlcForward(payHash [32]byte, incomingHtlcAmt,
return NewLinkError(failure)
}
// Check that the incoming to outgoing time-lock delta is within the
// configured CLTV range.
if incomingDelta > l.cfg.MaxOutgoingCltvExpiry {
l.log.Warnf("incoming htlc(%x) has a time-lock delta "+
"outside the configured CLTV range: got %v, "+
"but maximum is %v",
payHash[:], incomingDelta, l.cfg.MaxOutgoingCltvExpiry)
return NewLinkError(&lnwire.FailExpiryTooFar{})
}
return nil
}
@ -3131,7 +3146,7 @@ func (l *channelLink) processRemoteAdds(fwdPkg *channeldb.FwdPkg) {
obfuscator, false,
)
l.log.Error("rejected htlc that uses use as an " +
l.log.Error("rejected htlc that uses us as an " +
"introduction point when we do not support " +
"route blinding")
@ -3185,7 +3200,7 @@ func (l *channelLink) processRemoteAdds(fwdPkg *channeldb.FwdPkg) {
// Otherwise, it was already processed, we can
// can collect it and continue.
outgoingAdd := &lnwire.UpdateAddHTLC{
Expiry: fwdInfo.OutgoingCTLV,
Expiry: fwdInfo.OutgoingCLTV,
Amount: fwdInfo.AmountToForward,
PaymentHash: add.PaymentHash,
BlindingPoint: fwdInfo.NextBlinding,
@ -3224,7 +3239,7 @@ func (l *channelLink) processRemoteAdds(fwdPkg *channeldb.FwdPkg) {
htlc: outgoingAdd,
obfuscator: obfuscator,
incomingTimeout: add.Expiry,
outgoingTimeout: fwdInfo.OutgoingCTLV,
outgoingTimeout: fwdInfo.OutgoingCLTV,
inOnionCustomRecords: pld.CustomRecords(),
inboundFee: inboundFee,
inWireCustomRecords: add.CustomRecords.Copy(),
@ -3243,7 +3258,7 @@ func (l *channelLink) processRemoteAdds(fwdPkg *channeldb.FwdPkg) {
// create the outgoing HTLC using the parameters as
// specified in the forwarding info.
addMsg := &lnwire.UpdateAddHTLC{
Expiry: fwdInfo.OutgoingCTLV,
Expiry: fwdInfo.OutgoingCLTV,
Amount: fwdInfo.AmountToForward,
PaymentHash: add.PaymentHash,
BlindingPoint: fwdInfo.NextBlinding,
@ -3301,7 +3316,7 @@ func (l *channelLink) processRemoteAdds(fwdPkg *channeldb.FwdPkg) {
htlc: addMsg,
obfuscator: obfuscator,
incomingTimeout: add.Expiry,
outgoingTimeout: fwdInfo.OutgoingCTLV,
outgoingTimeout: fwdInfo.OutgoingCLTV,
inOnionCustomRecords: pld.CustomRecords(),
inboundFee: inboundFee,
inWireCustomRecords: add.CustomRecords.Copy(),
@ -3406,11 +3421,16 @@ func (l *channelLink) processExitHop(add lnwire.UpdateAddHTLC,
},
)
switch hop.ValidateFinalHtlc(
add.Amount, add.Expiry, heightNow,
invoices.MaxFinalCltvDelta,
fwdInfo, !isCustomHTLC,
) {
// As we're the exit hop, we'll double check the hop-payload included in
// the HTLC to ensure that it was crafted correctly by the sender and
// is compatible with the HTLC we were extended. If an external
// validator is active we might bypass the amount check.
if !isCustomHTLC && add.Amount < fwdInfo.AmountToForward {
case hop.FinalHtlcInvalidAmount:
l.log.Errorf("onion payload of incoming htlc(%x) has "+
"incompatible value: expected <=%v, got %v",
add.PaymentHash, add.Amount, fwdInfo.AmountToForward)
@ -3421,14 +3441,13 @@ func (l *channelLink) processExitHop(add lnwire.UpdateAddHTLC,
l.sendHTLCError(add, sourceRef, failure, obfuscator, true)
return nil
}
// We'll also ensure that our time-lock value has been computed
// correctly.
if add.Expiry < fwdInfo.OutgoingCTLV {
case hop.FinalHtlcInvalidCltv:
l.log.Errorf("onion payload of incoming htlc(%x) has "+
"incompatible time-lock: expected <=%v, got %v",
add.PaymentHash, add.Expiry, fwdInfo.OutgoingCTLV)
add.PaymentHash, add.Expiry, fwdInfo.OutgoingCLTV)
failure := NewLinkError(
lnwire.NewFinalIncorrectCltvExpiry(add.Expiry),
@ -3436,6 +3455,22 @@ func (l *channelLink) processExitHop(add lnwire.UpdateAddHTLC,
l.sendHTLCError(add, sourceRef, failure, obfuscator, true)
return nil
// Check that the incoming HTLC expiry is within the supported final-hop
// CLTV range.
case hop.FinalHtlcExpiryTooFar:
l.log.Warnf("incoming htlc(%x) has a final-hop CLTV delta "+
"outside the supported range: got %v, but maximum "+
"is %v",
add.PaymentHash, add.Expiry-heightNow,
invoices.MaxFinalCltvDelta)
failure := NewLinkError(
lnwire.NewFailIncorrectDetails(add.Amount, heightNow),
)
l.sendHTLCError(add, sourceRef, failure, obfuscator, true)
return nil
}
@ -3547,8 +3582,8 @@ func (l *channelLink) forwardBatch(replay bool, packets ...*htlcPacket) {
}
}
// sendHTLCError functions cancels HTLC and send cancel message back to the
// peer from which HTLC was received.
// sendHTLCError cancels the HTLC and sends a cancel message back to the peer
// from which the HTLC was received.
func (l *channelLink) sendHTLCError(add lnwire.UpdateAddHTLC,
sourceRef channeldb.AddRef, failure *LinkError,
e hop.ErrorEncrypter, isReceive bool) {
@ -3561,7 +3596,7 @@ func (l *channelLink) sendHTLCError(add lnwire.UpdateAddHTLC,
err = l.channel.FailHTLC(add.ID, reason, &sourceRef, nil, nil)
if err != nil {
l.log.Errorf("unable cancel htlc: %v", err)
l.log.Errorf("unable to cancel htlc: %v", err)
return
}
@ -4259,7 +4294,7 @@ func (l *channelLink) processRemoteCommitSig(ctx context.Context,
// want to ensure we release that memory back to the runtime.
l.uncommittedPreimages = nil
// We just received a new updates to our local commitment chain,
// We just received new updates to our local commitment chain,
// validate this new commitment, closing the link if invalid.
auxSigBlob, err := msg.CustomRecords.Serialize()
if err != nil {
@ -4587,7 +4622,7 @@ func (l *channelLink) processLocalUpdateFulfillHTLC(ctx context.Context,
}
// An HTLC we forward to the switch has just settled somewhere upstream.
// Therefore we settle the HTLC within the our local state machine.
// Therefore we settle the HTLC within our local state machine.
inKey := pkt.inKey()
err := l.channel.SettleHTLC(
htlc.PaymentPreimage, pkt.incomingHTLCID, pkt.sourceRef,
@ -4654,7 +4689,7 @@ func (l *channelLink) processLocalUpdateFailHTLC(ctx context.Context,
}
// An HTLC cancellation has been triggered somewhere upstream, we'll
// remove then HTLC from our local state machine.
// remove the HTLC from our local state machine.
inKey := pkt.inKey()
err := l.channel.FailHTLC(
pkt.incomingHTLCID, htlc.Reason, pkt.sourceRef, pkt.destRef,

View file

@ -237,11 +237,37 @@ func (l *linkTestContext) sendSettleBobToAlice(htlcID uint64,
l.aliceLink.HandleChannelUpdate(settle)
}
// receiveSettleAliceToBob waits for Alice to send a HTLC settle message to
// Bob, then hands this to Bob.
// receiveFailAliceToBob waits for Alice to fail an HTLC to Bob.
func (l *linkTestContext) receiveFailAliceToBob() {
l.t.Helper()
l.receiveFailAliceToBobMsg()
}
// receiveFailAliceToBobWithCode waits for Alice to fail an HTLC to Bob and
// verifies that the failure code matches the expectation.
func (l *linkTestContext) receiveFailAliceToBobWithCode(
code lnwire.FailCode) {
l.t.Helper()
failMsg := l.receiveFailAliceToBobMsg()
failure, err := newMockDeobfuscator().DecryptError(failMsg.Reason)
if err != nil {
l.t.Fatalf("unable to decrypt failure: %v", err)
}
if failure.WireMessage().Code() != code {
l.t.Fatalf("expected %v but got %v",
code, failure.WireMessage().Code())
}
}
// receiveFailAliceToBobMsg waits for Alice to send a fail HTLC message to Bob,
// applies it to Bob, and returns the message.
func (l *linkTestContext) receiveFailAliceToBobMsg() *lnwire.UpdateFailHTLC {
l.t.Helper()
var msg lnwire.Message
select {
case msg = <-l.aliceMsgs:
@ -258,6 +284,8 @@ func (l *linkTestContext) receiveFailAliceToBob() {
if err != nil {
l.t.Fatalf("unable to apply received fail htlc: %v", err)
}
return failMsg
}
// assertNoMsgFromAlice asserts that Alice hasn't sent a message. Before

View file

@ -779,13 +779,13 @@ func testChannelLinkInboundFee(t *testing.T, //nolint:thelper
NextHop: n.carolChannelLink.
ShortChanID(),
AmountToForward: 1_000_000,
OutgoingCTLV: 106,
OutgoingCLTV: 106,
},
},
{
FwdInfo: hop.ForwardingInfo{
AmountToForward: 1_000_000,
OutgoingCTLV: 106,
OutgoingCLTV: 106,
},
},
}
@ -974,7 +974,7 @@ func TestExitNodeHTLCTimelockExceedsPayload(t *testing.T) {
// The proper value of the outgoing CLTV should be the policy set by
// the receiving node, instead we set it to be a value less than the
// incoming HTLC timelock.
hops[0].FwdInfo.OutgoingCTLV = htlcExpiry - 1
hops[0].FwdInfo.OutgoingCLTV = htlcExpiry - 1
firstHop := n.firstBobChannelLink.ShortChanID()
_, err = makePayment(
n.aliceServer, n.bobServer, firstHop, hops, amount, htlcAmt,
@ -1012,7 +1012,7 @@ func TestExitNodeTimelockPayloadExceedsHTLC(t *testing.T) {
// The proper value of the outgoing CLTV should be the policy set by
// the receiving node, instead we set it to be a value greater than the
// incoming HTLC timelock.
hops[0].FwdInfo.OutgoingCTLV = htlcExpiry + 1
hops[0].FwdInfo.OutgoingCLTV = htlcExpiry + 1
firstHop := n.firstBobChannelLink.ShortChanID()
_, err = makePayment(
n.aliceServer, n.bobServer, firstHop, hops, amount, htlcAmt,
@ -6320,17 +6320,51 @@ func TestCheckHtlcForward(t *testing.T) {
})
t.Run("cltv expiry too far in the future", func(t *testing.T) {
// Check that expiry isn't too far in the future.
t.Run("cltv expiry outside supported range", func(t *testing.T) {
// Check that expiry stays within the supported range.
result := link.CheckHtlcForward(
hash, 1500, 1000, 10200, 10100, models.InboundFee{}, 0,
lnwire.ShortChannelID{}, nil,
)
_, ok := result.WireMessage().(*lnwire.FailExpiryTooFar)
if !ok {
t.Fatalf("expected FailExpiryTooFar failure code")
}
})
t.Run("incoming cltv delta outside range", func(t *testing.T) {
result := link.CheckHtlcForward(
hash, 1500, 1000, 150+DefaultMaxOutgoingCltvExpiry+1,
150, models.InboundFee{}, 0, lnwire.ShortChannelID{},
nil,
)
if _, ok := result.WireMessage().(*lnwire.FailExpiryTooFar); !ok {
t.Fatalf("expected FailExpiryTooFar failure code")
}
})
t.Run("incoming cltv delta at maximum", func(t *testing.T) {
result := link.CheckHtlcForward(
hash, 1500, 1000, 150+DefaultMaxOutgoingCltvExpiry,
150, models.InboundFee{}, 0, lnwire.ShortChannelID{},
nil,
)
require.Nil(t, result)
})
t.Run("incoming cltv below outgoing cltv", func(t *testing.T) {
result := link.CheckHtlcForward(
hash, 1500, 1000, 190, 200, models.InboundFee{}, 0,
lnwire.ShortChannelID{}, nil,
)
_, ok := result.WireMessage().(*lnwire.FailIncorrectCltvExpiry)
if !ok {
t.Fatalf(
"expected FailIncorrectCltvExpiry failure code",
)
}
})
t.Run("inbound fee satisfied", func(t *testing.T) {
t.Parallel()
@ -6662,6 +6696,121 @@ func TestChannelLinkHoldInvoiceRestart(t *testing.T) {
}
}
// TestChannelLinkExitHopExpiryTooFar asserts that an exit hop fails an
// incoming HTLC if its expiry is outside the supported range.
func TestChannelLinkExitHopExpiryTooFar(t *testing.T) {
t.Parallel()
const chanAmt = btcutil.SatoshiPerBitcoin * 5
harness, err := newSingleLinkTestHarness(t, chanAmt, 0)
require.NoError(t, err, "unable to create link")
if err := harness.start(); err != nil {
t.Fatalf("unable to start test harness: %v", err)
}
t.Cleanup(harness.aliceLink.Stop)
coreLink, ok := harness.aliceLink.(*channelLink)
require.True(t, ok)
registry, ok := coreLink.cfg.Registry.(*mockInvoiceRegistry)
require.True(t, ok)
alicePeer, ok := coreLink.cfg.Peer.(*mockPeer)
require.True(t, ok)
aliceMsgs := alicePeer.sentMsgs
registry.settleChan = make(chan lntypes.Hash)
htlc, invoice := generateHtlcAndInvoice(t, 0)
htlc.Expiry = testStartingHeight +
invpkg.MaxFinalCltvDelta + 1
err = registry.AddInvoice(t.Context(), *invoice, htlc.PaymentHash)
require.NoError(t, err, "unable to add invoice to registry")
ctx := linkTestContext{
t: t,
aliceSwitch: harness.aliceSwitch,
aliceLink: harness.aliceLink,
aliceMsgs: aliceMsgs,
bobChannel: harness.bobChannel,
}
ctx.sendHtlcBobToAlice(htlc)
ctx.sendCommitSigBobToAlice(1)
ctx.receiveRevAndAckAliceToBob()
ctx.receiveCommitSigAliceToBob(1)
ctx.sendRevAndAckBobToAlice()
ctx.receiveFailAliceToBobWithCode(
lnwire.CodeIncorrectOrUnknownPaymentDetails,
)
ctx.receiveCommitSigAliceToBob(0)
select {
case <-registry.settleChan:
t.Fatal("exit hop notification received")
case <-time.After(time.Second):
}
}
// TestChannelLinkExitHopExpiryAtMaximum asserts that an exit hop accepts an
// incoming HTLC if its expiry is exactly at the maximum.
func TestChannelLinkExitHopExpiryAtMaximum(t *testing.T) {
t.Parallel()
const chanAmt = btcutil.SatoshiPerBitcoin * 5
harness, err := newSingleLinkTestHarness(t, chanAmt, 0)
require.NoError(t, err, "unable to create link")
if err := harness.start(); err != nil {
t.Fatalf("unable to start test harness: %v", err)
}
t.Cleanup(harness.aliceLink.Stop)
coreLink, ok := harness.aliceLink.(*channelLink)
require.True(t, ok)
registry, ok := coreLink.cfg.Registry.(*mockInvoiceRegistry)
require.True(t, ok)
alicePeer, ok := coreLink.cfg.Peer.(*mockPeer)
require.True(t, ok)
aliceMsgs := alicePeer.sentMsgs
registry.settleChan = make(chan lntypes.Hash)
htlc, invoice := generateHtlcAndInvoice(t, 0)
htlc.Expiry = testStartingHeight +
invpkg.MaxFinalCltvDelta
err = registry.AddInvoice(t.Context(), *invoice, htlc.PaymentHash)
require.NoError(t, err, "unable to add invoice to registry")
ctx := linkTestContext{
t: t,
aliceSwitch: harness.aliceSwitch,
aliceLink: harness.aliceLink,
aliceMsgs: aliceMsgs,
bobChannel: harness.bobChannel,
}
ctx.sendHtlcBobToAlice(htlc)
ctx.sendCommitSigBobToAlice(1)
ctx.receiveRevAndAckAliceToBob()
ctx.receiveCommitSigAliceToBob(1)
ctx.sendRevAndAckBobToAlice()
select {
case <-registry.settleChan:
case <-time.After(5 * time.Second):
t.Fatal("expected exit hop notification")
}
ctx.receiveSettleAliceToBob()
ctx.receiveCommitSigAliceToBob(0)
}
// TestChannelLinkRevocationWindowRegular asserts that htlcs paying to a regular
// invoice are settled even if the revocation window gets exhausted.
func TestChannelLinkRevocationWindowRegular(t *testing.T) {

View file

@ -375,7 +375,8 @@ func encodeFwdInfo(w io.Writer, f *hop.ForwardingInfo) error {
return err
}
if err := binary.Write(w, binary.BigEndian, f.OutgoingCTLV); err != nil {
err := binary.Write(w, binary.BigEndian, f.OutgoingCLTV)
if err != nil {
return err
}
@ -514,7 +515,7 @@ func (p *mockIteratorDecoder) DecodeHopIterator(r io.Reader, rHash []byte,
Realm: [1]byte{}, // hop.BitcoinNetwork
NextAddress: nextHopBytes,
ForwardAmount: uint64(f.AmountToForward),
OutgoingCltv: f.OutgoingCTLV,
OutgoingCltv: f.OutgoingCLTV,
})
}
@ -569,7 +570,8 @@ func decodeFwdInfo(r io.Reader, f *hop.ForwardingInfo) error {
return err
}
if err := binary.Read(r, binary.BigEndian, &f.OutgoingCTLV); err != nil {
err := binary.Read(r, binary.BigEndian, &f.OutgoingCLTV)
if err != nil {
return err
}

View file

@ -3603,7 +3603,7 @@ func getThreeHopEvents(channels *clusterChannels, htlcID uint64,
bobInfo := HtlcInfo{
IncomingTimeLock: htlc.Expiry,
IncomingAmt: htlc.Amount,
OutgoingTimeLock: hops[1].FwdInfo.OutgoingCTLV,
OutgoingTimeLock: hops[1].FwdInfo.OutgoingCLTV,
OutgoingAmt: hops[1].FwdInfo.AmountToForward,
}
@ -4216,7 +4216,7 @@ func TestInterceptableSwitchWatchDog(t *testing.T) {
require.Equal(t,
int32(packet.incomingTimeout-c.cltvRejectDelta),
intercepted.AutoFailHeight,
intercepted.AutoFailHeight(),
)
// Htlc expires before a resolution from the interceptor.

View file

@ -3,6 +3,7 @@ package invoices
import (
"errors"
"fmt"
"math"
"strings"
"time"
@ -22,6 +23,11 @@ const (
// TODO(halseth): determine the max length payment request when field
// lengths are final.
MaxPaymentRequestSize = 4096
// MaxFinalCltvDelta is the upper bound for final CLTV deltas used by
// invoice creation and final-hop HTLC validation. It matches
// routing.MaxCLTVDelta.
MaxFinalCltvDelta = math.MaxUint16
)
var (

View file

@ -443,6 +443,14 @@ var allTestCases = []*lntest.TestCase{
Name: "forward interceptor restart",
TestFunc: testForwardInterceptorRestart,
},
{
Name: "forward interceptor on chain settle after restart",
TestFunc: testForwardInterceptorOnChainSettleAfterRestart,
},
{
Name: "forward interceptor on chain settle no restart",
TestFunc: testForwardInterceptorOnChainSettleNoRestart,
},
{
Name: "invoice HTLC modifier basic",
TestFunc: testInvoiceHtlcModifierBasic,

View file

@ -59,18 +59,38 @@ type estimateRouteFeeTestCase struct {
}
// testEstimateRouteFee tests the estimation of routing fees using either graph
// data or sending out a probe payment.
// data or sending out a probe payment. This test validates graph-based fee
// estimation, probe-based fee estimation with single LSP, probe-based fee
// estimation with multiple route hints to same LSP (worst-case fee selection),
// probe-based fee estimation with multiple different public LSPs (worst-case
// fee selection across LSPs, up to MaxLspsToProbe), and non-LSP probing (all
// private destination hops).
//
// Note: We test with exactly MaxLspsToProbe (3) LSPs. Testing with more LSPs
// is not feasible because the LSP selection uses map iteration, which has
// non-deterministic order in Go, making it impossible to predict which LSPs
// will be probed.
func testEstimateRouteFee(ht *lntest.HarnessTest) {
// Ensure MaxLspsToProbe is set to 3 as expected by this test. The test
// uses exactly 3 LSPs in the multi-LSP test case. If MaxLspsToProbe
// changes, this assertion will fail as a reminder to update the test.
require.Equal(ht, 3, routerrpc.MaxLspsToProbe,
"MaxLspsToProbe should be 3")
mts := newMppTestScenario(ht)
// We extend the regular mpp test scenario with a new node Paula. Paula
// is connected to Bob and Eve through private channels.
// We extend the regular mpp test scenario with two new nodes:
// - Paula: connected to Bob and Eve through private channels
// - Frank: connected to Dave through a private channel
//
// /-------------\
// _ Eve _ (private) \
// / \ \
// Alice -- Carol ---- Bob --------- Paula
// \ / (private)
// \__ Dave ____/
// \
// \__ Frank (private)
//
req := &mppOpenChannelRequest{
amtAliceCarol: 200_000,
@ -88,6 +108,7 @@ func testEstimateRouteFee(ht *lntest.HarnessTest) {
probeInitiator = mts.alice
paula := ht.NewNode("Paula", nil)
frank := ht.NewNode("Frank", nil)
// The channel from Bob to Paula actually doesn't have enough liquidity
// to carry out the probe. We assume in normal operation that hop hints
@ -106,6 +127,13 @@ func testEstimateRouteFee(ht *lntest.HarnessTest) {
Amt: 1_000_000,
})
// Frank is a private node connected to Dave (public LSP).
ht.EnsureConnected(mts.dave, frank)
ht.OpenChannel(mts.dave, frank, lntest.OpenChannelParams{
Private: true,
Amt: 1_000_000,
})
bobsPrivChannels := mts.bob.RPC.ListChannels(&lnrpc.ListChannelsRequest{
PrivateOnly: true,
})
@ -118,6 +146,14 @@ func testEstimateRouteFee(ht *lntest.HarnessTest) {
require.Len(ht, evesPrivChannels.Channels, 1)
evePaulaChanID := evesPrivChannels.Channels[0].ChanId
davesPrivChannels := mts.dave.RPC.ListChannels(
&lnrpc.ListChannelsRequest{
PrivateOnly: true,
},
)
require.Len(ht, davesPrivChannels.Channels, 1)
daveFrankChanID := davesPrivChannels.Channels[0].ChanId
// Let's disable the paths from Alice to Bob through Dave and Eve with
// high fees. This ensures that the path estimates are based on Carol's
// channel to Bob for the first set of tests.
@ -196,6 +232,33 @@ func testEstimateRouteFee(ht *lntest.HarnessTest) {
},
},
}
daveHopHint = &lnrpc.HopHint{
NodeId: mts.dave.PubKeyStr,
FeeBaseMsat: 3_000,
FeeProportionalMillionths: 3_000,
CltvExpiryDelta: 120,
ChanId: daveFrankChanID,
}
// Multiple different public LSPs (Bob, Eve, Dave).
multipleLspsRouteHints = []*lnrpc.RouteHint{
{
HopHints: []*lnrpc.HopHint{
bobHopHint,
},
},
{
HopHints: []*lnrpc.HopHint{
eveHopHint,
},
},
{
HopHints: []*lnrpc.HopHint{
daveHopHint,
},
},
}
)
defaultTimelock := int64(chainreg.DefaultBitcoinTimeLockDelta)
@ -231,6 +294,14 @@ func testEstimateRouteFee(ht *lntest.HarnessTest) {
feeACEP := feeEP + feeCE
deltaACEP := deltaCE + deltaEP
// For multiple LSPs test, the route with the highest fee should be
// selected (Eve). Note that we return both fee and CLTV delta from
// the same route (the highest-fee route), not the max fee and max
// delta independently. This ensures the returned values represent an
// actual viable route.
highestFeeRouteFee := feeACEP
highestFeeRouteDelta := deltaACEP
initialBlockHeight := int64(mts.alice.RPC.GetInfo().BlockHeight)
// Locktime is always composed of the initial block height and the
@ -271,6 +342,19 @@ func testEstimateRouteFee(ht *lntest.HarnessTest) {
expectedCltvDelta: locktime + deltaCB,
expectedFailureReason: failureReasonNone,
},
// Rule 1: Invoice target is public (Bob), even with public
// destination hop hints. Should route directly to Bob, NOT
// treat as LSP.
{
name: "probe based estimate, public " +
"target with public hop hints",
probing: true,
destination: mts.bob,
routeHints: singleRouteHint,
expectedRoutingFeesMsat: feeStandardSingleHop,
expectedCltvDelta: locktime + deltaCB,
expectedFailureReason: failureReasonNone,
},
// We expect the previous probing results adjusted by Paula's
// hop data.
{
@ -340,6 +424,23 @@ func testEstimateRouteFee(ht *lntest.HarnessTest) {
expectedCltvDelta: 0,
expectedFailureReason: failureReasonNoRoute,
},
// Test multiple different public LSPs. The worst-case (most
// expensive) route should be returned. Eve has the highest
// fees among the 3 LSPs tested. Note: We don't test with more
// than MaxLspsToProbe LSPs because map iteration order in Go
// is non-deterministic, making it impossible to predict which
// LSPs will be selected for probing.
{
name: "probe based estimate, " +
"multiple different public LSPs",
probing: true,
destination: frank,
routeHints: multipleLspsRouteHints,
expectedRoutingFeesMsat: highestFeeRouteFee,
expectedCltvDelta: locktime +
highestFeeRouteDelta,
expectedFailureReason: failureReasonNone,
},
}
for _, testCase := range testCases {

View file

@ -57,12 +57,18 @@ func testEndorsement(ht *lntest.HarnessTest, aliceEndorse bool) {
FeeLimitMsat: math.MaxInt64,
}
expectedValue := []byte{lnwire.ExperimentalUnendorsed}
if aliceEndorse {
expectedValue = []byte{lnwire.ExperimentalEndorsed}
t := uint64(lnwire.ExperimentalEndorsementType)
sendReq.FirstHopCustomRecords = map[uint64][]byte{
t: expectedValue,
var expectedValue []byte
hasEndorsement := lntest.ExperimentalEndorsementActive()
if hasEndorsement {
if aliceEndorse {
expectedValue = []byte{lnwire.ExperimentalEndorsed}
t := uint64(lnwire.ExperimentalEndorsementType)
sendReq.FirstHopCustomRecords = map[uint64][]byte{
t: expectedValue,
}
} else {
expectedValue = []byte{lnwire.ExperimentalUnendorsed}
}
}
@ -70,8 +76,13 @@ func testEndorsement(ht *lntest.HarnessTest, aliceEndorse bool) {
// Validate that our signal (positive or zero) propagates until carol
// and then is dropped because she has disabled the feature.
validateEndorsedAndResume(ht, bobIntercept, true, expectedValue)
validateEndorsedAndResume(ht, carolIntercept, true, expectedValue)
// When the endorsement experiment is not active, no signal is sent.
validateEndorsedAndResume(
ht, bobIntercept, hasEndorsement, expectedValue,
)
validateEndorsedAndResume(
ht, carolIntercept, hasEndorsement, expectedValue,
)
validateEndorsedAndResume(ht, daveIntercept, false, nil)
var preimage lntypes.Preimage

View file

@ -434,14 +434,25 @@ func testForwardInterceptorRestart(ht *lntest.HarnessTest) {
// We should get another notification about the held HTLC.
packet = ht.ReceiveHtlcInterceptor(bobInterceptor)
require.Len(ht, packet.InWireCustomRecords, 2)
// Check the expected number of custom records based on whether the
// endorsement experiment is still active.
expectedLen := 1
if lntest.ExperimentalEndorsementActive() {
expectedLen = 2
}
require.Len(ht, packet.InWireCustomRecords, expectedLen)
require.Equal(ht, lntest.CustomRecordsWithUnendorsed(customRecords),
packet.InWireCustomRecords)
// And now we forward the payment at Carol, expecting only an
// endorsement signal in our incoming custom records.
// endorsement signal in our incoming custom records (if the experiment
// is still active).
packet = ht.ReceiveHtlcInterceptor(carolInterceptor)
require.Len(ht, packet.InWireCustomRecords, 1)
expectedCarolLen := 0
if lntest.ExperimentalEndorsementActive() {
expectedCarolLen = 1
}
require.Len(ht, packet.InWireCustomRecords, expectedCarolLen)
err = carolInterceptor.Send(&routerrpc.ForwardHtlcInterceptResponse{
IncomingCircuitKey: packet.IncomingCircuitKey,
Action: actionResume,
@ -494,6 +505,172 @@ func testForwardInterceptorRestart(ht *lntest.HarnessTest) {
)
}
// testForwardInterceptorOnChainSettleAfterRestart tests that an HTLC offered
// to the interceptor by the on-chain resolver remains settleable after a new
// block is mined. This reproduces the incident path where Bob restarted after
// the force-close, so only the on-chain interceptor entry exists.
func testForwardInterceptorOnChainSettleAfterRestart(ht *lntest.HarnessTest) {
const (
chanAmt = btcutil.Amount(300000)
invoiceAmt = int64(100000)
)
// Bob requires an interceptor so the forwarded HTLC remains held until
// the test explicitly resolves it.
p := lntest.OpenChannelParams{Amt: chanAmt}
cfgs := [][]string{nil, {"--requireinterceptor"}, nil}
chanPoints, nodes := ht.CreateSimpleNetwork(cfgs, p)
alice, bob, carol := nodes[0], nodes[1], nodes[2]
cpAB := chanPoints[0]
// Fund Bob so he can publish the on-chain HTLC success sweep once the
// interceptor supplies the preimage.
ht.FundCoins(btcutil.SatoshiPerBitcoin, bob)
interceptor, cancelInterceptor := bob.RPC.HtlcInterceptor()
addResp := carol.RPC.AddInvoice(&lnrpc.Invoice{
Value: invoiceAmt,
})
invoice := carol.RPC.LookupInvoice(addResp.RHash)
payHash, err := lntypes.MakeHash(invoice.RHash)
require.NoError(ht, err)
req := &routerrpc.SendPaymentRequest{
PaymentRequest: invoice.PaymentRequest,
FeeLimitMsat: noFeeLimitMsat,
}
ht.SendPaymentAssertInflight(alice, req)
_ = ht.ReceiveHtlcInterceptor(interceptor)
ht.AssertIncomingHTLCActive(bob, cpAB, invoice.RHash)
ht.AssertPaymentStatus(alice, payHash, lnrpc.Payment_IN_FLIGHT)
closeStream, _ := ht.CloseChannelAssertPending(
alice, cpAB, true,
)
ht.AssertStreamChannelForceClosed(
alice, cpAB, false, closeStream,
)
ht.AssertChannelPendingForceClose(bob, cpAB)
cancelInterceptor()
ht.RestartNode(bob)
// Re-register the interceptor after restart. The previous stream was
// cancelled before Bob went down. The incoming contest resolver only
// re-offers the on-chain HTLC to the active stream.
interceptor, cancelInterceptor = bob.RPC.HtlcInterceptor()
defer cancelInterceptor()
// After restart, the incoming contest resolver re-offers the HTLC to
// the interceptor through the on-chain path.
intercepted := ht.ReceiveHtlcInterceptor(interceptor)
// Mine one block after the on-chain intercept has been offered. With
// the current bug, the held entry is evicted here because the on-chain
// packet has no auto-fail height.
ht.MineEmptyBlocks(1)
ht.AssertNumTxsInMempool(0)
err = interceptor.Send(&routerrpc.ForwardHtlcInterceptResponse{
IncomingCircuitKey: intercepted.IncomingCircuitKey,
Action: routerrpc.ResolveHoldForwardAction_SETTLE,
Preimage: invoice.RPreimage,
})
require.NoError(ht, err, "failed to settle intercepted HTLC")
// The preimage should reach the contest resolver and register Bob's
// HTLC success input with the sweeper.
ht.AssertAtLeastNumPendingSweeps(bob, 1)
// Give the sweeper another blockbeat to publish the sweep transaction.
ht.MineEmptyBlocks(1)
ht.MineBlocksAndAssertNumTxes(1, 1)
ht.AssertPaymentStatus(alice, payHash, lnrpc.Payment_SUCCEEDED)
// Bob's sweep is mined above. Clean up Alice's force close so the next
// test starts with an empty mempool.
ht.CleanupForceClose(alice)
}
// testForwardInterceptorOnChainSettleNoRestart tests that an HTLC which was
// first held off-chain can still be settled after the incoming channel
// force-closes without restarting Bob. This covers the duplicate-entry path:
// the old off-chain held entry must not prevent settlement from reaching the
// on-chain contest resolver.
func testForwardInterceptorOnChainSettleNoRestart(ht *lntest.HarnessTest) {
const (
chanAmt = btcutil.Amount(300000)
invoiceAmt = int64(100000)
)
// Bob requires an interceptor so the forwarded HTLC remains held until
// the test explicitly resolves it.
p := lntest.OpenChannelParams{Amt: chanAmt}
cfgs := [][]string{nil, {"--requireinterceptor"}, nil}
chanPoints, nodes := ht.CreateSimpleNetwork(cfgs, p)
alice, bob, carol := nodes[0], nodes[1], nodes[2]
cpAB := chanPoints[0]
// Fund Bob so he can publish the on-chain HTLC success sweep once the
// interceptor supplies the preimage.
ht.FundCoins(btcutil.SatoshiPerBitcoin, bob)
interceptor, cancelInterceptor := bob.RPC.HtlcInterceptor()
defer cancelInterceptor()
addResp := carol.RPC.AddInvoice(&lnrpc.Invoice{
Value: invoiceAmt,
})
invoice := carol.RPC.LookupInvoice(addResp.RHash)
payHash, err := lntypes.MakeHash(invoice.RHash)
require.NoError(ht, err)
req := &routerrpc.SendPaymentRequest{
PaymentRequest: invoice.PaymentRequest,
FeeLimitMsat: noFeeLimitMsat,
}
ht.SendPaymentAssertInflight(alice, req)
intercepted := ht.ReceiveHtlcInterceptor(interceptor)
ht.AssertIncomingHTLCActive(bob, cpAB, invoice.RHash)
ht.AssertPaymentStatus(alice, payHash, lnrpc.Payment_IN_FLIGHT)
closeStream, _ := ht.CloseChannelAssertPending(
alice, cpAB, true,
)
ht.AssertStreamChannelForceClosed(
alice, cpAB, false, closeStream,
)
ht.AssertChannelPendingForceClose(bob, cpAB)
ht.AssertNumTxsInMempool(0)
err = interceptor.Send(&routerrpc.ForwardHtlcInterceptResponse{
IncomingCircuitKey: intercepted.IncomingCircuitKey,
Action: routerrpc.ResolveHoldForwardAction_SETTLE,
Preimage: invoice.RPreimage,
})
require.NoError(ht, err, "failed to settle intercepted HTLC")
// The preimage should reach the contest resolver and register Bob's
// HTLC success input with the sweeper.
ht.AssertAtLeastNumPendingSweeps(bob, 1)
// Give the sweeper another blockbeat to publish the sweep transaction.
ht.MineEmptyBlocks(1)
ht.MineBlocksAndAssertNumTxes(1, 1)
ht.AssertPaymentStatus(alice, payHash, lnrpc.Payment_SUCCEEDED)
// Bob's sweep is mined above. Clean up Alice's force close so the next
// test starts with an empty mempool.
ht.CleanupForceClose(alice)
}
// interceptorTestScenario is a helper struct to hold the test context and
// provide the needed functionality.
type interceptorTestScenario struct {

View file

@ -147,4 +147,4 @@ replace github.com/ulikunitz/xz => github.com/ulikunitz/xz v0.5.11
// https://deps.dev/advisory/OSV/GO-2021-0053?from=%2Fgo%2Fgithub.com%252Fgogo%252Fprotobuf%2Fv1.3.1
replace github.com/gogo/protobuf => github.com/gogo/protobuf v1.3.2
go 1.24.9
go 1.24.11

View file

@ -115,7 +115,15 @@ func DefaultDB() *DB {
},
Postgres: &sqldb.PostgresConfig{
MaxConnections: defaultPostgresMaxConnections,
QueryConfig: *sqldb.DefaultPostgresConfig(),
// Normally we don't use a global lock for channeldb
// access, but if a user encounters huge concurrency
// issues, they can enable this to use a global lock.
ChannelDBWithGlobalLock: false,
// Default to true to maintain safe single-writer
// behavior until the wallet subsystem is upgraded to
// a native sql schema.
WalletDBWithGlobalLock: true,
QueryConfig: *sqldb.DefaultPostgresConfig(),
},
Sqlite: &sqldb.SqliteConfig{
MaxConnections: defaultSqliteMaxConnections,
@ -400,9 +408,15 @@ func (db *DB) GetBackends(ctx context.Context, chanDBPath,
// users to native SQL.
postgresConfig := GetPostgresConfigKVDB(db.Postgres)
// Create a separate config for channeldb with the global lock
// setting if configured.
postgresConfigChannelDB := GetPostgresConfigKVDB(db.Postgres)
postgresConfigChannelDB.WithGlobalLock = db.Postgres.
ChannelDBWithGlobalLock
postgresBackend, err := kvdb.Open(
kvdb.PostgresBackendName, ctx,
postgresConfig, NSChannelDB,
postgresConfigChannelDB, NSChannelDB,
)
if err != nil {
return nil, fmt.Errorf("error opening postgres graph "+
@ -450,14 +464,11 @@ func (db *DB) GetBackends(ctx context.Context, chanDBPath,
}
closeFuncs[NSTowerServerDB] = postgresTowerServerBackend.Close
// The wallet subsystem is still not robust enough to run it
// without a single writer in postgres therefore we create a
// new config with the global lock enabled.
//
// NOTE: This is a temporary measure and should be removed as
// soon as the wallet code is more robust.
// Create a separate config for wallet with the global lock
// setting if configured.
postgresConfigWalletDB := GetPostgresConfigKVDB(db.Postgres)
postgresConfigWalletDB.WithGlobalLock = true
postgresConfigWalletDB.WithGlobalLock = db.Postgres.
WalletDBWithGlobalLock
postgresWalletBackend, err := kvdb.Open(
kvdb.PostgresBackendName, ctx,

View file

@ -1,6 +1,6 @@
# If you change this please also update GO_VERSION in Makefile (then run
# `make lint` to see where else it needs to be updated as well).
FROM golang:1.25.3-bookworm
FROM golang:1.25.5-bookworm
RUN apt-get update && apt-get install -y \
git \

View file

@ -6,7 +6,7 @@ set -e
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# golang docker image version used in this script.
GO_IMAGE=docker.io/library/golang:1.25.3-alpine
GO_IMAGE=docker.io/library/golang:1.25.5-alpine
PROTOBUF_VERSION=$(docker run --rm -v $DIR/../:/lnd -w /lnd $GO_IMAGE \
go list -f '{{.Version}}' -m google.golang.org/protobuf)

View file

@ -6,7 +6,6 @@ import (
"crypto/rand"
"errors"
"fmt"
"math"
mathRand "math/rand"
"sort"
"time"
@ -405,10 +404,12 @@ func AddInvoice(ctx context.Context, cfg *AddInvoiceConfig,
options = append(options, zpay32.Description(invoice.Memo))
}
if invoice.CltvExpiry > routing.MaxCLTVDelta {
// Final-hop invoices are limited to the same CLTV bound used by the
// link and contractcourt validation.
if invoice.CltvExpiry > invoices.MaxFinalCltvDelta {
return nil, nil, fmt.Errorf("CLTV delta of %v is too large, "+
"max accepted is: %v", invoice.CltvExpiry,
math.MaxUint16)
invoices.MaxFinalCltvDelta)
}
// We'll use our current default CLTV value unless one was specified as

View file

@ -9,6 +9,7 @@ import (
"github.com/btcsuite/btcd/wire"
"github.com/lightningnetwork/lnd/channeldb"
"github.com/lightningnetwork/lnd/graph/db/models"
"github.com/lightningnetwork/lnd/invoices"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/zpay32"
"github.com/stretchr/testify/mock"
@ -25,6 +26,24 @@ var (
pubkey = btcec.NewPublicKey(new(btcec.FieldVal).SetInt(4), pubKeyY)
)
// TestAddInvoiceRejectsCltvAboveMaxIncoming asserts that invoice creation
// rejects final CLTV deltas above the supported maximum.
func TestAddInvoiceRejectsCltvAboveMaxIncoming(t *testing.T) {
t.Parallel()
_, _, err := AddInvoice(
t.Context(), &AddInvoiceConfig{}, &AddInvoiceData{
CltvExpiry: invoices.MaxFinalCltvDelta + 1,
},
)
require.ErrorContains(
t, err, fmt.Sprintf(
"max accepted is: %v",
invoices.MaxFinalCltvDelta,
),
)
}
type hopHintsConfigMock struct {
t *testing.T
mock.Mock

View file

@ -96,7 +96,7 @@ func (r *forwardInterceptor) onIntercept(
IncomingExpiry: htlc.IncomingExpiry,
CustomRecords: htlc.InOnionCustomRecords,
OnionBlob: htlc.OnionBlob[:],
AutoFailHeight: htlc.AutoFailHeight,
AutoFailHeight: htlc.AutoFailHeight(),
InWireCustomRecords: htlc.InWireCustomRecords,
}

View file

@ -3073,6 +3073,10 @@ type ForwardHtlcInterceptRequest struct {
// The key of this forwarded htlc. It defines the incoming channel id and
// the index in this channel.
//
// Interceptor clients should handle requests for the same circuit key
// idempotently. Requests may be replayed after reconnect, and an htlc that was
// previously offered off-chain may be offered again after it moves on-chain.
IncomingCircuitKey *CircuitKey `protobuf:"bytes,1,opt,name=incoming_circuit_key,json=incomingCircuitKey,proto3" json:"incoming_circuit_key,omitempty"`
// The incoming htlc amount.
IncomingAmountMsat uint64 `protobuf:"varint,5,opt,name=incoming_amount_msat,json=incomingAmountMsat,proto3" json:"incoming_amount_msat,omitempty"`
@ -3095,7 +3099,8 @@ type ForwardHtlcInterceptRequest struct {
// The onion blob for the next hop
OnionBlob []byte `protobuf:"bytes,9,opt,name=onion_blob,json=onionBlob,proto3" json:"onion_blob,omitempty"`
// The block height at which this htlc will be auto-failed to prevent the
// channel from force-closing.
// channel from force-closing. For on-chain htlcs, this field is the
// settlement deadline instead and no automatic fail-back is attempted.
AutoFailHeight int32 `protobuf:"varint,10,opt,name=auto_fail_height,json=autoFailHeight,proto3" json:"auto_fail_height,omitempty"`
// The custom records of the peer's incoming p2p wire message.
InWireCustomRecords map[uint64][]byte `protobuf:"bytes,11,rep,name=in_wire_custom_records,json=inWireCustomRecords,proto3" json:"in_wire_custom_records,omitempty" protobuf_key:"varint,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"`
@ -3218,6 +3223,14 @@ func (x *ForwardHtlcInterceptRequest) GetInWireCustomRecords() map[uint64][]byte
// field modifications.
// - `Reject`: Fail the htlc backwards.
// - `Settle`: Settle this htlc with a given preimage.
//
// Once the incoming channel has force-closed and the HTLC is being resolved
// on-chain (see auto_fail_height), only `Settle` has any effect. The HTLC can no
// longer be resumed or failed back off-chain, so `Resume`, `ResumeModified`, and
// `Fail` return a stream-terminating error. The HTLC stays held until it is
// settled with a preimage, the on-chain resolver completes, or it expires
// on-chain. Clients should reconnect to receive any held HTLCs that remain
// unresolved.
type ForwardHtlcInterceptResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache

View file

@ -981,6 +981,10 @@ message ForwardHtlcInterceptRequest {
/*
The key of this forwarded htlc. It defines the incoming channel id and
the index in this channel.
Interceptor clients should handle requests for the same circuit key
idempotently. Requests may be replayed after reconnect, and an htlc that was
previously offered off-chain may be offered again after it moves on-chain.
*/
CircuitKey incoming_circuit_key = 1;
@ -1015,7 +1019,8 @@ message ForwardHtlcInterceptRequest {
bytes onion_blob = 9;
// The block height at which this htlc will be auto-failed to prevent the
// channel from force-closing.
// channel from force-closing. For on-chain htlcs, this field is the
// settlement deadline instead and no automatic fail-back is attempted.
int32 auto_fail_height = 10;
// The custom records of the peer's incoming p2p wire message.
@ -1030,6 +1035,14 @@ forward. The caller can choose either to:
field modifications.
- `Reject`: Fail the htlc backwards.
- `Settle`: Settle this htlc with a given preimage.
Once the incoming channel has force-closed and the HTLC is being resolved
on-chain (see auto_fail_height), only `Settle` has any effect. The HTLC can no
longer be resumed or failed back off-chain, so `Resume`, `ResumeModified`, and
`Fail` return a stream-terminating error. The HTLC stays held until it is
settled with a preimage, the on-chain resolver completes, or it expires
on-chain. Clients should reconnect to receive any held HTLCs that remain
unresolved.
*/
message ForwardHtlcInterceptResponse {
/**

View file

@ -78,7 +78,7 @@
"parameters": [
{
"name": "body",
"description": "*\nForwardHtlcInterceptResponse enables the caller to resolve a previously hold\nforward. The caller can choose either to:\n- `Resume`: Execute the default behavior (usually forward).\n- `ResumeModified`: Execute the default behavior (usually forward) with HTLC\nfield modifications.\n- `Reject`: Fail the htlc backwards.\n- `Settle`: Settle this htlc with a given preimage. (streaming inputs)",
"description": "*\nForwardHtlcInterceptResponse enables the caller to resolve a previously hold\nforward. The caller can choose either to:\n- `Resume`: Execute the default behavior (usually forward).\n- `ResumeModified`: Execute the default behavior (usually forward) with HTLC\nfield modifications.\n- `Reject`: Fail the htlc backwards.\n- `Settle`: Settle this htlc with a given preimage.\n\nOnce the incoming channel has force-closed and the HTLC is being resolved\non-chain (see auto_fail_height), only `Settle` has any effect. The HTLC can no\nlonger be resumed or failed back off-chain, so `Resume`, `ResumeModified`, and\n`Fail` return a stream-terminating error. The HTLC stays held until it is\nsettled with a preimage, the on-chain resolver completes, or it expires\non-chain. Clients should reconnect to receive any held HTLCs that remain\nunresolved. (streaming inputs)",
"in": "body",
"required": true,
"schema": {
@ -1481,7 +1481,7 @@
"properties": {
"incoming_circuit_key": {
"$ref": "#/definitions/routerrpcCircuitKey",
"description": "The key of this forwarded htlc. It defines the incoming channel id and\nthe index in this channel."
"description": "The key of this forwarded htlc. It defines the incoming channel id and\nthe index in this channel.\n\nInterceptor clients should handle requests for the same circuit key\nidempotently. Requests may be replayed after reconnect, and an htlc that was\npreviously offered off-chain may be offered again after it moves on-chain."
},
"incoming_amount_msat": {
"type": "string",
@ -1529,7 +1529,7 @@
"auto_fail_height": {
"type": "integer",
"format": "int32",
"description": "The block height at which this htlc will be auto-failed to prevent the\nchannel from force-closing."
"description": "The block height at which this htlc will be auto-failed to prevent the\nchannel from force-closing. For on-chain htlcs, this field is the\nsettlement deadline instead and no automatic fail-back is attempted."
},
"in_wire_custom_records": {
"type": "object",
@ -1585,7 +1585,7 @@
"description": "Any custom records that should be set on the p2p wire message message of\nthe resumed HTLC. This field is ignored if the action is not\nRESUME_MODIFIED.\n\nThis map will merge with the existing set of custom records (if any),\nreplacing any conflicting types. Note that there currently is no support\nfor deleting existing custom records (they can only be replaced)."
}
},
"description": "*\nForwardHtlcInterceptResponse enables the caller to resolve a previously hold\nforward. The caller can choose either to:\n- `Resume`: Execute the default behavior (usually forward).\n- `ResumeModified`: Execute the default behavior (usually forward) with HTLC\nfield modifications.\n- `Reject`: Fail the htlc backwards.\n- `Settle`: Settle this htlc with a given preimage."
"description": "*\nForwardHtlcInterceptResponse enables the caller to resolve a previously hold\nforward. The caller can choose either to:\n- `Resume`: Execute the default behavior (usually forward).\n- `ResumeModified`: Execute the default behavior (usually forward) with HTLC\nfield modifications.\n- `Reject`: Fail the htlc backwards.\n- `Settle`: Settle this htlc with a given preimage.\n\nOnce the incoming channel has force-closed and the HTLC is being resolved\non-chain (see auto_fail_height), only `Settle` has any effect. The HTLC can no\nlonger be resumed or failed back off-chain, so `Resume`, `ResumeModified`, and\n`Fail` return a stream-terminating error. The HTLC stays held until it is\nsettled with a preimage, the on-chain resolver completes, or it expires\non-chain. Clients should reconnect to receive any held HTLCs that remain\nunresolved."
},
"routerrpcGetMissionControlConfigResponse": {
"type": "object",

View file

@ -63,6 +63,11 @@ type RouterBackend struct {
FetchChannelEndpoints func(chanID uint64) (route.Vertex,
route.Vertex, error)
// HasNode returns true if the node exists in the graph (i.e., has
// public channels), false otherwise. This means the node is a public
// node and should be reachable.
HasNode func(nodePub route.Vertex) (bool, error)
// FindRoute is a closure that abstracts away how we locate/query for
// routes.
FindRoute func(*routing.RouteRequest) (*route.Route, float64, error)

View file

@ -1,7 +1,6 @@
package routerrpc
import (
"bytes"
"context"
crand "crypto/rand"
"errors"
@ -44,6 +43,12 @@ const (
// DefaultPaymentTimeout is the default value of time we should spend
// when attempting to fulfill the payment.
DefaultPaymentTimeout int32 = 60
// MaxLspsToProbe is the maximum number of LSPs to probe when
// estimating fees for worst-case fee estimation. This is a
// precautionary measure to prevent the estimation from taking too
// long, and it is also a griefing protection.
MaxLspsToProbe = 3
)
var (
@ -171,10 +176,9 @@ var (
DefaultRouterMacFilename = "router.macaroon"
)
// FetchChannelEndpoints returns the pubkeys of both endpoints of the
// given channel id if it exists in the graph.
type FetchChannelEndpoints func(chanID uint64) (route.Vertex, route.Vertex,
error)
// HasNode returns true if the node exists in the graph (i.e., has public
// channels), false otherwise.
type HasNode func(nodePub route.Vertex) (bool, error)
// ServerShell is a shell struct holding a reference to the actual sub-server.
// It is used to register the gRPC sub-server with the root server before we
@ -553,6 +557,7 @@ func (s *Server) probePaymentRequest(ctx context.Context, paymentRequest string,
// If the payment addresses is specified, then we'll also populate that
// now as well.
payReq.PaymentAddr.WhenSome(func(addr [32]byte) {
probeRequest.PaymentAddr = make([]byte, lntypes.HashSize)
copy(probeRequest.PaymentAddr, addr[:])
})
@ -561,7 +566,8 @@ func (s *Server) probePaymentRequest(ctx context.Context, paymentRequest string,
// If the hints don't indicate an LSP then chances are that our probe
// payment won't be blocked along the route to the destination. We send
// a probe payment with unmodified route hints.
if !isLSP(hints, s.cfg.RouterBackend.FetchChannelEndpoints) {
invoiceTargetCompressed := payReq.Destination.SerializeCompressed()
if !isLSP(hints, invoiceTargetCompressed, s.cfg.RouterBackend.HasNode) {
log.Infof("No LSP detected, probing destination %x",
probeRequest.Dest)
@ -569,200 +575,346 @@ func (s *Server) probePaymentRequest(ctx context.Context, paymentRequest string,
return s.sendProbePayment(ctx, probeRequest)
}
// If the heuristic indicates an LSP we modify the route hints to allow
// probing the LSP.
lspAdjustedRouteHints, lspHint, err := prepareLspRouteHints(
hints, *payReq.MilliSat,
// If the heuristic indicates an LSP, we filter and group route hints by
// public LSP nodes, then probe each unique LSP separately and return
// the cheapest route.
lspGroups, err := prepareLspRouteHints(
hints, *payReq.MilliSat, s.cfg.RouterBackend.HasNode,
)
if err != nil {
return nil, err
}
// Set the destination to the LSP node ID.
lspDest := lspHint.NodeID.SerializeCompressed()
probeRequest.Dest = lspDest
log.Infof("LSP detected, found %d unique public LSP node(s) to probe",
len(lspGroups))
log.Infof("LSP detected, probing LSP with destination: %x", lspDest)
// The adjusted route hints serve the payment probe to find the last
// public hop to the LSP on the route.
if len(lspAdjustedRouteHints) > 0 {
probeRequest.RouteHints = invoicesrpc.CreateRPCRouteHints(
lspAdjustedRouteHints,
)
// Probe up to MaxLspsToProbe LSPs and track the most expensive route
// for worst-case fee estimation.
if len(lspGroups) > MaxLspsToProbe {
log.Debugf("Limiting LSP probes from %d to %d for worst-case "+
"fee estimation", len(lspGroups), MaxLspsToProbe)
}
var (
worstCaseResp *RouteFeeResponse
worstCaseLspDest route.Vertex
probeCount int
)
// The payment probe will be able to calculate the fee up until the LSP
// node. The fee of the last hop has to be calculated manually. Since
// the last hop's fee amount has to be sent across the payment path we
// have to add it to the original payment amount. Only then will the
// payment probe be able to determine the correct fee to the last hop
// prior to the private destination. For example, if the user wants to
// send 1000 sats to a private destination and the last hop's fee is 10
// sats, then 1010 sats will have to arrive at the last hop. This means
// that the probe has to be dispatched with 1010 sats to correctly
// calculate the routing fee.
//
// Calculate the hop fee for the last hop manually.
hopFee := lspHint.HopFee(*payReq.MilliSat)
if err != nil {
return nil, err
}
for lspKey, group := range lspGroups {
if probeCount >= MaxLspsToProbe {
break
}
probeCount++
// Add the last hop's fee to the requested payment amount that we want
// to get an estimate for.
probeRequest.AmtMsat += int64(hopFee)
lspHint := group.LspHopHint
// Use the hop hint's cltv delta as the payment request's final cltv
// delta. The actual final cltv delta of the invoice will be added to
// the payment probe's cltv delta.
probeRequest.FinalCltvDelta = int32(lspHint.CLTVExpiryDelta)
log.Infof("Probing LSP with destination: %v", lspKey)
// Dispatch the payment probe with adjusted fee amount.
resp, err := s.sendProbePayment(ctx, probeRequest)
if err != nil {
return nil, fmt.Errorf("failed to send probe payment to "+
"LSP with destination %x: %w", lspDest, err)
}
// Create a new probe request for this LSP.
lspProbeRequest := &SendPaymentRequest{
TimeoutSeconds: probeRequest.TimeoutSeconds,
Dest: lspKey[:],
MaxParts: probeRequest.MaxParts,
AllowSelfPayment: probeRequest.AllowSelfPayment,
AmtMsat: amtMsat,
PaymentHash: probeRequest.PaymentHash,
FeeLimitSat: probeRequest.FeeLimitSat,
FinalCltvDelta: int32(lspHint.CLTVExpiryDelta),
DestFeatures: probeRequest.DestFeatures,
}
// If the payment probe failed we only return the failure reason and
// leave the probe result params unaltered.
if resp.FailureReason != lnrpc.PaymentFailureReason_FAILURE_REASON_NONE { //nolint:ll
return resp, nil
}
// Copy the payment address if present.
if len(probeRequest.PaymentAddr) > 0 {
lspProbeRequest.PaymentAddr = make(
[]byte, lntypes.HashSize,
)
// The probe succeeded, so we can add the last hop's fee to fee the
// payment probe returned.
resp.RoutingFeeMsat += int64(hopFee)
copy(
lspProbeRequest.PaymentAddr,
probeRequest.PaymentAddr,
)
}
// Add the final cltv delta of the invoice to the payment probe's total
// cltv delta. This is the cltv delta for the hop behind the LSP.
resp.TimeLockDelay += int64(payReq.MinFinalCLTVExpiry())
// Set the adjusted route hints for this LSP.
if len(group.AdjustedRouteHints) > 0 {
lspProbeRequest.RouteHints = invoicesrpc.
CreateRPCRouteHints(group.AdjustedRouteHints)
}
return resp, nil
}
// Calculate the hop fee for the last hop manually.
hopFee := lspHint.HopFee(*payReq.MilliSat)
// isLSP checks if the route hints indicate an LSP. An LSP is indicated with
// true if the destination hop hint in each route hint has the same node id,
// false otherwise. If the destination hop hint of any route hint contains a
// public channel, the function returns false because we can directly send a
// probe to the final destination.
func isLSP(routeHints [][]zpay32.HopHint,
fetchChannelEndpoints FetchChannelEndpoints) bool {
// Add the last hop's fee to the probe amount.
lspProbeRequest.AmtMsat += int64(hopFee)
if len(routeHints) == 0 || len(routeHints[0]) == 0 {
return false
}
destHopHint := routeHints[0][len(routeHints[0])-1]
// If the destination hop hint of the first route hint contains a public
// channel we can send a probe to it directly, hence we don't signal an
// LSP.
_, _, err := fetchChannelEndpoints(destHopHint.ChannelID)
if err == nil {
return false
}
for i := 1; i < len(routeHints); i++ {
// Skip empty route hints.
if len(routeHints[i]) == 0 {
// Dispatch the payment probe for this LSP.
resp, err := s.sendProbePayment(ctx, lspProbeRequest)
if err != nil {
log.Warnf("Failed to probe LSP %v: %v", lspKey, err)
continue
}
lastHop := routeHints[i][len(routeHints[i])-1]
// If the probe failed, skip this LSP.
if resp.FailureReason !=
lnrpc.PaymentFailureReason_FAILURE_REASON_NONE {
// If the last hop hint of any route hint contains a public
// channel we can send a probe to it directly, hence we don't
// signal an LSP.
_, _, err = fetchChannelEndpoints(lastHop.ChannelID)
if err == nil {
return false
log.Debugf("Probe to LSP %v failed with reason: %v",
lspKey, resp.FailureReason)
continue
}
matchesDestNode := bytes.Equal(
lastHop.NodeID.SerializeCompressed(),
destHopHint.NodeID.SerializeCompressed(),
)
if !matchesDestNode {
// The probe succeeded, add the last hop's fee.
resp.RoutingFeeMsat += int64(hopFee)
// Add the final cltv delta of the invoice.
resp.TimeLockDelay += int64(payReq.MinFinalCLTVExpiry())
log.Infof("Probe to LSP %v succeeded with fee: %d msat",
lspKey, resp.RoutingFeeMsat)
// Track the most expensive route for worst-case estimation.
// We solely consider the routing fee for the worst-case
// estimation.
if worstCaseResp == nil ||
resp.RoutingFeeMsat > worstCaseResp.RoutingFeeMsat {
if worstCaseResp != nil {
log.Debugf("LSP %v has higher fee "+
"(%d msat) than current worst-case "+
"%v (%d msat), updating worst-case "+
"estimate", lspKey,
resp.RoutingFeeMsat, worstCaseLspDest,
worstCaseResp.RoutingFeeMsat)
}
worstCaseResp = resp
worstCaseLspDest = lspKey
} else {
log.Debugf("LSP %v fee (%d msat) is lower than "+
"current worst-case %v (%d msat), keeping "+
"worst-case estimate", lspKey,
resp.RoutingFeeMsat, worstCaseLspDest,
worstCaseResp.RoutingFeeMsat)
}
}
// If no LSP probe succeeded, return an error.
if worstCaseResp == nil {
return nil, fmt.Errorf("all LSP probe payments failed")
}
log.Infof("Returning worst-case route via LSP %v with fee: %d msat, "+
"timelock: %d", worstCaseLspDest, worstCaseResp.RoutingFeeMsat,
worstCaseResp.TimeLockDelay)
return worstCaseResp, nil
}
// isLSP checks if the route hints indicate an LSP setup. An LSP setup is
// identified when the invoice destination is private but the final hop in the
// route hints is a public node (the LSP). This function implements three rules:
//
// 1. If the invoice target is a public node (exists in graph) => isLsp = false
// We can route directly to the target, so no LSP is involved.
//
// 2. If at least one destination hop hint (last hop in route hint) is public
// => isLsp = true. The public destination hop is the LSP, and the actual
// invoice target is a private node behind it.
//
// 3. If all destination hop hints are private nodes => isLsp = false.
// We assume this is NOT an LSP setup. Instead, we expect the route hints
// contain public nodes earlier in the path (not the final hop) that our
// pathfinder can route to. For example:
// The pathfinder will route to PublicNode and use the hints from there.
// Note: If no public nodes exist anywhere in the route hints, the
// destination would be unreachable (malformed invoice), but we don't
// validate that here.
func isLSP(routeHints [][]zpay32.HopHint, invoiceTarget []byte,
hasNode HasNode) bool {
if len(routeHints) == 0 || len(routeHints[0]) == 0 {
log.Debugf("No route hints provided, this is not an LSP setup")
return false
}
// Rule 1: If the invoice target is a public node (exists in the graph),
// we can route directly to it, so it's not an LSP setup.
if len(invoiceTarget) > 0 {
var targetVertex route.Vertex
copy(targetVertex[:], invoiceTarget)
isPublic, err := hasNode(targetVertex)
if err != nil {
log.Warnf("Failed to check if invoice target %x is "+
"public: %v", invoiceTarget, err)
return false
}
if isPublic {
log.Infof("Invoice target %x is a public node in the "+
"graph, this is NOT an LSP setup",
invoiceTarget)
return false
}
}
// We ensured that the destination hop hint doesn't contain a public
// channel, and that all destination hop hints of all route hints match,
// so we signal an LSP.
return true
for _, hopHints := range routeHints {
// Skip empty route hints.
if len(hopHints) == 0 {
continue
}
lastHop := hopHints[len(hopHints)-1]
lastHopNodeCompressed := lastHop.NodeID.SerializeCompressed()
// Check if this destination hop hint node is public.
// Rule 2: If we find a public node, we can exit early.
var lastHopVertex route.Vertex
copy(lastHopVertex[:], lastHopNodeCompressed)
isPublic, err := hasNode(lastHopVertex)
if err != nil {
log.Warnf("Failed to check if destination hop "+
"hint %x is public: %v", lastHopNodeCompressed,
err)
continue
}
if isPublic {
log.Infof("Destination hop hint %x is a public node, "+
"this is an LSP setup", lastHopNodeCompressed)
return true
}
}
// Rule 3: If all destination hop hints are private nodes (not in the
// graph), this is NOT an LSP setup. We assume the route hints contain
// public nodes earlier in the path that we can route through using
// standard pathfinding with the hints.
log.Infof("All destination hop hints are private, this is NOT an " +
"LSP setup")
return false
}
// LspRouteGroup represents a group of route hints that share the same public
// LSP destination node. This is needed when probing LSPs separately to find
// the cheapest route.
type LspRouteGroup struct {
// LspHopHint is the hop hint for the LSP node with worst-case fees and
// CLTV delta.
LspHopHint *zpay32.HopHint
// AdjustedRouteHints are the route hints with the LSP hop stripped off.
AdjustedRouteHints [][]zpay32.HopHint
}
// prepareLspRouteHints assumes that the isLsp heuristic returned true for the
// route hints passed in here. It constructs a modified list of route hints that
// allows the caller to probe the LSP, which itself is returned as a separate
// hop hint.
// route hints passed in here. It filters route hints to only include those with
// public destination nodes, groups them by unique LSP node, and returns a map
// of LSP groups keyed by the LSP node's compressed public key.
func prepareLspRouteHints(routeHints [][]zpay32.HopHint,
amt lnwire.MilliSatoshi) ([][]zpay32.HopHint, *zpay32.HopHint, error) {
amt lnwire.MilliSatoshi,
hasNode HasNode) (map[route.Vertex]*LspRouteGroup, error) {
// This should never happen, but we check for it for completeness.
// Because the isLSP heuristic already checked that the route hints are
// not empty.
if len(routeHints) == 0 {
return nil, nil, fmt.Errorf("no route hints provided")
return nil, fmt.Errorf("no route hints provided")
}
// Create the LSP hop hint. We are probing for the worst case fee and
// cltv delta. So we look for the max values amongst all LSP hop hints.
refHint := routeHints[0][len(routeHints[0])-1]
refHint.CLTVExpiryDelta = maxLspCltvDelta(routeHints)
refHint.FeeBaseMSat, refHint.FeeProportionalMillionths = maxLspFee(
routeHints, amt,
)
// Map to group route hints by LSP node pubkey.
lspGroups := make(map[route.Vertex]*LspRouteGroup)
// We construct a modified list of route hints that allows the caller to
// probe the LSP.
adjustedHints := make([][]zpay32.HopHint, 0, len(routeHints))
for _, routeHint := range routeHints {
// Skip empty route hints.
if len(routeHint) == 0 {
continue
}
// Strip off the LSP hop hint from all route hints.
for i := 0; i < len(routeHints); i++ {
hint := routeHints[i]
if len(hint) > 1 {
adjustedHints = append(
adjustedHints, hint[:len(hint)-1],
// Get the destination hop hint (last hop in the route).
destHop := routeHint[len(routeHint)-1]
destNodeCompressed := destHop.NodeID.SerializeCompressed()
// Check if this destination node is public.
var destVertex route.Vertex
copy(destVertex[:], destNodeCompressed)
isPublic, err := hasNode(destVertex)
if err != nil {
log.Warnf("Failed to check if dest hop hint %x is "+
"public: %v", destNodeCompressed, err)
continue
}
// Skip private destination nodes - we only probe public LSPs.
if !isPublic {
log.Debugf("Skipping route hint with private dest "+
"node %x", destNodeCompressed)
continue
}
// Use the compressed pubkey as the map key.
var lspKey route.Vertex
copy(lspKey[:], destNodeCompressed)
// Get or create the LSP group for this node.
group, exists := lspGroups[lspKey]
if !exists {
//nolint:ll
lspHop := zpay32.HopHint{
NodeID: destHop.NodeID,
ChannelID: destHop.ChannelID,
FeeBaseMSat: destHop.FeeBaseMSat,
FeeProportionalMillionths: destHop.FeeProportionalMillionths,
CLTVExpiryDelta: destHop.CLTVExpiryDelta,
}
group = &LspRouteGroup{
LspHopHint: &lspHop,
AdjustedRouteHints: make([][]zpay32.HopHint, 0),
}
lspGroups[lspKey] = group
}
// Update the LSP hop hint with worst-case (max) fees and CLTV.
hopFee := destHop.HopFee(amt)
currentMaxFee := group.LspHopHint.HopFee(amt)
if hopFee > currentMaxFee {
group.LspHopHint.FeeBaseMSat = destHop.FeeBaseMSat
group.LspHopHint.FeeProportionalMillionths = destHop.
FeeProportionalMillionths
}
if destHop.CLTVExpiryDelta > group.LspHopHint.CLTVExpiryDelta {
group.LspHopHint.CLTVExpiryDelta = destHop.
CLTVExpiryDelta
}
// Add the route hint with the LSP hop stripped off (if there
// are hops before the LSP).
if len(routeHint) > 1 {
group.AdjustedRouteHints = append(
group.AdjustedRouteHints,
routeHint[:len(routeHint)-1],
)
}
}
return adjustedHints, &refHint, nil
}
// maxLspFee returns base fee and fee rate amongst all LSP route hints that
// results in the overall highest fee for the given amount.
func maxLspFee(routeHints [][]zpay32.HopHint, amt lnwire.MilliSatoshi) (uint32,
uint32) {
var maxFeePpm uint32
var maxBaseFee uint32
var maxTotalFee lnwire.MilliSatoshi
for _, rh := range routeHints {
lastHop := rh[len(rh)-1]
lastHopFee := lastHop.HopFee(amt)
if lastHopFee > maxTotalFee {
maxTotalFee = lastHopFee
maxBaseFee = lastHop.FeeBaseMSat
maxFeePpm = lastHop.FeeProportionalMillionths
}
if len(lspGroups) == 0 {
return nil, fmt.Errorf("no public LSP nodes found in " +
"route hints")
}
return maxBaseFee, maxFeePpm
}
log.Infof("Found %d unique public LSP node(s) in route hints",
len(lspGroups))
// maxLspCltvDelta returns the maximum cltv delta amongst all LSP route hints.
func maxLspCltvDelta(routeHints [][]zpay32.HopHint) uint16 {
var maxCltvDelta uint16
for _, rh := range routeHints {
rhLastHop := rh[len(rh)-1]
if rhLastHop.CLTVExpiryDelta > maxCltvDelta {
maxCltvDelta = rhLastHop.CLTVExpiryDelta
}
}
return maxCltvDelta
return lspGroups, nil
}
// probePaymentStream is a custom implementation of the grpc.ServerStream

View file

@ -1,12 +1,12 @@
package routerrpc
import (
"bytes"
"context"
"testing"
"time"
"github.com/btcsuite/btcd/btcec/v2"
graphdb "github.com/lightningnetwork/lnd/graph/db"
"github.com/lightningnetwork/lnd/lnrpc"
"github.com/lightningnetwork/lnd/lnwire"
paymentsdb "github.com/lightningnetwork/lnd/payments/db"
@ -220,12 +220,18 @@ func TestTrackPaymentsNoInflightUpdates(t *testing.T) {
require.Equal(t, lnrpc.Payment_SUCCEEDED, payment.Status)
}
// TestIsLsp tests the isLSP heuristic. Combinations of different route hints
// with different fees and cltv deltas are tested to ensure that the heuristic
// correctly identifies whether a route leads to an LSP or not.
// TestIsLsp tests the isLSP heuristic. It validates all three LSP detection
// rules:
// Rule 1: Invoice target is public => not LSP.
// Rule 2: All destination hop hints are private => not LSP (Boltz case).
// Rule 3: At least one destination hop hint is public => LSP (Muun case).
func TestIsLsp(t *testing.T) {
probeAmtMsat := lnwire.MilliSatoshi(1_000_000)
// Setup test nodes:
// - Alice: public node (in graph)
// - Bob: private node
// - Carol: private node
// - Dave: public node (in graph)
// - Eve: private node
alicePrivKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
alicePubKey := alicePrivKey.PubKey()
@ -242,216 +248,519 @@ func TestIsLsp(t *testing.T) {
require.NoError(t, err)
davePubKey := davePrivKey.PubKey()
var (
aliceHopHint = zpay32.HopHint{
NodeID: alicePubKey,
FeeBaseMSat: 100,
FeeProportionalMillionths: 1_000,
ChannelID: 421337,
}
evePrivKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
evePubKey := evePrivKey.PubKey()
bobHopHint = zpay32.HopHint{
NodeID: bobPubKey,
FeeBaseMSat: 2_000,
FeeProportionalMillionths: 2_000,
CLTVExpiryDelta: 288,
ChannelID: 815,
}
// Create hop hints for each node.
aliceHopHint := zpay32.HopHint{
NodeID: alicePubKey,
FeeBaseMSat: 100,
FeeProportionalMillionths: 1_000,
CLTVExpiryDelta: 40,
ChannelID: 1,
}
carolHopHint = zpay32.HopHint{
NodeID: carolPubKey,
FeeBaseMSat: 2_000,
FeeProportionalMillionths: 2_000,
ChannelID: 815,
}
bobHopHint := zpay32.HopHint{
NodeID: bobPubKey,
FeeBaseMSat: 2_000,
FeeProportionalMillionths: 2_000,
CLTVExpiryDelta: 144,
ChannelID: 2,
}
daveHopHint = zpay32.HopHint{
NodeID: davePubKey,
FeeBaseMSat: 2_000,
FeeProportionalMillionths: 2_000,
ChannelID: 815,
}
carolHopHint := zpay32.HopHint{
NodeID: carolPubKey,
FeeBaseMSat: 1_500,
FeeProportionalMillionths: 1_500,
CLTVExpiryDelta: 144,
ChannelID: 3,
}
publicChannelID = uint64(42)
daveHopHintPublicChan = zpay32.HopHint{
NodeID: davePubKey,
FeeBaseMSat: 2_000,
FeeProportionalMillionths: 2_000,
ChannelID: publicChannelID,
}
)
daveHopHint := zpay32.HopHint{
NodeID: davePubKey,
FeeBaseMSat: 3_000,
FeeProportionalMillionths: 3_000,
CLTVExpiryDelta: 288,
ChannelID: 4,
}
bobExpensiveCopy := bobHopHint.Copy()
bobExpensiveCopy.FeeBaseMSat = 1_000_000
bobExpensiveCopy.FeeProportionalMillionths = 1_000_000
bobExpensiveCopy.CLTVExpiryDelta = bobHopHint.CLTVExpiryDelta - 1
eveHopHint := zpay32.HopHint{
NodeID: evePubKey,
FeeBaseMSat: 500,
FeeProportionalMillionths: 500,
CLTVExpiryDelta: 40,
ChannelID: 5,
}
//nolint:ll
lspTestCases := []struct {
name string
routeHints [][]zpay32.HopHint
probeAmtMsat lnwire.MilliSatoshi
isLsp bool
expectedHints [][]zpay32.HopHint
expectedLspHop *zpay32.HopHint
// Mock hasNode: returns true only for alice and dave.
hasNode := func(nodePub route.Vertex) (bool, error) {
aliceVertex := route.NewVertex(alicePubKey)
daveVertex := route.NewVertex(davePubKey)
return bytes.Equal(nodePub[:], aliceVertex[:]) ||
bytes.Equal(nodePub[:], daveVertex[:]), nil
}
tests := []struct {
name string
routeHints [][]zpay32.HopHint
invoiceTarget []byte
expectLSP bool
}{
// Edge cases.
{
name: "empty route hints",
routeHints: [][]zpay32.HopHint{{}},
probeAmtMsat: probeAmtMsat,
isLsp: false,
expectedHints: [][]zpay32.HopHint{},
expectedLspHop: nil,
name: "no route hints",
routeHints: [][]zpay32.HopHint{},
invoiceTarget: nil,
expectLSP: false,
},
{
name: "single route hint",
routeHints: [][]zpay32.HopHint{{daveHopHint}},
probeAmtMsat: probeAmtMsat,
isLsp: true,
expectedHints: [][]zpay32.HopHint{},
expectedLspHop: &daveHopHint,
name: "empty route hint array",
routeHints: [][]zpay32.HopHint{{}},
invoiceTarget: nil,
expectLSP: false,
},
// Rule 1: Invoice target is public => NOT an LSP.
// Rationale: Can route directly to public target.
{
name: "single route, multiple hints",
routeHints: [][]zpay32.HopHint{{
aliceHopHint, bobHopHint,
}},
probeAmtMsat: probeAmtMsat,
isLsp: true,
expectedHints: [][]zpay32.HopHint{{aliceHopHint}},
expectedLspHop: &bobHopHint,
},
{
name: "multiple routes, multiple hints",
name: "invoice target is public (alice)",
routeHints: [][]zpay32.HopHint{
{
aliceHopHint, bobHopHint,
},
{
carolHopHint, bobHopHint,
},
{bobHopHint, carolHopHint},
},
probeAmtMsat: probeAmtMsat,
isLsp: true,
expectedHints: [][]zpay32.HopHint{
{aliceHopHint}, {carolHopHint},
},
expectedLspHop: &bobHopHint,
invoiceTarget: alicePubKey.SerializeCompressed(),
expectLSP: false,
},
{
name: "multiple routes, multiple hints with min length",
name: "invoice target is public with public dest hop",
routeHints: [][]zpay32.HopHint{
{
bobHopHint,
},
{
carolHopHint, bobHopHint,
},
{bobHopHint, daveHopHint},
},
probeAmtMsat: probeAmtMsat,
isLsp: true,
expectedHints: [][]zpay32.HopHint{
{carolHopHint},
},
expectedLspHop: &bobHopHint,
invoiceTarget: davePubKey.SerializeCompressed(),
expectLSP: false,
},
{
name: "multiple routes, multiple hints, diff fees+cltv",
name: "invoice target is public with multiple routes",
routeHints: [][]zpay32.HopHint{
{
bobHopHint,
},
{
carolHopHint, bobExpensiveCopy,
},
{bobHopHint, carolHopHint},
{aliceHopHint, daveHopHint},
},
probeAmtMsat: probeAmtMsat,
isLsp: true,
expectedHints: [][]zpay32.HopHint{
{carolHopHint},
},
expectedLspHop: &zpay32.HopHint{
NodeID: bobHopHint.NodeID,
ChannelID: bobHopHint.ChannelID,
FeeBaseMSat: bobExpensiveCopy.FeeBaseMSat,
FeeProportionalMillionths: bobExpensiveCopy.FeeProportionalMillionths,
CLTVExpiryDelta: bobHopHint.CLTVExpiryDelta,
invoiceTarget: alicePubKey.SerializeCompressed(),
expectLSP: false,
},
// Rule 2: All destination hop hints are private => NOT an LSP.
// Rationale: The destination hop hint is private so it cannot
// be probed so we default to NOT an LSP.
{
name: "single route to private dest",
routeHints: [][]zpay32.HopHint{
{aliceHopHint, bobHopHint},
},
invoiceTarget: bobPubKey.SerializeCompressed(),
expectLSP: false,
},
{
name: "multiple routes, different final hops",
name: "multiple routes, all to private dests",
routeHints: [][]zpay32.HopHint{
{
aliceHopHint, bobHopHint,
},
{
carolHopHint, daveHopHint,
},
{aliceHopHint, bobHopHint},
{daveHopHint, carolHopHint},
},
probeAmtMsat: probeAmtMsat,
isLsp: false,
expectedHints: [][]zpay32.HopHint{},
expectedLspHop: nil,
invoiceTarget: nil,
expectLSP: false,
},
{
name: "multiple routes, same public hops",
name: "single hop to private node",
routeHints: [][]zpay32.HopHint{
{
aliceHopHint, daveHopHintPublicChan,
},
{
carolHopHint, daveHopHintPublicChan,
},
{eveHopHint},
},
probeAmtMsat: probeAmtMsat,
isLsp: false,
expectedHints: [][]zpay32.HopHint{},
expectedLspHop: nil,
invoiceTarget: evePubKey.SerializeCompressed(),
expectLSP: false,
},
{
name: "multiple routes, same public hops",
name: "all routes to same private node",
routeHints: [][]zpay32.HopHint{
{
aliceHopHint, daveHopHint,
},
{
carolHopHint, daveHopHintPublicChan,
},
{
aliceHopHint, daveHopHintPublicChan,
},
{aliceHopHint, bobHopHint},
{daveHopHint, bobHopHint},
{carolHopHint, bobHopHint},
},
probeAmtMsat: probeAmtMsat,
isLsp: false,
expectedHints: [][]zpay32.HopHint{},
expectedLspHop: nil,
invoiceTarget: nil,
expectLSP: false,
},
// Rule 3: At least one destination hop is public => IS an LSP.
// Rationale: As long as there is at least one public
// destination route hint, it is an LSP setup and can be probed.
{
name: "single route to public dest (dave)",
routeHints: [][]zpay32.HopHint{
{bobHopHint, daveHopHint},
},
invoiceTarget: evePubKey.SerializeCompressed(),
expectLSP: true,
},
{
name: "direct hop to public LSP (alice)",
routeHints: [][]zpay32.HopHint{
{aliceHopHint},
},
invoiceTarget: bobPubKey.SerializeCompressed(),
expectLSP: true,
},
{
name: "multiple routes to same public LSP (dave)",
routeHints: [][]zpay32.HopHint{
{bobHopHint, daveHopHint},
{carolHopHint, daveHopHint},
{eveHopHint, daveHopHint},
},
invoiceTarget: nil,
expectLSP: true,
},
{
name: "multiple routes to different public LSPs",
routeHints: [][]zpay32.HopHint{
{bobHopHint, aliceHopHint},
{carolHopHint, daveHopHint},
},
invoiceTarget: nil,
expectLSP: true,
},
{
name: "mixed public and private dest hops",
routeHints: [][]zpay32.HopHint{
{aliceHopHint, bobHopHint},
{carolHopHint, daveHopHint},
{bobHopHint, eveHopHint},
},
invoiceTarget: nil,
expectLSP: true,
},
{
name: "first route has public dest, rest private",
routeHints: [][]zpay32.HopHint{
{bobHopHint, aliceHopHint},
{carolHopHint, eveHopHint},
},
invoiceTarget: nil,
expectLSP: true,
},
}
// Returns ErrEdgeNotFound for private channels.
fetchChannelEndpoints := func(chanID uint64) (route.Vertex,
route.Vertex, error) {
if chanID == publicChannelID {
return route.Vertex{}, route.Vertex{}, nil
}
return route.Vertex{}, route.Vertex{}, graphdb.ErrEdgeNotFound
}
for _, tc := range lspTestCases {
t.Run(tc.name, func(t *testing.T) {
isLsp := isLSP(tc.routeHints, fetchChannelEndpoints)
require.Equal(t, tc.isLsp, isLsp)
if !tc.isLsp {
return
}
adjustedHints, lspHint, _ := prepareLspRouteHints(
tc.routeHints, tc.probeAmtMsat,
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isLSP(
tt.routeHints, tt.invoiceTarget, hasNode,
)
require.Equal(t, tc.expectedHints, adjustedHints)
require.Equal(t, tc.expectedLspHop, lspHint)
require.Equal(t, tt.expectLSP, result)
})
}
}
// TestPrepareLspRouteHints tests the prepareLspRouteHints function to ensure
// it correctly filters, groups, and calculates worst-case fees for LSP routes.
func TestPrepareLspRouteHints(t *testing.T) {
// Setup test nodes:
// - Alice: public LSP node (in graph)
// - Bob: private node
// - Carol: private node
// - Dave: public LSP node (in graph)
// - Eve: private node
alicePrivKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
alicePubKey := alicePrivKey.PubKey()
bobPrivKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
bobPubKey := bobPrivKey.PubKey()
carolPrivKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
carolPubKey := carolPrivKey.PubKey()
davePrivKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
davePubKey := davePrivKey.PubKey()
evePrivKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
evePubKey := evePrivKey.PubKey()
// Create hop hints with varying fees and CLTV deltas.
aliceHopHint1 := zpay32.HopHint{
NodeID: alicePubKey,
FeeBaseMSat: 100,
FeeProportionalMillionths: 1_000,
CLTVExpiryDelta: 40,
ChannelID: 1,
}
aliceHopHint2 := zpay32.HopHint{
NodeID: alicePubKey,
FeeBaseMSat: 200,
FeeProportionalMillionths: 2_000,
CLTVExpiryDelta: 80,
ChannelID: 2,
}
bobHopHint := zpay32.HopHint{
NodeID: bobPubKey,
FeeBaseMSat: 500,
FeeProportionalMillionths: 500,
CLTVExpiryDelta: 144,
ChannelID: 3,
}
carolHopHint := zpay32.HopHint{
NodeID: carolPubKey,
FeeBaseMSat: 300,
FeeProportionalMillionths: 300,
CLTVExpiryDelta: 40,
ChannelID: 4,
}
daveHopHint1 := zpay32.HopHint{
NodeID: davePubKey,
FeeBaseMSat: 1_000,
FeeProportionalMillionths: 1_000,
CLTVExpiryDelta: 144,
ChannelID: 5,
}
daveHopHint2 := zpay32.HopHint{
NodeID: davePubKey,
FeeBaseMSat: 2_000,
FeeProportionalMillionths: 500,
CLTVExpiryDelta: 288,
ChannelID: 6,
}
eveHopHint := zpay32.HopHint{
NodeID: evePubKey,
FeeBaseMSat: 100,
FeeProportionalMillionths: 100,
CLTVExpiryDelta: 40,
ChannelID: 7,
}
// Mock hasNode: returns true only for alice and dave.
hasNode := func(nodePub route.Vertex) (bool, error) {
aliceVertex := route.NewVertex(alicePubKey)
daveVertex := route.NewVertex(davePubKey)
return bytes.Equal(nodePub[:], aliceVertex[:]) ||
bytes.Equal(nodePub[:], daveVertex[:]), nil
}
amt := lnwire.MilliSatoshi(1_000_000)
tests := []struct {
name string
routeHints [][]zpay32.HopHint
expectedGrps int
validateFunc func(t *testing.T,
groups map[route.Vertex]*LspRouteGroup)
}{
{
name: "single public LSP with one route",
routeHints: [][]zpay32.HopHint{
{bobHopHint, aliceHopHint1},
},
expectedGrps: 1,
validateFunc: func(t *testing.T,
groups map[route.Vertex]*LspRouteGroup) {
require.Len(t, groups, 1)
// Find alice's group.
aliceKey := route.NewVertex(alicePubKey)
group, ok := groups[aliceKey]
require.True(t, ok, "alice group not found")
// Verify LSP hop hint.
require.Equal(t, aliceHopHint1.FeeBaseMSat,
group.LspHopHint.FeeBaseMSat)
require.Equal(t, aliceHopHint1.CLTVExpiryDelta,
group.LspHopHint.CLTVExpiryDelta)
// Verify adjusted route hints.
require.Len(t, group.AdjustedRouteHints, 1)
require.Len(t, group.AdjustedRouteHints[0], 1)
require.Equal(t, bobHopHint.NodeID,
group.AdjustedRouteHints[0][0].NodeID)
},
},
{
name: "single LSP with multiple routes, same fees",
routeHints: [][]zpay32.HopHint{
{bobHopHint, aliceHopHint1},
{carolHopHint, aliceHopHint1},
},
expectedGrps: 1,
validateFunc: func(t *testing.T,
groups map[route.Vertex]*LspRouteGroup) {
aliceKey := route.NewVertex(alicePubKey)
group, ok := groups[aliceKey]
require.True(t, ok, "alice group not found")
// Should have 2 adjusted route hints.
require.Len(t, group.AdjustedRouteHints, 2)
// Fees should match the single hop hint.
require.Equal(t, aliceHopHint1.FeeBaseMSat,
group.LspHopHint.FeeBaseMSat)
require.Equal(t, aliceHopHint1.CLTVExpiryDelta,
group.LspHopHint.CLTVExpiryDelta)
},
},
{
name: "single LSP with different fees, uses worst case",
routeHints: [][]zpay32.HopHint{
{bobHopHint, aliceHopHint1},
{carolHopHint, aliceHopHint2},
},
expectedGrps: 1,
validateFunc: func(t *testing.T,
groups map[route.Vertex]*LspRouteGroup) {
aliceKey := route.NewVertex(alicePubKey)
group, ok := groups[aliceKey]
require.True(t, ok, "alice group not found")
// Should use worst-case (higher) fees.
fee1 := aliceHopHint1.HopFee(amt)
fee2 := aliceHopHint2.HopFee(amt)
require.Greater(t, fee2, fee1,
"hint2 should have higher fees")
// Group should have hint2's fees.
require.Equal(t, aliceHopHint2.FeeBaseMSat,
group.LspHopHint.FeeBaseMSat)
//nolint:ll
require.Equal(t,
aliceHopHint2.FeeProportionalMillionths,
group.LspHopHint.FeeProportionalMillionths)
// Should use worst-case CLTV delta.
require.Equal(t, aliceHopHint2.CLTVExpiryDelta,
group.LspHopHint.CLTVExpiryDelta)
},
},
{
name: "multiple public LSPs",
routeHints: [][]zpay32.HopHint{
{bobHopHint, aliceHopHint1},
{carolHopHint, daveHopHint1},
},
expectedGrps: 2,
validateFunc: func(t *testing.T,
groups map[route.Vertex]*LspRouteGroup) {
require.Len(t, groups, 2)
aliceKey := route.NewVertex(alicePubKey)
daveKey := route.NewVertex(davePubKey)
_, hasAlice := groups[aliceKey]
_, hasDave := groups[daveKey]
require.True(t, hasAlice, "alice group missing")
require.True(t, hasDave, "dave group missing")
},
},
{
name: "filters out private dest hops",
routeHints: [][]zpay32.HopHint{
{aliceHopHint1, bobHopHint},
{carolHopHint, daveHopHint1},
{bobHopHint, eveHopHint},
},
expectedGrps: 1,
validateFunc: func(t *testing.T,
groups map[route.Vertex]*LspRouteGroup) {
require.Len(t, groups, 1)
daveKey := route.NewVertex(davePubKey)
group, ok := groups[daveKey]
require.True(t, ok, "dave group not found")
// Only one route hint should remain
require.Len(t, group.AdjustedRouteHints, 1)
},
},
{
name: "multiple routes to same LSP with varying CLTV",
routeHints: [][]zpay32.HopHint{
{bobHopHint, daveHopHint1},
{carolHopHint, daveHopHint2},
},
expectedGrps: 1,
validateFunc: func(t *testing.T,
groups map[route.Vertex]*LspRouteGroup) {
daveKey := route.NewVertex(davePubKey)
group, ok := groups[daveKey]
require.True(t, ok, "dave group not found")
// Should use maximum CLTV delta.
require.Equal(t, daveHopHint2.CLTVExpiryDelta,
group.LspHopHint.CLTVExpiryDelta)
},
},
{
name: "single hop to public LSP",
routeHints: [][]zpay32.HopHint{
{aliceHopHint1},
},
expectedGrps: 1,
validateFunc: func(t *testing.T,
groups map[route.Vertex]*LspRouteGroup) {
aliceKey := route.NewVertex(alicePubKey)
group, ok := groups[aliceKey]
require.True(t, ok, "alice group not found")
// No adjusted hints since it's a direct hop
require.Len(t, group.AdjustedRouteHints, 0)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
groups, err := prepareLspRouteHints(
tt.routeHints, amt, hasNode,
)
require.NoError(t, err)
require.Len(t, groups, tt.expectedGrps)
// Run custom validation if provided.
if tt.validateFunc != nil {
tt.validateFunc(t, groups)
}
})
}
// Error cases which in operation should never happen because we always
// call isLSP first to check if the route hints are an LSP setup.
t.Run("error: no route hints", func(t *testing.T) {
_, err := prepareLspRouteHints(
[][]zpay32.HopHint{}, amt, hasNode,
)
require.Error(t, err)
require.Contains(t, err.Error(), "no route hints")
})
t.Run("error: no public LSP nodes found", func(t *testing.T) {
// All private destination hops. If all destination hops are
// private we cannot probe any LSPs so we return an error.
routeHints := [][]zpay32.HopHint{
{aliceHopHint1, bobHopHint},
{daveHopHint1, carolHopHint},
}
_, err := prepareLspRouteHints(routeHints, amt, hasNode)
require.Error(t, err)
require.Contains(t, err.Error(), "no public LSP nodes found")
})
}

View file

@ -7,9 +7,11 @@ import (
"os"
"strconv"
"strings"
"time"
"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/wire"
"github.com/lightningnetwork/lnd"
"github.com/lightningnetwork/lnd/input"
"github.com/lightningnetwork/lnd/lnrpc"
"github.com/lightningnetwork/lnd/lntest/wait"
@ -288,6 +290,15 @@ func CalcStaticFeeBuffer(c lnrpc.CommitmentType, numHTLCs int) btcutil.Amount {
func CustomRecordsWithUnendorsed(
originalRecords lnwire.CustomRecords) map[uint64][]byte {
if !ExperimentalEndorsementActive() {
// Return nil if there are no records, to match wire encoding.
if len(originalRecords) == 0 {
return nil
}
return originalRecords.Copy()
}
return originalRecords.MergedCopy(map[uint64][]byte{
uint64(lnwire.ExperimentalEndorsementType): {
lnwire.ExperimentalUnendorsed,
@ -295,6 +306,12 @@ func CustomRecordsWithUnendorsed(
)
}
// ExperimentalEndorsementActive returns true if the experimental endorsement
// window is still open.
func ExperimentalEndorsementActive() bool {
return time.Now().Before(lnd.EndorsementExperimentEnd)
}
// LnrpcOutpointToStr returns a string representation of an lnrpc.OutPoint.
func LnrpcOutpointToStr(outpoint *lnrpc.OutPoint) string {
return fmt.Sprintf("%s:%d", outpoint.TxidStr, outpoint.OutputIndex)

View file

@ -77,6 +77,10 @@ type ResolutionReq struct {
// CommitTx is the force close commitment transaction.
CommitTx *wire.MsgTx
// CommitTxBlockHeight is the block height where the commitment
// transaction confirmed. It is 0 if unknown or not confirmed yet.
CommitTxBlockHeight uint32
// CommitFee is the fee that was paid for the commitment transaction.
CommitFee btcutil.Amount

View file

@ -2215,20 +2215,23 @@ func NewBreachRetribution(chanState *channeldb.OpenChannel, stateNum uint64,
// At this point, we'll check to see if we need any extra
// resolution data for this output.
//
//nolint:ll
resolveReq := ResolutionReq{
ChanPoint: chanState.FundingOutpoint,
ChanType: chanState.ChanType,
ShortChanID: chanState.ShortChanID(),
Initiator: chanState.IsInitiator,
FundingBlob: chanState.CustomBlob,
Type: input.TaprootRemoteCommitSpend,
CloseType: Breach,
CommitTx: spendTx,
SignDesc: *br.LocalOutputSignDesc,
KeyRing: keyRing,
CsvDelay: ourDelay,
BreachCsvDelay: fn.Some(theirDelay),
CommitFee: chanState.RemoteCommitment.CommitFee,
ChanPoint: chanState.FundingOutpoint,
ChanType: chanState.ChanType,
ShortChanID: chanState.ShortChanID(),
Initiator: chanState.IsInitiator,
FundingBlob: chanState.CustomBlob,
Type: input.TaprootRemoteCommitSpend,
CloseType: Breach,
CommitTx: spendTx,
CommitTxBlockHeight: breachHeight,
SignDesc: *br.LocalOutputSignDesc,
KeyRing: keyRing,
CsvDelay: ourDelay,
BreachCsvDelay: fn.Some(theirDelay),
CommitFee: chanState.RemoteCommitment.CommitFee,
}
if revokedLog != nil {
resolveReq.CommitBlob = revokedLog.CustomBlob.ValOpt()
@ -2295,20 +2298,23 @@ func NewBreachRetribution(chanState *channeldb.OpenChannel, stateNum uint64,
// At this point, we'll check to see if we need any extra
// resolution data for this output.
//
//nolint:ll
resolveReq := ResolutionReq{
ChanPoint: chanState.FundingOutpoint,
ChanType: chanState.ChanType,
ShortChanID: chanState.ShortChanID(),
Initiator: chanState.IsInitiator,
FundingBlob: chanState.CustomBlob,
Type: input.TaprootCommitmentRevoke,
CloseType: Breach,
CommitTx: spendTx,
SignDesc: *br.RemoteOutputSignDesc,
KeyRing: keyRing,
CsvDelay: theirDelay,
BreachCsvDelay: fn.Some(theirDelay),
CommitFee: chanState.RemoteCommitment.CommitFee,
ChanPoint: chanState.FundingOutpoint,
ChanType: chanState.ChanType,
ShortChanID: chanState.ShortChanID(),
Initiator: chanState.IsInitiator,
FundingBlob: chanState.CustomBlob,
Type: input.TaprootCommitmentRevoke,
CloseType: Breach,
CommitTx: spendTx,
CommitTxBlockHeight: breachHeight,
SignDesc: *br.RemoteOutputSignDesc,
KeyRing: keyRing,
CsvDelay: theirDelay,
BreachCsvDelay: fn.Some(theirDelay),
CommitFee: chanState.RemoteCommitment.CommitFee,
}
if revokedLog != nil {
resolveReq.CommitBlob = revokedLog.CustomBlob.ValOpt()
@ -6886,6 +6892,7 @@ func NewUnilateralCloseSummary(chanState *channeldb.OpenChannel, //nolint:funlen
// First, we'll generate the commitment point and the revocation point
// so we can re-construct the HTLC state and also our payment key.
commitType := lntypes.Remote
commitTxHeight := uint32(commitSpend.SpendingHeight)
keyRing := DeriveCommitmentKeys(
commitPoint, commitType, chanState.ChanType,
&chanState.LocalChanCfg, &chanState.RemoteChanCfg,
@ -6920,8 +6927,9 @@ func NewUnilateralCloseSummary(chanState *channeldb.OpenChannel, //nolint:funlen
chainfee.SatPerKWeight(remoteCommit.FeePerKw), commitType,
signer, remoteCommit.Htlcs, keyRing, &chanState.LocalChanCfg,
&chanState.RemoteChanCfg, commitSpend.SpendingTx,
chanState.ChanType, isRemoteInitiator, leaseExpiry, chanState,
auxResult.AuxLeaves, auxResolver,
commitTxHeight, chanState.ChanType,
isRemoteInitiator, leaseExpiry, chanState, auxResult.AuxLeaves,
auxResolver,
)
if err != nil {
return nil, fmt.Errorf("unable to create htlc resolutions: %w",
@ -7009,21 +7017,24 @@ func NewUnilateralCloseSummary(chanState *channeldb.OpenChannel, //nolint:funlen
// At this point, we'll check to see if we need any extra
// resolution data for this output.
//
//nolint:ll
resolveReq := ResolutionReq{
ChanPoint: chanState.FundingOutpoint,
ChanType: chanState.ChanType,
ShortChanID: chanState.ShortChanID(),
Initiator: chanState.IsInitiator,
CommitBlob: chanState.RemoteCommitment.CustomBlob,
FundingBlob: chanState.CustomBlob,
Type: input.TaprootRemoteCommitSpend,
CloseType: RemoteForceClose,
CommitTx: commitTxBroadcast,
ContractPoint: *selfPoint,
SignDesc: commitResolution.SelfOutputSignDesc,
KeyRing: keyRing,
CsvDelay: maturityDelay,
CommitFee: chanState.RemoteCommitment.CommitFee,
ChanPoint: chanState.FundingOutpoint,
ChanType: chanState.ChanType,
ShortChanID: chanState.ShortChanID(),
Initiator: chanState.IsInitiator,
CommitBlob: chanState.RemoteCommitment.CustomBlob,
FundingBlob: chanState.CustomBlob,
Type: input.TaprootRemoteCommitSpend,
CloseType: RemoteForceClose,
CommitTx: commitTxBroadcast,
CommitTxBlockHeight: commitTxHeight,
ContractPoint: *selfPoint,
SignDesc: commitResolution.SelfOutputSignDesc,
KeyRing: keyRing,
CsvDelay: maturityDelay,
CommitFee: chanState.RemoteCommitment.CommitFee,
}
resolveBlob := fn.MapOptionZ(
auxResolver,
@ -7209,7 +7220,7 @@ type HtlcResolutions struct {
// the remote party's commitment transaction.
func newOutgoingHtlcResolution(signer input.Signer,
localChanCfg *channeldb.ChannelConfig, commitTx *wire.MsgTx,
htlc *channeldb.HTLC, keyRing *CommitmentKeyRing,
commitTxHeight uint32, htlc *channeldb.HTLC, keyRing *CommitmentKeyRing,
feePerKw chainfee.SatPerKWeight, csvDelay, leaseExpiry uint32,
whoseCommit lntypes.ChannelParty, isCommitFromInitiator bool,
chanType channeldb.ChannelType, chanState *channeldb.OpenChannel,
@ -7285,24 +7296,26 @@ func newOutgoingHtlcResolution(signer input.Signer,
}
}
//nolint:ll
resReq := ResolutionReq{
ChanPoint: chanState.FundingOutpoint,
ChanType: chanType,
ShortChanID: chanState.ShortChanID(),
Initiator: chanState.IsInitiator,
CommitBlob: chanState.RemoteCommitment.CustomBlob,
FundingBlob: chanState.CustomBlob,
Type: input.TaprootHtlcOfferedRemoteTimeout,
CloseType: RemoteForceClose,
CommitTx: commitTx,
ContractPoint: op,
SignDesc: signDesc,
KeyRing: keyRing,
CsvDelay: htlcCsvDelay,
CltvDelay: fn.Some(htlc.RefundTimeout),
CommitFee: chanState.RemoteCommitment.CommitFee,
HtlcID: fn.Some(htlc.HtlcIndex),
PayHash: fn.Some(htlc.RHash),
ChanPoint: chanState.FundingOutpoint,
ChanType: chanType,
ShortChanID: chanState.ShortChanID(),
Initiator: chanState.IsInitiator,
CommitBlob: chanState.RemoteCommitment.CustomBlob,
FundingBlob: chanState.CustomBlob,
Type: input.TaprootHtlcOfferedRemoteTimeout,
CloseType: RemoteForceClose,
CommitTx: commitTx,
CommitTxBlockHeight: commitTxHeight,
ContractPoint: op,
SignDesc: signDesc,
KeyRing: keyRing,
CsvDelay: htlcCsvDelay,
CltvDelay: fn.Some(htlc.RefundTimeout),
CommitFee: chanState.RemoteCommitment.CommitFee,
HtlcID: fn.Some(htlc.HtlcIndex),
PayHash: fn.Some(htlc.RHash),
}
resolveRes := fn.MapOptionZ(
auxResolver,
@ -7513,31 +7526,33 @@ func newOutgoingHtlcResolution(signer input.Signer,
// the sweeping sub-system.
resolveRes := fn.MapOptionZ(
auxResolver, func(a AuxContractResolver) fn.Result[tlv.Blob] {
//nolint:ll
resReq := ResolutionReq{
ChanPoint: chanState.FundingOutpoint,
ChanType: chanType,
ShortChanID: chanState.ShortChanID(),
Initiator: chanState.IsInitiator,
CommitBlob: chanState.LocalCommitment.CustomBlob, //nolint:ll
FundingBlob: chanState.CustomBlob,
Type: input.TaprootHtlcLocalOfferedTimeout, //nolint:ll
CloseType: LocalForceClose,
CommitTx: commitTx,
ContractPoint: op,
SignDesc: sweepSignDesc,
KeyRing: keyRing,
CsvDelay: htlcCsvDelay,
HtlcAmt: btcutil.Amount(txOut.Value),
CommitCsvDelay: csvDelay,
CltvDelay: fn.Some(htlc.RefundTimeout),
CommitFee: chanState.LocalCommitment.CommitFee, //nolint:ll
HtlcID: fn.Some(htlc.HtlcIndex),
PayHash: fn.Some(htlc.RHash),
ChanPoint: chanState.FundingOutpoint,
ChanType: chanType,
ShortChanID: chanState.ShortChanID(),
Initiator: chanState.IsInitiator,
CommitBlob: chanState.LocalCommitment.CustomBlob,
FundingBlob: chanState.CustomBlob,
Type: input.TaprootHtlcLocalOfferedTimeout,
CloseType: LocalForceClose,
CommitTx: commitTx,
CommitTxBlockHeight: commitTxHeight,
ContractPoint: op,
SignDesc: sweepSignDesc,
KeyRing: keyRing,
CsvDelay: htlcCsvDelay,
HtlcAmt: btcutil.Amount(txOut.Value),
CommitCsvDelay: csvDelay,
CltvDelay: fn.Some(htlc.RefundTimeout),
CommitFee: chanState.LocalCommitment.CommitFee,
HtlcID: fn.Some(htlc.HtlcIndex),
PayHash: fn.Some(htlc.RHash),
AuxSigDesc: fn.Some(AuxSigDesc{
SignDetails: *txSignDetails,
AuxSig: func() []byte {
tlvType := htlcCustomSigType.TypeVal() //nolint:ll
return htlc.CustomRecords[uint64(tlvType)] //nolint:ll
tlvType := htlcCustomSigType.TypeVal()
return htlc.CustomRecords[uint64(tlvType)]
}(),
}),
}
@ -7573,7 +7588,7 @@ func newOutgoingHtlcResolution(signer input.Signer,
// TODO(roasbeef) consolidate code with above func
func newIncomingHtlcResolution(signer input.Signer,
localChanCfg *channeldb.ChannelConfig, commitTx *wire.MsgTx,
htlc *channeldb.HTLC, keyRing *CommitmentKeyRing,
commitTxHeight uint32, htlc *channeldb.HTLC, keyRing *CommitmentKeyRing,
feePerKw chainfee.SatPerKWeight, csvDelay, leaseExpiry uint32,
whoseCommit lntypes.ChannelParty, isCommitFromInitiator bool,
chanType channeldb.ChannelType, chanState *channeldb.OpenChannel,
@ -7648,26 +7663,28 @@ func newIncomingHtlcResolution(signer input.Signer,
}
}
//nolint:ll
resReq := ResolutionReq{
ChanPoint: chanState.FundingOutpoint,
ChanType: chanType,
ShortChanID: chanState.ShortChanID(),
Initiator: chanState.IsInitiator,
CommitBlob: chanState.RemoteCommitment.CustomBlob,
Type: input.TaprootHtlcAcceptedRemoteSuccess,
FundingBlob: chanState.CustomBlob,
CloseType: RemoteForceClose,
CommitTx: commitTx,
ContractPoint: op,
SignDesc: signDesc,
KeyRing: keyRing,
HtlcID: fn.Some(htlc.HtlcIndex),
CsvDelay: htlcCsvDelay,
CltvDelay: fn.Some(htlc.RefundTimeout),
CommitFee: chanState.RemoteCommitment.CommitFee,
PayHash: fn.Some(htlc.RHash),
CommitCsvDelay: csvDelay,
HtlcAmt: htlc.Amt.ToSatoshis(),
ChanPoint: chanState.FundingOutpoint,
ChanType: chanType,
ShortChanID: chanState.ShortChanID(),
Initiator: chanState.IsInitiator,
CommitBlob: chanState.RemoteCommitment.CustomBlob,
Type: input.TaprootHtlcAcceptedRemoteSuccess,
FundingBlob: chanState.CustomBlob,
CloseType: RemoteForceClose,
CommitTx: commitTx,
CommitTxBlockHeight: commitTxHeight,
ContractPoint: op,
SignDesc: signDesc,
KeyRing: keyRing,
HtlcID: fn.Some(htlc.HtlcIndex),
CsvDelay: htlcCsvDelay,
CltvDelay: fn.Some(htlc.RefundTimeout),
CommitFee: chanState.RemoteCommitment.CommitFee,
PayHash: fn.Some(htlc.RHash),
CommitCsvDelay: csvDelay,
HtlcAmt: htlc.Amt.ToSatoshis(),
}
resolveRes := fn.MapOptionZ(
auxResolver,
@ -7867,28 +7884,30 @@ func newIncomingHtlcResolution(signer input.Signer,
resolveRes := fn.MapOptionZ(
auxResolver, func(a AuxContractResolver) fn.Result[tlv.Blob] {
//nolint:ll
resReq := ResolutionReq{
ChanPoint: chanState.FundingOutpoint,
ChanType: chanType,
ShortChanID: chanState.ShortChanID(),
Initiator: chanState.IsInitiator,
CommitBlob: chanState.LocalCommitment.CustomBlob, //nolint:ll
Type: input.TaprootHtlcAcceptedLocalSuccess, //nolint:ll
FundingBlob: chanState.CustomBlob,
CloseType: LocalForceClose,
CommitTx: commitTx,
ContractPoint: op,
SignDesc: sweepSignDesc,
KeyRing: keyRing,
HtlcID: fn.Some(htlc.HtlcIndex),
CsvDelay: htlcCsvDelay,
CommitFee: chanState.LocalCommitment.CommitFee, //nolint:ll
PayHash: fn.Some(htlc.RHash),
ChanPoint: chanState.FundingOutpoint,
ChanType: chanType,
ShortChanID: chanState.ShortChanID(),
Initiator: chanState.IsInitiator,
CommitBlob: chanState.LocalCommitment.CustomBlob,
Type: input.TaprootHtlcAcceptedLocalSuccess,
FundingBlob: chanState.CustomBlob,
CloseType: LocalForceClose,
CommitTx: commitTx,
CommitTxBlockHeight: commitTxHeight,
ContractPoint: op,
SignDesc: sweepSignDesc,
KeyRing: keyRing,
HtlcID: fn.Some(htlc.HtlcIndex),
CsvDelay: htlcCsvDelay,
CommitFee: chanState.LocalCommitment.CommitFee,
PayHash: fn.Some(htlc.RHash),
AuxSigDesc: fn.Some(AuxSigDesc{
SignDetails: *txSignDetails,
AuxSig: func() []byte {
tlvType := htlcCustomSigType.TypeVal() //nolint:ll
return htlc.CustomRecords[uint64(tlvType)] //nolint:ll
tlvType := htlcCustomSigType.TypeVal()
return htlc.CustomRecords[uint64(tlvType)]
}(),
}),
CommitCsvDelay: csvDelay,
@ -7949,9 +7968,10 @@ func extractHtlcResolutions(feePerKw chainfee.SatPerKWeight,
whoseCommit lntypes.ChannelParty, signer input.Signer,
htlcs []channeldb.HTLC, keyRing *CommitmentKeyRing,
localChanCfg, remoteChanCfg *channeldb.ChannelConfig,
commitTx *wire.MsgTx, chanType channeldb.ChannelType,
isCommitFromInitiator bool, leaseExpiry uint32,
chanState *channeldb.OpenChannel, auxLeaves fn.Option[CommitAuxLeaves],
commitTx *wire.MsgTx, commitTxHeight uint32,
chanType channeldb.ChannelType, isCommitFromInitiator bool,
leaseExpiry uint32, chanState *channeldb.OpenChannel,
auxLeaves fn.Option[CommitAuxLeaves],
auxResolver fn.Option[AuxContractResolver]) (*HtlcResolutions, error) {
// TODO(roasbeef): don't need to swap csv delay?
@ -7984,8 +8004,8 @@ func extractHtlcResolutions(feePerKw chainfee.SatPerKWeight,
// Otherwise, we'll create an incoming HTLC resolution
// as we can satisfy the contract.
ihr, err := newIncomingHtlcResolution(
signer, localChanCfg, commitTx, &htlc,
keyRing, feePerKw, uint32(csvDelay),
signer, localChanCfg, commitTx, commitTxHeight,
&htlc, keyRing, feePerKw, uint32(csvDelay),
leaseExpiry, whoseCommit, isCommitFromInitiator,
chanType, chanState, auxLeaves, auxResolver,
)
@ -7999,10 +8019,10 @@ func extractHtlcResolutions(feePerKw chainfee.SatPerKWeight,
}
ohr, err := newOutgoingHtlcResolution(
signer, localChanCfg, commitTx, &htlc, keyRing,
feePerKw, uint32(csvDelay), leaseExpiry, whoseCommit,
isCommitFromInitiator, chanType, chanState, auxLeaves,
auxResolver,
signer, localChanCfg, commitTx, commitTxHeight, &htlc,
keyRing, feePerKw, uint32(csvDelay), leaseExpiry,
whoseCommit, isCommitFromInitiator, chanType, chanState,
auxLeaves, auxResolver,
)
if err != nil {
return nil, fmt.Errorf("outgoing resolution "+
@ -8148,7 +8168,8 @@ func (lc *LightningChannel) ForceClose(opts ...ForceCloseOpt) (
localCommitment := lc.channelState.LocalCommitment
summary, err := NewLocalForceCloseSummary(
lc.channelState, lc.Signer, commitTx,
localCommitment.CommitHeight, lc.leafStore, lc.auxResolver,
0, localCommitment.CommitHeight, lc.leafStore,
lc.auxResolver,
)
if err != nil {
return nil, fmt.Errorf("unable to gen force close "+
@ -8162,11 +8183,11 @@ func (lc *LightningChannel) ForceClose(opts ...ForceCloseOpt) (
}
// NewLocalForceCloseSummary generates a LocalForceCloseSummary from the given
// channel state. The passed commitTx must be a fully signed commitment
// channel state. The passed commitTx must be a fully signed commitment
// transaction corresponding to localCommit.
func NewLocalForceCloseSummary(chanState *channeldb.OpenChannel,
signer input.Signer, commitTx *wire.MsgTx, stateNum uint64,
leafStore fn.Option[AuxLeafStore],
signer input.Signer, commitTx *wire.MsgTx, commitTxHeight uint32,
stateNum uint64, leafStore fn.Option[AuxLeafStore],
auxResolver fn.Option[AuxContractResolver]) (*LocalForceCloseSummary,
error) {
@ -8301,20 +8322,21 @@ func NewLocalForceCloseSummary(chanState *channeldb.OpenChannel,
func(a AuxContractResolver) fn.Result[tlv.Blob] {
//nolint:ll
return a.ResolveContract(ResolutionReq{
ChanPoint: chanState.FundingOutpoint, //nolint:ll
ChanType: chanState.ChanType,
ShortChanID: chanState.ShortChanID(),
Initiator: chanState.IsInitiator,
CommitBlob: chanState.LocalCommitment.CustomBlob,
FundingBlob: chanState.CustomBlob,
Type: input.TaprootLocalCommitSpend,
CloseType: LocalForceClose,
CommitTx: commitTx,
ContractPoint: commitResolution.SelfOutPoint,
SignDesc: commitResolution.SelfOutputSignDesc,
KeyRing: keyRing,
CsvDelay: csvTimeout,
CommitFee: chanState.LocalCommitment.CommitFee,
ChanPoint: chanState.FundingOutpoint,
ChanType: chanState.ChanType,
ShortChanID: chanState.ShortChanID(),
Initiator: chanState.IsInitiator,
CommitBlob: chanState.LocalCommitment.CustomBlob,
FundingBlob: chanState.CustomBlob,
Type: input.TaprootLocalCommitSpend,
CloseType: LocalForceClose,
CommitTx: commitTx,
CommitTxBlockHeight: commitTxHeight,
ContractPoint: commitResolution.SelfOutPoint,
SignDesc: commitResolution.SelfOutputSignDesc,
KeyRing: keyRing,
CsvDelay: csvTimeout,
CommitFee: chanState.LocalCommitment.CommitFee,
})
},
)
@ -8334,9 +8356,9 @@ func NewLocalForceCloseSummary(chanState *channeldb.OpenChannel,
htlcResolutions, err := extractHtlcResolutions(
chainfee.SatPerKWeight(localCommit.FeePerKw), lntypes.Local,
signer, localCommit.Htlcs, keyRing, &chanState.LocalChanCfg,
&chanState.RemoteChanCfg, commitTx, chanState.ChanType,
chanState.IsInitiator, leaseExpiry, chanState,
auxResult.AuxLeaves, auxResolver,
&chanState.RemoteChanCfg, commitTx, commitTxHeight,
chanState.ChanType, chanState.IsInitiator, leaseExpiry,
chanState, auxResult.AuxLeaves, auxResolver,
)
if err != nil {
return nil, fmt.Errorf("unable to gen htlc resolution: %w", err)

View file

@ -1,6 +1,6 @@
# If you change this please also update GO_VERSION in Makefile (then run
# `make lint` to see where else it needs to be updated as well).
FROM golang:1.25.3-bookworm
FROM golang:1.25.5-bookworm
MAINTAINER Olaoluwa Osuntokun <laolu@lightning.engineering>

View file

@ -4,4 +4,4 @@ require github.com/lightningnetwork/lnd/ticker v1.0.0
replace github.com/lightningnetwork/lnd/ticker v1.0.0 => ../ticker
go 1.19
go 1.24.11

View file

@ -13,6 +13,7 @@ import (
"github.com/lightningnetwork/lnd/channeldb"
"github.com/lightningnetwork/lnd/discovery"
"github.com/lightningnetwork/lnd/fn/v2"
"github.com/lightningnetwork/lnd/funding"
"github.com/lightningnetwork/lnd/graph/db/models"
"github.com/lightningnetwork/lnd/lnrpc"
"github.com/lightningnetwork/lnd/lnwire"
@ -321,12 +322,19 @@ func (r *Manager) createEdge(channel *channeldb.OpenChannel,
shortChanID = channel.ZeroConfRealScid()
}
fundingScript, err := funding.MakeFundingScript(channel)
if err != nil {
return nil, nil, fmt.Errorf("unable to create funding "+
"script: %v", err)
}
info := &models.ChannelEdgeInfo{
ChannelID: shortChanID.ToUint64(),
ChainHash: channel.ChainHash,
Features: lnwire.EmptyFeatureVector(),
Capacity: channel.Capacity,
ChannelPoint: channel.FundingOutpoint,
ChannelID: shortChanID.ToUint64(),
ChainHash: channel.ChainHash,
Features: lnwire.EmptyFeatureVector(),
Capacity: channel.Capacity,
ChannelPoint: channel.FundingOutpoint,
FundingScript: fn.Some(fundingScript),
}
copy(info.NodeKey1Bytes[:], nodeKey1Bytes)

View file

@ -13,6 +13,8 @@ import (
"github.com/btcsuite/btcd/wire"
"github.com/lightningnetwork/lnd/channeldb"
"github.com/lightningnetwork/lnd/discovery"
"github.com/lightningnetwork/lnd/fn/v2"
"github.com/lightningnetwork/lnd/funding"
"github.com/lightningnetwork/lnd/graph/db/models"
"github.com/lightningnetwork/lnd/keychain"
"github.com/lightningnetwork/lnd/lnrpc"
@ -385,6 +387,10 @@ func TestCreateEdgeLower(t *testing.T) {
Index: 0,
},
}
fundingScript, err := funding.MakeFundingScript(channel)
require.NoError(t, err)
expectedInfo := &models.ChannelEdgeInfo{
ChannelID: 8,
ChainHash: channel.ChainHash,
@ -399,6 +405,7 @@ func TestCreateEdgeLower(t *testing.T) {
remoteMultisigKey.SerializeCompressed()),
AuthProof: nil,
ExtraOpaqueData: nil,
FundingScript: fn.Some(fundingScript),
}
expectedEdge := &models.ChannelEdgePolicy{
ChannelID: 8,
@ -473,6 +480,10 @@ func TestCreateEdgeHigher(t *testing.T) {
Index: 0,
},
}
fundingScript, err := funding.MakeFundingScript(channel)
require.NoError(t, err)
expectedInfo := &models.ChannelEdgeInfo{
ChannelID: 8,
ChainHash: channel.ChainHash,
@ -487,6 +498,7 @@ func TestCreateEdgeHigher(t *testing.T) {
localMultisigKey.SerializeCompressed()),
AuthProof: nil,
ExtraOpaqueData: nil,
FundingScript: fn.Some(fundingScript),
}
expectedEdge := &models.ChannelEdgePolicy{
ChannelID: 8,

View file

@ -133,30 +133,100 @@ func (b *missionControlStore) clear() error {
}
// fetchAll returns all results currently stored in the database.
// It also removes any corrupted entries that fail to deserialize from both
// the database and the in-memory tracking structures.
func (b *missionControlStore) fetchAll() ([]*paymentResult, error) {
var results []*paymentResult
var corruptedKeys [][]byte
// Read all results and identify corrupted entries.
err := b.db.view(func(resultBucket kvdb.RBucket) error {
results = make([]*paymentResult, 0)
corruptedKeys = make([][]byte, 0)
return resultBucket.ForEach(func(k, v []byte) error {
err := resultBucket.ForEach(func(k, v []byte) error {
result, err := deserializeResult(k, v)
// In case of an error, track the key for removal.
if err != nil {
return err
log.Warnf("Failed to deserialize mission "+
"control entry (key=%x): %v", k, err)
// Make a copy of the key since ForEach reuses
// the slice.
keyCopy := make([]byte, len(k))
copy(keyCopy, k)
corruptedKeys = append(corruptedKeys, keyCopy)
return nil
}
results = append(results, result)
return nil
})
if err != nil {
return err
}
return nil
}, func() {
results = nil
corruptedKeys = nil
})
if err != nil {
return nil, err
}
// Delete corrupted entries from the database which were identified
// when loading the results from the database.
//
// TODO: This code part should eventually be removed once we move the
// mission control store to a native sql database and have to do a
// full migration of the data.
if len(corruptedKeys) > 0 {
err = b.db.update(func(resultBucket kvdb.RwBucket) error {
for _, key := range corruptedKeys {
if err := resultBucket.Delete(key); err != nil {
return fmt.Errorf("failed to delete "+
"corrupted entry: %w", err)
}
}
return nil
}, func() {})
if err != nil {
return nil, err
}
// Build a set of corrupted keys.
corruptedSet := make(map[string]struct{}, len(corruptedKeys))
for _, key := range corruptedKeys {
corruptedSet[string(key)] = struct{}{}
}
// Remove corrupted keys from in-memory map.
for keyStr := range corruptedSet {
delete(b.keysMap, keyStr)
}
// Remove from the keys list in a single pass.
for e := b.keys.Front(); e != nil; {
next := e.Next()
keyVal, ok := e.Value.(string)
if ok {
_, isCorrupted := corruptedSet[keyVal]
if isCorrupted {
b.keys.Remove(e)
}
}
e = next
}
log.Infof("Removed %d corrupted mission control entries",
len(corruptedKeys))
}
return results, nil
}

View file

@ -332,3 +332,108 @@ func BenchmarkMissionControlStoreFlushing(b *testing.B) {
})
}
}
// TestMissionControlStoreDeletesCorruptedEntries tests that fetchAll() skips
// entries that fail to deserialize, deletes them from the database, and
// removes them from the in-memory tracking structures.
func TestMissionControlStoreDeletesCorruptedEntries(t *testing.T) {
h := newMCStoreTestHarness(t, testMaxRecords, time.Second)
store := h.store
failureSourceIdx := 1
// Create two valid results.
result1 := newPaymentResult(
1, mcStoreTestRoute, testTime, testTime,
fn.Some(newPaymentFailure(
&failureSourceIdx,
lnwire.NewFailIncorrectDetails(100, 1000),
)),
)
result2 := newPaymentResult(
2, mcStoreTestRoute, testTime.Add(time.Hour),
testTime.Add(time.Hour),
fn.Some(newPaymentFailure(
&failureSourceIdx,
lnwire.NewFailIncorrectDetails(100, 1000),
)),
)
// Store both results.
store.AddResult(result1)
store.AddResult(result2)
require.NoError(t, store.storeResults())
// Insert a corrupted entry into the database.
var corruptedKey [8 + 8 + 33]byte
byteOrder.PutUint64(corruptedKey[:], uint64(testTime.Add(
30*time.Minute).UnixNano()),
)
byteOrder.PutUint64(corruptedKey[8:], 99) // Unique ID.
copy(corruptedKey[16:], result1.route.Val.sourcePubKey.Val[:])
err := store.db.update(func(bucket kvdb.RwBucket) error {
// Insert corrupted/invalid TLV data that will fail to
// deserialize.
corruptedValue := []byte{0xFF, 0xFF, 0xFF, 0xFF}
return bucket.Put(corruptedKey[:], corruptedValue)
}, func() {})
require.NoError(t, err)
// Add the corrupted key to in-memory tracking to simulate it being
// loaded at startup (newMissionControlStore populates keysMap from
// all DB keys).
corruptedKeyStr := string(corruptedKey[:])
store.keysMap[corruptedKeyStr] = struct{}{}
store.keys.PushBack(corruptedKeyStr)
// Verify the corrupted key is in the in-memory tracking.
_, exists := store.keysMap[corruptedKeyStr]
require.True(t, exists, "corrupted key should be in keysMap")
// Verify we have 3 entries in the database before fetchAll.
var dbEntryCountBefore int
err = store.db.view(func(bucket kvdb.RBucket) error {
return bucket.ForEach(func(k, v []byte) error {
dbEntryCountBefore++
return nil
})
}, func() {
dbEntryCountBefore = 0
})
require.NoError(t, err)
require.Equal(t, 3, dbEntryCountBefore, "should have 3 entries "+
"in the database before cleanup")
// Now fetch all results. The corrupted entry should be skipped,
// deleted from the DB, and removed from in-memory tracking.
results, err := store.fetchAll()
require.NoError(t, err, "fetchAll should not return an error "+
"even when encountering corrupted entries")
require.Len(t, results, 2, "should skip the corrupted entry and "+
"return only valid results")
// Verify we still have the correct results.
require.Equal(t, result1, results[0])
require.Equal(t, result2, results[1])
// Verify the corrupted entry was removed from in-memory tracking.
_, exists = store.keysMap[corruptedKeyStr]
require.False(t, exists, "corrupted key should not exist in keysMap")
// Verify the corrupted entry was deleted from the database.
var dbEntryCountAfter int
err = store.db.view(func(bucket kvdb.RBucket) error {
return bucket.ForEach(func(k, v []byte) error {
dbEntryCountAfter++
return nil
})
}, func() {
dbEntryCountAfter = 0
})
require.NoError(t, err)
require.Equal(t, 2, dbEntryCountAfter, "corrupted entry should be "+
"deleted from the database")
}

View file

@ -1,9 +1,11 @@
package rpcperms
import (
"bytes"
"context"
"errors"
"fmt"
"runtime/debug"
"sync"
"sync/atomic"
@ -14,6 +16,8 @@ import (
"github.com/lightningnetwork/lnd/monitoring"
"github.com/lightningnetwork/lnd/subscribe"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"gopkg.in/macaroon-bakery.v2/bakery"
)
@ -111,6 +115,8 @@ var (
// +---v--------------------------------+
// | InterceptorChain |
// +-+----------------------------------+
// | Panic Recovery Interceptor |
// +----------------------------------+
// | Log Interceptor |
// +----------------------------------+
// | RPC State Interceptor |
@ -536,7 +542,19 @@ func (r *InterceptorChain) CreateServerOpts() []grpc.ServerOption {
var unaryInterceptors []grpc.UnaryServerInterceptor
var strmInterceptors []grpc.StreamServerInterceptor
// The first interceptors we'll add to the chain is our logging
// The recovery interceptors need to be the outermost interceptors so
// synchronous panics in subsequent interceptors or RPC handlers are
// converted into an RPC error instead of crashing lnd.
unaryInterceptors = append(
unaryInterceptors,
panicRecoveryUnaryServerInterceptor(r.rpcsLog),
)
strmInterceptors = append(
strmInterceptors,
panicRecoveryStreamServerInterceptor(r.rpcsLog),
)
// The next interceptors we'll add to the chain are our logging
// interceptors, so we can automatically log all errors that happen
// during RPC calls.
unaryInterceptors = append(
@ -595,6 +613,139 @@ func (r *InterceptorChain) CreateServerOpts() []grpc.ServerOption {
return serverOpts
}
// logRecoveredPanic logs a panic caught while handling an RPC request. The
// stack trace is included to preserve enough information to debug the faulty
// handler while allowing lnd to keep running.
func logRecoveredPanic(logger btclog.Logger, fullMethod string,
panicValue any) {
if logger == nil {
return
}
if fullMethod == "" {
fullMethod = "<unknown>"
}
stack := truncatePanicStack(debug.Stack())
logger.Errorf("[%v]: recovered panic in RPC handler: %v\n%s",
fullMethod, panicValue, stack)
}
const (
// maxPanicStackSize is the maximum stack size logged for recovered RPC
// panics. This follows the existing 8 KiB recovered-panic stack bound
// convention while avoiding package coupling for a single constant.
maxPanicStackSize = 8192
panicStackTruncatedMsg = "\n... stack trace truncated ..."
)
// truncatePanicStack caps a panic stack trace while keeping the final logged
// line readable when possible.
func truncatePanicStack(stack []byte) []byte {
if len(stack) <= maxPanicStackSize {
return stack
}
suffix := []byte(panicStackTruncatedMsg)
maxStackLen := maxPanicStackSize - len(suffix)
searchStack := stack[:maxStackLen+1]
newLineIndex := bytes.LastIndexByte(searchStack, '\n')
if newLineIndex > 0 {
maxStackLen = newLineIndex
}
truncatedStack := make([]byte, 0, maxStackLen+len(suffix))
truncatedStack = append(truncatedStack, stack[:maxStackLen]...)
truncatedStack = append(truncatedStack, suffix...)
return truncatedStack
}
// panicRecoveryUnaryServerInterceptor recovers panics from unary RPC handlers
// and converts them to an internal gRPC error.
func panicRecoveryUnaryServerInterceptor(
logger btclog.Logger) grpc.UnaryServerInterceptor {
return func(ctx context.Context, req any,
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler) (any, error) {
var (
resp any
err error
)
func() {
defer func() {
panicValue := recover()
if panicValue == nil {
return
}
fullMethod := ""
if info != nil {
fullMethod = info.FullMethod
}
logRecoveredPanic(
logger, fullMethod, panicValue,
)
resp = nil
err = status.Error(
codes.Internal, "internal server error",
)
}()
resp, err = handler(ctx, req)
}()
return resp, err
}
}
// panicRecoveryStreamServerInterceptor recovers panics from streaming RPC
// handlers and converts them to an internal gRPC error.
func panicRecoveryStreamServerInterceptor(
logger btclog.Logger) grpc.StreamServerInterceptor {
return func(srv any, ss grpc.ServerStream,
info *grpc.StreamServerInfo,
handler grpc.StreamHandler) error {
var err error
func() {
defer func() {
panicValue := recover()
if panicValue == nil {
return
}
fullMethod := ""
if info != nil {
fullMethod = info.FullMethod
}
logRecoveredPanic(
logger, fullMethod, panicValue,
)
err = status.Error(
codes.Internal, "internal server error",
)
}()
err = handler(srv, ss)
}()
return err
}
}
// errorLogUnaryServerInterceptor is a simple UnaryServerInterceptor that will
// automatically log any errors that occur when serving a client's unary
// request.

View file

@ -0,0 +1,129 @@
package rpcperms
import (
"bytes"
"context"
"errors"
"testing"
"github.com/btcsuite/btclog/v2"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// TestPanicRecoveryUnaryServerInterceptor asserts that unary handler panics are
// converted to internal RPC errors rather than propagating to the process.
func TestPanicRecoveryUnaryServerInterceptor(t *testing.T) {
interceptor := panicRecoveryUnaryServerInterceptor(btclog.Disabled)
info := &grpc.UnaryServerInfo{
FullMethod: "/test.Service/Unary",
}
resp, err := interceptor(
t.Context(), nil, info,
func(context.Context, any) (any, error) {
panic("boom")
},
)
require.Nil(t, resp)
require.Error(t, err)
require.Equal(t, codes.Internal, status.Code(err))
expectedResp := struct{}{}
expectedErr := errors.New("handler error")
resp, err = interceptor(
t.Context(), nil, info,
func(context.Context, any) (any, error) {
return expectedResp, expectedErr
},
)
require.Equal(t, expectedResp, resp)
require.ErrorIs(t, err, expectedErr)
var nilLogger btclog.Logger
interceptor = panicRecoveryUnaryServerInterceptor(nilLogger)
resp, err = interceptor(
t.Context(), nil, info,
func(context.Context, any) (any, error) {
panic("boom")
},
)
require.Nil(t, resp)
require.Error(t, err)
require.Equal(t, codes.Internal, status.Code(err))
}
// TestPanicRecoveryStreamServerInterceptor asserts that stream handler panics
// are converted to internal RPC errors rather than propagating to the process.
func TestPanicRecoveryStreamServerInterceptor(t *testing.T) {
interceptor := panicRecoveryStreamServerInterceptor(btclog.Disabled)
info := &grpc.StreamServerInfo{
FullMethod: "/test.Service/Stream",
}
err := interceptor(
nil, nil, info, func(any, grpc.ServerStream) error {
panic("boom")
},
)
require.Error(t, err)
require.Equal(t, codes.Internal, status.Code(err))
expectedErr := errors.New("handler error")
err = interceptor(
nil, nil, info, func(any, grpc.ServerStream) error {
return expectedErr
},
)
require.ErrorIs(t, err, expectedErr)
var nilLogger btclog.Logger
interceptor = panicRecoveryStreamServerInterceptor(nilLogger)
err = interceptor(
nil, nil, info, func(any, grpc.ServerStream) error {
panic("boom")
},
)
require.Error(t, err)
require.Equal(t, codes.Internal, status.Code(err))
var stream recordingServerStream
err = interceptor(
nil, &stream, info, func(_ any, ss grpc.ServerStream) error {
require.NoError(t, ss.SendMsg(struct{}{}))
panic("boom")
},
)
require.Error(t, err)
require.Equal(t, codes.Internal, status.Code(err))
require.Equal(t, 1, stream.numSent)
}
type recordingServerStream struct {
grpc.ServerStream
numSent int
}
func (s *recordingServerStream) SendMsg(any) error {
s.numSent++
return nil
}
// TestTruncatePanicStack asserts that panic stack traces are capped with a
// readable truncation marker.
func TestTruncatePanicStack(t *testing.T) {
shortStack := []byte("short stack")
require.Equal(t, shortStack, truncatePanicStack(shortStack))
longStack := bytes.Repeat([]byte("stack frame\n"), maxPanicStackSize)
truncatedStack := truncatePanicStack(longStack)
require.LessOrEqual(t, len(truncatedStack), maxPanicStackSize)
require.True(
t, bytes.HasSuffix(
truncatedStack, []byte(panicStackTruncatedMsg),
),
)
}

View file

@ -685,6 +685,8 @@ func newRPCServer(cfg *Config, interceptorChain *rpcperms.InterceptorChain,
// addDeps populates all dependencies needed by the RPC server, and any
// of the sub-servers that it maintains. When this is done, the RPC server can
// be started, and start accepting RPC calls.
//
//nolint:funlen
func (r *rpcServer) addDeps(ctx context.Context, s *server,
macService *macaroons.Service,
subServerCgs *subRPCServerConfigs, atpl *autopilot.Manager,
@ -734,6 +736,11 @@ func (r *rpcServer) addDeps(ctx context.Context, s *server,
return info.NodeKey1Bytes, info.NodeKey2Bytes, nil
},
HasNode: func(nodePub route.Vertex) (bool, error) {
_, exists, err := graph.HasNode(ctx, nodePub)
return exists, err
},
FindRoute: s.chanRouter.FindRoute,
MissionControl: s.defaultMC,
ActiveNetParams: r.cfg.ActiveNetParams.Params,
@ -8071,14 +8078,10 @@ func (r *rpcServer) UpdateChannelPolicy(ctx context.Context,
// We'll also ensure that the user isn't setting a CLTV delta that
// won't give outgoing HTLCs enough time to fully resolve if needed.
if req.TimeLockDelta < minTimeLockDelta {
return nil, fmt.Errorf("time lock delta of %v is too small, "+
"minimum supported is %v", req.TimeLockDelta,
minTimeLockDelta)
} else if req.TimeLockDelta > uint32(MaxTimeLockDelta) {
return nil, fmt.Errorf("time lock delta of %v is too big, "+
"maximum supported is %v", req.TimeLockDelta,
MaxTimeLockDelta)
if err := validateChannelPolicyTimeLockDelta(
req.TimeLockDelta, r.cfg.MaxOutgoingCltvExpiry,
); err != nil {
return nil, err
}
// By default, positive inbound fees are rejected.

View file

@ -1616,6 +1616,17 @@
; Whether to skip executing schema migrations.
; db.postgres.skipmigrations=false
; Use a global lock for channeldb access. This ensures only a single writer at
; a time but reduces concurrency. This is a temporary workaround until the
; revocation log is migrated to native SQL.
; db.postgres.channeldb-with-global-lock=false
; Use a global lock for wallet database access. This is a temporary workaround
; until the wallet subsystem is upgraded to a native sql schema.
; db.postgres.walletdb-with-global-lock=true
; The maximum number of elements to use in a native-SQL batch query IN clause.
; db.postgres.query.max-batch-size=5000

View file

@ -59,7 +59,7 @@ OPTIONS_NO_LND_DEFAULT_VALUE_CHECK="channel-max-fee-exposure adminmacaroonpath \
backupfilepath maxchansize bitcoin.chaindir bitcoin.defaultchanconfs \
bitcoin.defaultremotedelay bitcoin.dnsseed signrpc.signermacaroonpath \
walletrpc.walletkitmacaroonpath chainrpc.notifiermacaroonpath \
routerrpc.routermacaroonpath"
routerrpc.routermacaroonpath db.postgres.walletdb-with-global-lock"
# EXITCODE is returned at the end after all checks are performed and set to 1

View file

@ -822,6 +822,7 @@ func newServer(ctx context.Context, cfg *Config, listenAddrs []net.Addr,
s.witnessBeacon = newPreimageBeacon(
dbs.ChanStateDB.NewWitnessCache(),
s.interceptableSwitch.ForwardPacket,
s.interceptableSwitch.RemoveOnChainIntercept,
)
chanStatusMgrCfg := &netann.ChanStatusConfig{
@ -1246,6 +1247,11 @@ func newServer(ctx context.Context, cfg *Config, listenAddrs []net.Addr,
ChainHash: *s.cfg.ActiveNetParams.GenesisHash,
IncomingBroadcastDelta: lncfg.DefaultIncomingBroadcastDelta,
OutgoingBroadcastDelta: lncfg.DefaultOutgoingBroadcastDelta,
CustomHtlcChecker: fn.MapOption(
func(t htlcswitch.AuxTrafficShaper) contractcourt.CustomHtlcChecker {
return t
},
)(s.implCfg.TrafficShaper),
NewSweepAddr: func() ([]byte, error) {
addr, err := newSweepPkScriptGen(
cc.Wallet, netParams,
@ -2128,6 +2134,21 @@ func (s *server) Start(ctx context.Context) error {
cleanup := cleaner{}
s.start.Do(func() {
// Before starting any subsystems, repair any link nodes that
// may have been incorrectly pruned due to the race condition
// that was fixed in the link node pruning logic. This must
// happen before the chain arbitrator and other subsystems load
// channels, to ensure the invariant "link node exists iff
// channels exist" is maintained.
err := s.chanStateDB.RepairLinkNodes(s.cfg.ActiveNetParams.Net)
if err != nil {
srvrLog.Errorf("Failed to repair link nodes: %v", err)
startErr = err
return
}
cleanup = cleanup.add(s.customMessageServer.Stop)
if err := s.customMessageServer.Start(); err != nil {
startErr = err
@ -2451,9 +2472,8 @@ func (s *server) Start(ctx context.Context) error {
// With all the relevant sub-systems started, we'll now attempt
// to establish persistent connections to our direct channel
// collaborators within the network. Before doing so however,
// we'll prune our set of link nodes found within the database
// to ensure we don't reconnect to any nodes we no longer have
// open channels with.
// we'll prune our set of link nodes to ensure we don't
// reconnect to any nodes we no longer have open channels with.
if err := s.chanStateDB.PruneLinkNodes(); err != nil {
srvrLog.Errorf("Failed to prune link nodes: %v", err)
@ -3378,6 +3398,18 @@ func (s *server) genNodeAnnouncement(features *lnwire.RawFeatureVector,
modifier(&newNodeAnn)
}
// The modifiers may have added duplicate addresses, so we need to
// de-duplicate them here.
uniqueAddrs := map[string]struct{}{}
dedupedAddrs := make([]net.Addr, 0)
for _, addr := range newNodeAnn.Addresses {
if _, ok := uniqueAddrs[addr.String()]; !ok {
uniqueAddrs[addr.String()] = struct{}{}
dedupedAddrs = append(dedupedAddrs, addr)
}
}
newNodeAnn.Addresses = dedupedAddrs
// Sign a new update after applying all of the passed modifiers.
err := netann.SignNodeAnnouncement(
s.nodeSigner, s.identityKeyLoc, &newNodeAnn,
@ -5485,6 +5517,20 @@ func (s *server) AttemptRBFCloseUpdate(ctx context.Context,
return updates, nil
}
// calculateNodeAnnouncementTimestamp returns the timestamp to use for a node
// announcement, ensuring it's at least one second after the previously
// persisted timestamp. This ensures BOLT-07 compliance, which requires node
// announcements to have strictly increasing timestamps.
func calculateNodeAnnouncementTimestamp(persistedTime,
currentTime time.Time) time.Time {
if persistedTime.Unix() >= currentTime.Unix() {
return persistedTime.Add(time.Second)
}
return currentTime
}
// setSelfNode configures and sets the server's self node. It sets the node
// announcement, signs it, and updates the source node in the graph. When
// determining values such as color and alias, the method prioritizes values
@ -5552,9 +5598,9 @@ func (s *server) setSelfNode(ctx context.Context, nodePub route.Vertex,
// If we have a source node persisted in the DB already, then we
// just need to make sure that the new LastUpdate time is at
// least one second after the last update time.
if srcNode.LastUpdate.Second() >= nodeLastUpdate.Second() {
nodeLastUpdate = srcNode.LastUpdate.Add(time.Second)
}
nodeLastUpdate = calculateNodeAnnouncementTimestamp(
srcNode.LastUpdate, nodeLastUpdate,
)
// If the color is not changed from default, it means that we
// didn't specify a different color in the config. We'll use the

141
server_test.go Normal file
View file

@ -0,0 +1,141 @@
package lnd
import (
"testing"
"time"
"github.com/stretchr/testify/require"
)
// TestNodeAnnouncementTimestampComparison tests the timestamp comparison
// logic used in setSelfNode to ensure node announcements have strictly
// increasing timestamps at second precision (as required by BOLT-07 and
// enforced by the database storage).
func TestNodeAnnouncementTimestampComparison(t *testing.T) {
t.Parallel()
// Use a simple base time for the tests.
baseTime := int64(1000)
tests := []struct {
name string
srcNodeLastUpdate time.Time
nodeLastUpdate time.Time
expectedResult time.Time
description string
}{
{
name: "same second different nanoseconds",
srcNodeLastUpdate: time.Unix(baseTime, 0),
nodeLastUpdate: time.Unix(baseTime, 500_000_000),
expectedResult: time.Unix(baseTime+1, 0),
description: "Edge case: timestamps in same second " +
"but different nanoseconds. Must increment " +
"to avoid persisting same second-level " +
"timestamp.",
},
{
name: "different seconds",
srcNodeLastUpdate: time.Unix(baseTime, 0),
nodeLastUpdate: time.Unix(baseTime+2, 0),
expectedResult: time.Unix(baseTime+2, 0),
description: "Normal case: current time is already " +
"in a different (later) second. No increment " +
"needed.",
},
{
name: "exactly equal",
srcNodeLastUpdate: time.Unix(baseTime, 123456789),
nodeLastUpdate: time.Unix(baseTime, 123456789),
expectedResult: time.Unix(baseTime+1, 123456789),
description: "Timestamps are identical. Must " +
"increment to ensure strictly greater " +
"timestamp.",
},
{
name: "exactly equal - zero nanoseconds",
srcNodeLastUpdate: time.Unix(baseTime, 0),
nodeLastUpdate: time.Unix(baseTime, 0),
expectedResult: time.Unix(baseTime+1, 0),
description: "Timestamps are identical at second " +
"precision (0 nanoseconds), as would be read " +
"from DB. Must increment.",
},
{
name: "clock skew - persisted is newer",
srcNodeLastUpdate: time.Unix(baseTime+5, 0),
nodeLastUpdate: time.Unix(baseTime+3, 0),
expectedResult: time.Unix(baseTime+6, 0),
description: "Clock went backwards: persisted " +
"timestamp is newer than current time. Must " +
"increment from persisted timestamp.",
},
{
name: "clock skew - same second",
srcNodeLastUpdate: time.Unix(baseTime+5, 100_000_000),
nodeLastUpdate: time.Unix(baseTime+5, 900_000_000),
expectedResult: time.Unix(baseTime+6, 100_000_000),
description: "Clock skew within same second. Must " +
"increment to ensure strictly greater " +
"second-level timestamp.",
},
{
name: "same second component different " +
"minute",
srcNodeLastUpdate: time.Unix(baseTime, 0),
nodeLastUpdate: time.Unix(baseTime+60, 0),
expectedResult: time.Unix(baseTime+60, 0),
description: "Same seconds component (:00) but " +
"different minutes. Current time is later. " +
"Verifies we use .Unix() not .Second().",
},
{
name: "lower second component but " +
"later time",
srcNodeLastUpdate: time.Unix(baseTime+58, 0),
nodeLastUpdate: time.Unix(baseTime+63, 0),
expectedResult: time.Unix(baseTime+63, 0),
description: "Persisted has second=58, current has " +
"second=3 (next minute). Current is later " +
"overall. Verifies .Unix() not .Second().",
},
{
name: "higher second component but " +
"earlier time",
srcNodeLastUpdate: time.Unix(baseTime+63, 0),
nodeLastUpdate: time.Unix(baseTime+58, 0),
expectedResult: time.Unix(baseTime+64, 0),
description: "Persisted has second=3 (next minute), " +
"current has second=58. Persisted is later " +
"overall. Verifies .Unix() not .Second().",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
result := calculateNodeAnnouncementTimestamp(
tc.srcNodeLastUpdate,
tc.nodeLastUpdate,
)
// Verify we got the expected result.
require.Equal(
t, tc.expectedResult, result,
"Unexpected result: %s", tc.description,
)
// Verify result is strictly greater than persisted
// timestamp. This is an additional check to ensure
// the result is strictly greater than the persisted
// timestamp.
require.Greater(
t, result.Unix(), tc.srcNodeLastUpdate.Unix(),
"Result must be strictly greater than "+
"persisted timestamp: %s",
tc.description,
)
})
}
}

View file

@ -44,11 +44,13 @@ func (p *SqliteConfig) Validate() error {
//
//nolint:ll
type PostgresConfig struct {
Dsn string `long:"dsn" description:"Database connection string."`
Timeout time.Duration `long:"timeout" description:"Database connection timeout. Set to zero to disable."`
MaxConnections int `long:"maxconnections" description:"The maximum number of open connections to the database. Set to zero for unlimited."`
SkipMigrations bool `long:"skipmigrations" description:"Skip applying migrations on startup."`
QueryConfig `group:"query" namespace:"query"`
Dsn string `long:"dsn" description:"Database connection string."`
Timeout time.Duration `long:"timeout" description:"Database connection timeout. Set to zero to disable."`
MaxConnections int `long:"maxconnections" description:"The maximum number of open connections to the database. Set to zero for unlimited."`
SkipMigrations bool `long:"skipmigrations" description:"Skip applying migrations on startup."`
ChannelDBWithGlobalLock bool `long:"channeldb-with-global-lock" description:"Use a global lock for channeldb access. This ensures only a single writer at a time but reduces concurrency. This is a temporary workaround until the revocation log is migrated to a native sql schema."`
WalletDBWithGlobalLock bool `long:"walletdb-with-global-lock" description:"Use a global lock for wallet database access. This ensures only a single writer at a time but reduces concurrency. This is a temporary workaround until the wallet subsystem is upgraded to a native sql schema."`
QueryConfig `group:"query" namespace:"query"`
}
// Validate checks that the PostgresConfig values are valid.

Some files were not shown because too many files have changed in this diff Show more