What changed, and why it matters
This commit fixes a bug in how the Lightning Network Daemon (LND) copies payment channel data. When the program made a copy of an HTLC (a pending payment in a Lightning channel), it failed to copy several important fields and did not properly duplicate variable-length data like signatures, extra data, and custom records. This could cause copies or snapshots of channel state to be incomplete or accidentally share mutable data. The fix deep-copies all relevant fields and adds a test to verify the copy is fully independent.
Treat this as a correctness and potential state-integrity bug. Review all call sites of HTLC.Copy() to determine whether incomplete copies could have led to incorrect commitment signatures, stale state, or protocol violations. Include this fix in the next maintenance release and run the new regression test in CI.
Security signals we found
Incomplete deep copy of security-relevant channel state
Missing fields in HTLC clone (RHash, OnionBlob, HtlcIndex, LogIndex)
Nil-slice copy bug for Signature and ExtraData
Shallow copy of CustomRecords map sharing mutable byte slices
Channel state snapshot integrity risk
Evidence from the diff
The HTLC.Copy() method in chanstate/commitment.go previously initialized a new HTLC with only a subset of scalar fields and then used copy() into nil slices for Signature and ExtraData, which had no effect. It also omitted RHash, OnionBlob, HtlcIndex, and LogIndex from the literal copy, and shallow-copied CustomRecords. The patch allocates new slices for Signature and ExtraData, copies those bytes, copies fixed arrays for RHash and OnionBlob by assignment, includes HtlcIndex and LogIndex, and deep-copies each CustomRecords entry into newly allocated byte slices. A new unit test verifies that mutating the clone does not affect the original.
Changed components
chanstate/commitment.goHTLC.Copy()channel commitment state snapshots/copiesInspect captured patch +88 / −4
diff --git a/chanstate/commitment.go b/chanstate/commitment.go
index db8d102..42ef1f2 100644
--- a/chanstate/commitment.go
+++ b/chanstate/commitment.go
@@ -207,12 +207,29 @@ func (h *HTLC) Copy() HTLC {
Amt: h.Amt,
RefundTimeout: h.RefundTimeout,
OutputIndex: h.OutputIndex,
+ RHash: h.RHash,
+ OnionBlob: h.OnionBlob,
+ HtlcIndex: h.HtlcIndex,
+ LogIndex: h.LogIndex,
+ }
+ if len(h.Signature) > 0 {
+ clone.Signature = make([]byte, len(h.Signature))
+ copy(clone.Signature, h.Signature)
+ }
+ if len(h.ExtraData) > 0 {
+ clone.ExtraData = make(lnwire.ExtraOpaqueData, len(h.ExtraData))
+ copy(clone.ExtraData, h.ExtraData)
}
- copy(clone.Signature, h.Signature)
- copy(clone.RHash[:], h.RHash[:])
- copy(clone.ExtraData, h.ExtraData)
clone.BlindingPoint = h.BlindingPoint
- clone.CustomRecords = h.CustomRecords.Copy()
+ if h.CustomRecords != nil {
+ clone.CustomRecords = make(
+ lnwire.CustomRecords, len(h.CustomRecords),
+ )
+ for k, v := range h.CustomRecords {
+ clone.CustomRecords[k] = make([]byte, len(v))
+ copy(clone.CustomRecords[k], v)
+ }
+ }
return clone
}
diff --git a/chanstate/commitment_test.go b/chanstate/commitment_test.go
new file mode 100644
index 0000000..de2b9ef
--- /dev/null
+++ b/chanstate/commitment_test.go
@@ -0,0 +1,67 @@
+package chanstate
+
+import (
+ "bytes"
+ "testing"
+
+ "github.com/btcsuite/btcd/btcec/v2"
+ "github.com/lightningnetwork/lnd/lnwire"
+ "github.com/lightningnetwork/lnd/tlv"
+ "github.com/stretchr/testify/require"
+)
+
+// TestHTLCCopy asserts that copying an HTLC produces an independent deep copy.
+func TestHTLCCopy(t *testing.T) {
+ t.Parallel()
+
+ _, blindingPoint := btcec.PrivKeyFromBytes(bytes.Repeat([]byte{1}, 32))
+
+ var rHash [32]byte
+ copy(rHash[:], bytes.Repeat([]byte{2}, len(rHash)))
+
+ var onionBlob [lnwire.OnionPacketSize]byte
+ copy(onionBlob[:], bytes.Repeat([]byte{3}, len(onionBlob)))
+
+ htlc := HTLC{
+ Signature: []byte{4, 5, 6},
+ RHash: rHash,
+ Amt: 1000,
+ RefundTimeout: 144,
+ OutputIndex: 3,
+ Incoming: true,
+ OnionBlob: onionBlob,
+ HtlcIndex: 42,
+ LogIndex: 43,
+ ExtraData: lnwire.ExtraOpaqueData{7, 8, 9},
+ BlindingPoint: tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[lnwire.BlindingPointTlvType](
+ blindingPoint,
+ ),
+ ),
+ CustomRecords: lnwire.CustomRecords{
+ lnwire.MinCustomRecordsTlvType: []byte{10, 11, 12},
+ },
+ }
+
+ clone := htlc.Copy()
+ require.Equal(t, htlc, clone)
+
+ clone.Signature[0] = 0
+ require.Equal(t, byte(4), htlc.Signature[0])
+
+ clone.ExtraData[0] = 0
+ require.Equal(t, byte(7), htlc.ExtraData[0])
+
+ clone.CustomRecords[lnwire.MinCustomRecordsTlvType] = []byte{0}
+ require.Equal(
+ t, []byte{10, 11, 12},
+ htlc.CustomRecords[lnwire.MinCustomRecordsTlvType],
+ )
+
+ clone = htlc.Copy()
+ clone.CustomRecords[lnwire.MinCustomRecordsTlvType][0] = 0
+ require.Equal(
+ t, []byte{10, 11, 12},
+ htlc.CustomRecords[lnwire.MinCustomRecordsTlvType],
+ )
+}
Why this scored 46/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.