The release workflow runs from pushed version tags, but the build step
was setting SKIP_VERSION_CHECK=1. That made scripts/release.sh exit
before running check-tag, so CI did not compare the pushed tag with
the version reported from build/version.go.
Run the normal release target instead. This keeps release CI from
producing artifacts when the tag and embedded lnd version drift apart.
(cherry picked from commit e1f03b424f)
Adds a script that creates a signed annotated release tag only after
verifying:
1. The requested tag name matches the version constants committed in
HEAD:build/version.go. Catches the failure mode where a release
branch is tagged before the version bump has been committed, which
would otherwise leave the tagged commit reporting an old version
string at runtime.
2. The local HEAD is identical to the upstream lightningnetwork/lnd
view of the release branch. A release tag must never point at a
commit that has not been merged upstream yet.
The upstream remote is discovered by URL rather than by name, since
"origin" is conventionally the fork in a "gh repo fork" workflow. The
branch defaults to whichever one is currently checked out (typically a
release branch such as v0.21.x-branch) and can be overridden with
--branch.
The script deliberately does not push the tag or auto-bump version.go;
both remain explicit human steps.
(cherry picked from commit 5320aa0e36)
The v0.20.2-beta tag was cut on 2026-07-07 and published as a release
the following day, but the seven backports that landed on the release
branch afterwards all appended their entries to
release-notes-0.20.2.md, since no notes file existed for the next
patch release. The published 0.20.2 notes therefore advertised fixes
that are not in the 0.20.2 binaries.
Create release-notes-0.20.3.md and move those entries into it,
restoring release-notes-0.20.2.md to its content at the v0.20.2-beta
tag. The entry prose is carried over verbatim and matches the wording
already used for the same backports in release-notes-0.21.2.md on the
v0.21.x branch.
In this commit, we hold off on recording the remote party's close output until
we've decided we can act on their Shutdown. ReceiveShutdown wrote the field
before it looked at the state, so a Shutdown that arrives at a point where we
have nothing to do with it, say once we've already finished the negotiation,
would still overwrite the output we settled on before being turned away with
ErrInvalidState. The output we report for the close then describes a message we
rejected.
Nothing acts on this today, as we hand the outputs to the caller only after
ClosingTx tells it the negotiation finished, but the field is what we report to
the party that asked for the close, so we may as well only fill it in from a
message we accepted.
In this commit, we give the legacy ChanCloser a single owner, rather than
letting two goroutines advance it. The peer's channelManager drives the state
machine for the Shutdown and ClosingSigned messages that come off the wire, and
for local close requests. The link drives it as well: while we wait for the
channel to drain we register a flush hook, and the link invokes that hook from
its own goroutine, where it called BeginNegotiation directly. Nothing kept the
two apart, so the state field, the priorFeeOffers map, and the signing step
could all be touched at once. Under `go test -race` this shows up as a data race
on the state field.
Rather than reach for a lock, we route the flush through the channelManager. The
hook now only reports the channel ID over a new chanCloseFlushed channel, and
handleChanFlushed picks it up next to the close messages. Every transition, the
cached offer processing, the fee map, and the signing then happen on the one
goroutine, so the closer needs no synchronization of its own. We spell that out
on the type, since it's an invariant a new caller can break from the outside.
The report goes out from a fresh goroutine, which matters more than it looks.
The link may well be holding its own lock while it invokes the hook, and
channelManager reaches for that same lock in DisableAdds, so blocking on the
handoff would trade the race for a deadlock. The `go` in front of RemoveLink
just above it is there for the same reason.
We look the closer up with a plain map load rather than through
fetchActiveChanCloser, as that one builds a fresh closer when it doesn't find
an existing one, and a flush that lands after the negotiation was torn down has
no business starting a new negotiation.
One behavior change falls out of the move: the flush path now runs the same
finalization tail as the message path. It skipped that before, so a responder
that drained a cached offer would reach closeFinished and broadcast, but nothing
ran finalizeChanClosure until the next close message showed up, and having
already sent its final signature, there may not be one. The link == nil path
already ran the tail, so this makes all three paths agree.
The new test drives a close with a link that hands us the flush hook instead of
running it inline, so we can check that negotiation waits on the report, and
that a report for a channel we have no closer for is dropped.
In this commit, we make the RBF co-op closer validate the remote party's
delivery script in all cases, matching what the negotiation closer already does.
Previously we only ran the check when we had an upfront shutdown script on record
for the peer, so a peer that never committed to an upfront script could hand us a
delivery script that we'd stash and carry through the rest of the close flow
without ever looking at it.
We now always call validateShutdownScript with the (possibly nil) upfront
script: a nil upfront script still runs the well-formedness check on the peer's
script, and a non-nil one additionally enforces the exact match, same as before.
We also require the script to be present. The wire format puts no lower bound on
the address length, and validateShutdownScript treats an absent peer script as
nothing to check, so an empty one passed validation by default rather than on its
merits. Both entry points now go through one helper that insists on a script
before running the usual checks over it, which also covers a CloserScript
swapped in mid-negotiation via ClosingComplete rather than letting that one go
unchecked.
The delivery-form coverage is spelled out in the tests: the spec dropped p2pkh
and p2sh for co-op closes to keep the dust calculations uniform, and we don't
implement the OP_RETURN form that option_simple_close allows, so all of those are
rejected along with an empty or malformed script.
In this commit, we have DustLimitForSize fall back to the generic witness dust
threshold for any script size that doesn't match one of the well-known
templates.
The size switch covered P2WPKH, P2WSH, P2SH, P2PKH, and the explicit
unknown-witness size, and treated every other length as unreachable. That's a
narrower assumption than the callers can actually make good on: a witness
program for versions 1 through 16 carries a program of anywhere from 2 to 40
bytes, so its serialized length won't always land on one of those exact values.
The dust calculation only needs a representative output of roughly the right
shape, and the unknown-witness pricing is the conservative choice among the
ones we have, so we make it the default. That leaves the helper well defined
across the whole range of sizes callers can pass it, including scripts carrying
witness versions we don't know about yet.
The interceptor exposes its derived auto-fail height as an int32. Calculate
the height in int64 and fail forwards whose deadline cannot be represented
with expiry_too_far.
Add coverage for the range check and subsequent forward handling.
(cherry picked from commit ae3f4aff48)
Forward the supplied incoming HTLC expiry from the outgoing contest resolver to its embedded timeout resolver. This keeps the deadline available when resolution transitions after the outgoing HTLC expires.
(cherry picked from commit f77606851f)
In this commit, we cap each QueryChannelRange response at 100,000 SCIDs
across all streamed replies. The existing reply-count limit did not track
the aggregate decoded working set, so memory use varied with the encoding
and composition of the reply stream.
We count raw SCIDs before timestamp filtering, charge replies using the
received encoding type, and release all accumulated range state on any
error. This bounds both memory and CPU work while still leaving headroom
above the current graph.
(cherry picked from commit ceff94fadd)
In this commit, we cap each decompressed short channel ID set at 100,000
entries, matching the aggregate range reply budget. The old zlib reader
bounded compressed input rather than decoded output, so the two working-set
limits could drift apart.
We retain compatibility with protocol-valid compressed replies, reject
truncated or corrupt zlib streams, and close the reader on every exit.
Boundary, compatibility, corruption, and property tests cover the
decoder.
(cherry picked from commit d162291941)
Add integration tests for an lnd introduction node forwarding a blinded
payment whose non-final hops identify the next hop by node ID (next_node_id)
rather than a short channel ID, as produced by other implementations:
- testBlindedRouteNextNodeID: the outgoing channel is public.
- testBlindedRouteNextNodeIDPrivateChannel: the outgoing channel is
private, so the node ID resolves to an SCID alias.
- testBlindedRouteNextNodeIDRestart: the introduction node is restarted
while the HTLC is in flight, exercising forwarding-package replay and
re-decode of the node-ID blinded hop.
(cherry picked from commit da6a40c01d)
Extend the on-chain interceptor path in the witness beacon to expose a
node-ID next hop, mirroring the off-chain path. A node-ID next hop has no
outgoing channel of its own, so the beacon reports hop.Exit as the outgoing
channel (via ForwardingInfo.NextHopChannel().UnwrapOr) and the requested
next node's public key. The RPC boundary maps that to the NodeIDForwardSCID
sentinel so the forward is not misread as a final receive.
This is the requested next hop, not the channel eventually selected by
non-strict forwarding, so the beacon deliberately does not resolve it
against the circuit map.
(cherry picked from commit 9c4b8bfec2)
When the switch forwards a blinded hop identified by node ID, it has not
yet resolved a concrete outgoing channel at interception time. Expose the
next hop to the interceptor: InterceptedForward.Packet() reports the
packet's outgoing channel as-is (hop.Exit, since none is selected yet) and
carries the requested pubkey in OutgoingNodeID.
At the RPC boundary, forwardInterceptor.onIntercept maps a node-ID hop to
the reserved NodeIDForwardSCID sentinel in outgoing_requested_chan_id and
the pubkey in outgoing_requested_node_id, so a client switching on a zero
channel ID to detect the exit hop does not misread the forward as a final
receive. The sentinel is a wire-only concern, applied where the request is
built rather than in the switch's internal InterceptedPacket, which stays
truthful (OutgoingNodeID.IsSome() is the node-ID discriminator).
(cherry picked from commit 32373b76c7)
Now that the switch forwards blinded hops identified by node ID, a new
problem surfaces in the HTLC event stream. A node-ID next hop has no
outgoing short channel ID until non-strict forwarding selects one, so a
forward that fails before selection still carries outgoingChanID ==
hop.Exit. getEventType keys the exit hop off that sentinel, so it
misclassifies such a failed node-ID forward as a receive, mislabeling the
event streamed via SubscribeHtlcEvents (a forwarding failure reported as
a receive failure).
Two paths reach getEventType before an SCID is selected: the fail packet
built by failAddPacket and the resolution packet built by resolve, both
of which dropped the decoded next hop. Carry outgoingHop into both, and
classify a Right (node-ID) outgoingHop as a forward before the hop.Exit
check. A node-ID next hop is always a forward, never the exit hop.
(cherry picked from commit a4844ef522)
Fixeslightningnetwork/lnd#10937: forward a blinded-route payment when the
recipient identifies the next hop by node ID rather than a short channel
ID. The htlcPacket carries the decoded next hop to the switch, whose
handlePacketAdd resolves the pubkey to the peer's links via getLinks() and
lets the existing non-strict forwarding logic load-balance across the
peer's channels.
outgoingChanID stays a ShortChannelID. It is the persisted CircuitKey and
is set to the selected channel after non-strict selection. The circular
route check filters candidate channels before selection.
(cherry picked from commit dbc5704070)
Some implementations (e.g. Core Lightning) identify the next hop in a
blinded route by the next node's ID (next_node_id) instead of a short
channel ID. Decode such a hop into a node-ID next hop, the Right of
ForwardingInfo.NextHop, holding the next node's public key. The switch
resolves that key to one of our channels with the peer in a later commit.
BOLT 4 requires a non-final blinded hop to carry exactly one of
short_channel_id or next_node_id, so a hop that sets both is rejected.
(cherry picked from commit 4fd4289a08)
The forwarding next hop is currently always a short channel ID. To allow a
blinded route to identify the next hop by node ID instead, change
ForwardingInfo.NextHop to fn.Either[lnwire.ShortChannelID, [33]byte], where
the Left is the outgoing channel ID and the Right (wired up in a follow-up
commit) is the next node's public key.
This commit is a pure representational change with no behavioural effect:
every next hop is still a channel ID. The Either is encapsulated behind
ForwardingInfo methods so callers never destructure it directly: IsExit()
is the single source of truth for exit-hop detection (used by the link and
the contract court) and NextHopChannel() yields the outgoing SCID.
(cherry picked from commit d28a71765b)
A blinded route may identify the next hop by node ID (next_node_id) rather
than by channel, in which case there is no sender-specified outgoing channel
to report to an HTLC interceptor. Add an outgoing_node_id field to
ForwardHtlcInterceptRequest to carry the next hop's public key for these
forwards, and document that outgoing_requested_chan_id then holds a reserved
sentinel value so that clients switching on a zero channel ID to detect the
exit hop do not misclassify the forward as a final receive.
This commit only adds the schema and regenerated stubs; the fields are
populated by later commits.
(cherry picked from commit 14640a5016)
During non-strict forwarding, handlePacketAdd evaluates every candidate
channel to the next peer and calls CheckHtlcForward with the sender-requested
outgoing SCID (originalOutgoingChanID) for each candidate. That SCID flowed
through canSendHtlc into AuxTrafficShaper.ShouldHandleTraffic, so a
channel-keyed shaper was asked about the requested channel rather than the
candidate actually being evaluated. With parallel channels to a peer this
inspects the wrong channel.
Key the shaper on l.ShortChanID() (the channel under evaluation) instead.
originalScid is retained solely for createFailureWithUpdate / FailAliasUpdate,
so the alias-aware channel_update returned to the sender is unchanged and the
real SCID handed to the shaper never leaks onto the wire.
(cherry picked from commit b166780015)