What changed, and why it matters
This change fixes how the Lightning Network Daemon (LND) decides which pending payments (HTLCs) are considered 'active' in a payment channel. Previously, it matched HTLCs between the two sides of a channel by hashing the onion routing blob, which is not a unique identifier and can be duplicated by accident or on purpose. Now it matches by the HTLC's channel-level index and direction, which is a reliable identifier. The old behavior could have caused LND to misidentify active HTLCs when onion blobs were duplicated, potentially affecting channel state decisions.
Review downstream consumers of ActiveHtlcs to confirm they rely on the corrected identity semantics, and ensure the fix is included in any release branch. Consider whether the prior behavior could have led to incorrect HTLC inclusion/exclusion in channel state and whether additional hardening (e.g., invariant checks) is warranted.
Security signals we found
Use of non-unique routing payload (onion blob) as a matching key for channel state
Potential for duplicate onion blobs to cause ActiveHtlcs to misidentify HTLCs locked on both commitments
Fix aligns HTLC matching with channel-level identity (HtlcIndex + direction)
Test added to lock in correct matching behavior
Evidence from the diff
The ActiveHtlcs method in chanstate/open_channel.go previously built a map of remote commitment HTLCs keyed by SHA-256 of the OnionBlob, then matched local commitment HTLCs against that map. Because OnionBlob is routing payload data and can be duplicated across distinct HTLCs, this key was not a reliable identity. The patch replaces the onion hash key with an htlcKey struct composed of HtlcIndex and Incoming direction, which uniquely identifies an offered HTLC within channel state. A unit test is added demonstrating that two HTLCs with identical onion blobs but different HtlcIndex values are no longer conflated, and that direction is part of the identity.
Changed components
chanstate/open_channel.goOpenChannel.ActiveHtlcs()chanstate/open_channel_test.goInspect captured patch +83 / −7
diff --git a/chanstate/open_channel.go b/chanstate/open_channel.go
index 2b96a07..2011ebf 100644
--- a/chanstate/open_channel.go
+++ b/chanstate/open_channel.go
@@ -1,7 +1,6 @@
package chanstate
import (
- "crypto/sha256"
"errors"
"fmt"
"net"
@@ -822,17 +821,33 @@ func (c *OpenChannel) ActiveHtlcs() []HTLC {
c.RLock()
defer c.RUnlock()
+ // htlcKey uniquely identifies an HTLC within the channel state by its
+ // channel-level HTLC index and the direction of the offer. This is used
+ // to match the same HTLC across the local and remote commitment
+ // snapshots.
+ type htlcKey struct {
+ index uint64
+ incoming bool
+ }
+
// We'll only return HTLC's that are locked into *both* commitment
// transactions. So we'll iterate through their set of HTLC's to note
// which ones are present on their commitment.
- remoteHtlcs := make(map[[32]byte]struct{})
+ //
+ // HTLC identity is defined by the channel-level HTLC index plus the
+ // direction of the offer. The onion blob is routing payload data and
+ // can be duplicated by buggy or malicious senders, so it is not a
+ // robust key for matching the same HTLC across commitment snapshots.
+ remoteHtlcs := make(map[htlcKey]struct{})
for _, htlc := range c.RemoteCommitment.Htlcs {
log.Tracef("RemoteCommitment has htlc: id=%v, update=%v "+
"incoming=%v", htlc.HtlcIndex, htlc.LogIndex,
htlc.Incoming)
- onionHash := sha256.Sum256(htlc.OnionBlob[:])
- remoteHtlcs[onionHash] = struct{}{}
+ remoteHtlcs[htlcKey{
+ index: htlc.HtlcIndex,
+ incoming: htlc.Incoming,
+ }] = struct{}{}
}
// Now that we know which HTLC's they have, we'll only mark the HTLC's
@@ -843,9 +858,12 @@ func (c *OpenChannel) ActiveHtlcs() []HTLC {
"incoming=%v", htlc.HtlcIndex, htlc.LogIndex,
htlc.Incoming)
- onionHash := sha256.Sum256(htlc.OnionBlob[:])
- if _, ok := remoteHtlcs[onionHash]; !ok {
- log.Tracef("Skipped htlc due to onion mismatched: "+
+ _, ok := remoteHtlcs[htlcKey{
+ index: htlc.HtlcIndex,
+ incoming: htlc.Incoming,
+ }]
+ if !ok {
+ log.Tracef("Skipped htlc due to identity mismatch: "+
"id=%v, update=%v incoming=%v",
htlc.HtlcIndex, htlc.LogIndex, htlc.Incoming)
diff --git a/chanstate/open_channel_test.go b/chanstate/open_channel_test.go
new file mode 100644
index 0000000..ace3e2c
--- /dev/null
+++ b/chanstate/open_channel_test.go
@@ -0,0 +1,58 @@
+package chanstate
+
+import (
+ "testing"
+
+ "github.com/lightningnetwork/lnd/lnwire"
+ "github.com/stretchr/testify/require"
+)
+
+// TestActiveHtlcsMatchesByHTLCIdentity asserts that ActiveHtlcs matches HTLCs
+// by their channel identity, not by their onion blob. Onion blobs are routing
+// payload data and can be duplicated, while the HTLC index plus direction
+// identifies an offered HTLC within the channel state.
+func TestActiveHtlcsMatchesByHTLCIdentity(t *testing.T) {
+ t.Parallel()
+
+ var onionBlob [lnwire.OnionPacketSize]byte
+ onionBlob[0] = 1
+
+ matchingHTLC := HTLC{
+ HtlcIndex: 7,
+ LogIndex: 10,
+ Incoming: false,
+ OnionBlob: onionBlob,
+ }
+ duplicateOnionHTLC := HTLC{
+ HtlcIndex: 8,
+ LogIndex: 11,
+ Incoming: false,
+ OnionBlob: onionBlob,
+ }
+ oppositeDirectionHTLC := HTLC{
+ HtlcIndex: 7,
+ LogIndex: 12,
+ Incoming: true,
+ OnionBlob: onionBlob,
+ }
+
+ channel := &OpenChannel{
+ LocalCommitment: ChannelCommitment{
+ Htlcs: []HTLC{
+ matchingHTLC,
+ duplicateOnionHTLC,
+ oppositeDirectionHTLC,
+ },
+ },
+ RemoteCommitment: ChannelCommitment{
+ Htlcs: []HTLC{
+ matchingHTLC,
+ },
+ },
+ }
+
+ activeHtlcs := channel.ActiveHtlcs()
+ require.Len(t, activeHtlcs, 1)
+ require.Equal(t, matchingHTLC.HtlcIndex, activeHtlcs[0].HtlcIndex)
+ require.Equal(t, matchingHTLC.Incoming, activeHtlcs[0].Incoming)
+}
Why this scored 61/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.