From a4a3279d6f2f09db254c6339cc913e84c477e7d5 Mon Sep 17 00:00:00 2001 From: MPins Date: Thu, 12 Mar 2026 07:01:03 -0300 Subject: [PATCH 01/10] doc: create a diagram representing the link machine state --- htlcswitch/htlc_commitment_state_machine.md | 43 +++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 htlcswitch/htlc_commitment_state_machine.md diff --git a/htlcswitch/htlc_commitment_state_machine.md b/htlcswitch/htlc_commitment_state_machine.md new file mode 100644 index 00000000000..ef009f8794d --- /dev/null +++ b/htlcswitch/htlc_commitment_state_machine.md @@ -0,0 +1,43 @@ +## States and Transitions +```mermaid +--- +title: Channel Link State Machine +--- + +stateDiagram-v2 + + [*] --> Clean + + Clean --> Pending : receive update_* (processRemoteUpdate*) + Pending --> Pending : more update_* + + Pending --> TrySendCommitSig : BatchTicker / OweCommitment + TrySendCommitSig --> WaitingRevoke : SignNextCommitment ok + send CommitSig + TrySendCommitSig --> WindowExhausted : SignNextCommitment = ErrNoWindow + + WaitingRevoke --> Pending : receive RevokeAndAck (ReceiveRevocation) + WaitingRevoke --> Clean : receive RevokeAndAck and channel clean + + Pending --> RecvCommitSig : receive CommitSig (processRemoteCommitSig) + RecvCommitSig --> SendRevoke : ReceiveNewCommitment ok + SendRevoke --> Pending : RevokeCurrentCommitment + send RevokeAndAck + + Pending --> TrySendCommitSig : after RevokeAndAck/RecvRevoke if OweCommitment + + Clean --> Quiescent : STFU + Quiescent --> Clean : resume + + state Failed <> + Pending --> Failed : invalid sig/revocation / timeout + WaitingRevoke --> Failed : PendingCommitTicker timeout + +``` + +## Legend + +| Term | Meaning | +|------|---------| +| `OweCommitment` | Boolean flag set on the link when there are pending local updates that have not yet been covered by a `CommitSig`. Triggers sending the next commitment signature after a `RevokeAndAck` is received or when the batch ticker fires. | +| `WindowExhausted` | `SignNextCommitment` returned `ErrNoWindow`, meaning the in-flight HTLC limit was reached. The link waits for a `RevokeAndAck` to free a slot before retrying. | +| `BatchTicker` | Periodic timer that coalesces multiple downstream updates into a single `CommitSig` round. Replaced by `noopTicker` in fuzz/test harnesses. | +| `PendingCommitTicker` | Watchdog timer that fires if a `RevokeAndAck` is not received within the allowed window, transitioning the link to `Failed`. | From aa3d301470473be29b85776aaf2015b61e67f218 Mon Sep 17 00:00:00 2001 From: MPins Date: Thu, 12 Mar 2026 07:10:19 -0300 Subject: [PATCH 02/10] htlcswitch: expose invoiceRegistry and add generateSingleHopHtlc Expose the `invoiceRegistry` field in `singleLinkTestHarness` so tests can register and look up invoices directly. Add `generateSingleHopHtlc`, a test helper that builds a single-hop `UpdateAddHTLC` with a random preimage, intended for use in unit and fuzz tests. --- htlcswitch/link_test.go | 43 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/htlcswitch/link_test.go b/htlcswitch/link_test.go index 29b4f902d0b..44a81ca390a 100644 --- a/htlcswitch/link_test.go +++ b/htlcswitch/link_test.go @@ -2135,6 +2135,7 @@ type singleLinkTestHarness struct { aliceBatchTicker chan time.Time start func() error aliceRestore func() (*lnwallet.LightningChannel, error) + invoiceRegistry *mockInvoiceRegistry } func newSingleLinkTestHarness(t *testing.T, chanAmt, @@ -2277,6 +2278,7 @@ func newSingleLinkTestHarness(t *testing.T, chanAmt, aliceBatchTicker: bticker.Force, start: start, aliceRestore: aliceLc.restore, + invoiceRegistry: invoiceRegistry, } return harness, nil @@ -5012,6 +5014,47 @@ func generateHtlcAndInvoice(t *testing.T, return htlc, invoice } +// generateSingleHopHtlc generate a single hop htlc to send to the receiver. +func generateSingleHopHtlc(t *testing.T, id uint64, + htlcAmt lnwire.MilliSatoshi) (*lnwire.UpdateAddHTLC, lntypes.Preimage, + error) { + + t.Helper() + + htlcExpiry := testStartingHeight + testInvoiceCltvExpiry + hops := []*hop.Payload{ + hop.NewLegacyPayload(&sphinx.HopData{ + Realm: [1]byte{}, // hop.BitcoinNetwork + NextAddress: [8]byte{}, // hop.Exit, + ForwardAmount: uint64(htlcAmt), + OutgoingCltv: uint32(htlcExpiry), + }), + } + blob, err := generateRoute(hops...) + if err != nil { + return nil, lntypes.Preimage{}, err + } + + var preimage lntypes.Preimage + r, err := generateRandomBytes(sha256.Size) + if err != nil { + return nil, preimage, err + } + copy(preimage[:], r) + + rhash := sha256.Sum256(preimage[:]) + + htlc := &lnwire.UpdateAddHTLC{ + ID: id, + PaymentHash: rhash, + Amount: htlcAmt, + Expiry: uint32(htlcExpiry), + OnionBlob: blob, + } + + return htlc, preimage, nil +} + // TestChannelLinkNoMoreUpdates tests that we won't send a new commitment // when there are no new updates to sign. func TestChannelLinkNoMoreUpdates(t *testing.T) { From ee70f807b17f63c0d3e6ccceb3b5ed23b13c62e1 Mon Sep 17 00:00:00 2001 From: MPins Date: Fri, 27 Mar 2026 13:15:56 -0300 Subject: [PATCH 03/10] htlcswitch: add mockMailBox and noopTicker test helpers Add a no-op MailBox implementation and a no-op ticker for use in the channelLink FSM fuzz harness. --- htlcswitch/mock.go | 78 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/htlcswitch/mock.go b/htlcswitch/mock.go index 70bd73c37d2..53d2743db11 100644 --- a/htlcswitch/mock.go +++ b/htlcswitch/mock.go @@ -1179,3 +1179,81 @@ func (h *mockHTLCNotifier) NotifyFinalHtlcEvent(key models.CircuitKey, info channeldb.FinalHtlcInfo) { } + +// mockMailBox is a no-op mailbox for testing. +type mockMailBox struct{} + +// Compile-time assertion that mockMailBox implements MailBox. +var _ MailBox = (*mockMailBox)(nil) + +func (m *mockMailBox) AddMessage(msg lnwire.Message) error { + return nil +} + +func (m *mockMailBox) AddPacket(packet *htlcPacket) error { + return nil +} + +func (m *mockMailBox) HasPacket(CircuitKey) bool { + return false +} + +func (m *mockMailBox) AckPacket(CircuitKey) bool { + return false +} + +func (m *mockMailBox) FailAdd(packet *htlcPacket) { + +} + +func (m *mockMailBox) MessageOutBox() chan lnwire.Message { + return make(chan lnwire.Message) +} + +func (m *mockMailBox) PacketOutBox() chan *htlcPacket { + return make(chan *htlcPacket) +} + +func (m *mockMailBox) ResetMessages() error { + return nil +} + +func (m *mockMailBox) ResetPackets() error { + return nil +} + +func (m *mockMailBox) SetDustClosure(isDust dustClosure) { + +} + +func (m *mockMailBox) SetFeeRate(feerate chainfee.SatPerKWeight) { + +} + +func (m *mockMailBox) DustPackets() (lnwire.MilliSatoshi, lnwire.MilliSatoshi) { + return 0, 0 +} + +func (m *mockMailBox) Start() { + +} + +func (m *mockMailBox) Stop() { + +} + +type noopTicker struct{} + +func (n *noopTicker) Ticks() <-chan time.Time { + // Returning nil intentionally: a receive on a nil channel blocks + // forever, so the link's timer-driven paths never fire. + return nil +} + +func (n *noopTicker) Stop() {} + +func (n *noopTicker) Pause() {} + +func (n *noopTicker) Resume() {} + +func (n *noopTicker) ForceTick() {} From 9c6814a65a961442e02d8c63dda4c72f02f95eef Mon Sep 17 00:00:00 2001 From: MPins Date: Sun, 29 Mar 2026 16:41:40 -0300 Subject: [PATCH 04/10] htlcswitch: add goroutine-free link factory for fuzz harness Replace createChannelLinkWithPeer (which required a Switch and spawned the htlcManager goroutine) with newFuzzLink, a minimal link factory that: - accepts dependencies directly (registry, preimage cache, circuit map, bestHeight) instead of a mockServer, so no Switch or background goroutines are created at all - sets link.upstream directly to a buffered channel controlled by the caller, bypassing the mailbox entirely - attaches a mockMailBox so mailBox.ResetPackets() in resumeLink succeeds --- htlcswitch/test_utils.go | 101 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/htlcswitch/test_utils.go b/htlcswitch/test_utils.go index 2e084250943..41c411f3002 100644 --- a/htlcswitch/test_utils.go +++ b/htlcswitch/test_utils.go @@ -1202,6 +1202,107 @@ func (h *hopNetwork) createChannelLink(server, peer *mockServer, return link, nil } +// newFuzzLink creates a channelLink for fuzz and deterministic test harnesses +// without starting the htlcManager goroutine and without a Switch. No +// background goroutines are spawned — the caller drives all state transitions +// via direct method calls. The caller must inject the remote ChanSyncMsg into +// the returned upstream channel and then call link.resumeLink to complete +// reestablishment synchronously. +func (h *hopNetwork) newFuzzLink(t testing.TB, + peer lnpeer.Peer, + channel *lnwallet.LightningChannel, + decoder *mockIteratorDecoder, + registry *mockInvoiceRegistry, + pCache *mockPreimageCache, + circuits CircuitMap, + bestHeight func() uint32, +) (*channelLink, chan lnwire.Message) { + + const ( + minFeeUpdateTimeout = 30 * time.Minute + maxFeeUpdateTimeout = 40 * time.Minute + ) + + upstream := make(chan lnwire.Message, 1) + + //nolint:ll + l := NewChannelLink( + ChannelLinkConfig{ + BestHeight: bestHeight, + FwrdingPolicy: h.globalPolicy, + Peer: peer, + Circuits: circuits, + // The fuzz harness only exercises single-hop direct + // payments, so no packet forwarding ever occurs. + ForwardPackets: func(<-chan struct{}, bool, ...*htlcPacket) error { return nil }, + DecodeHopIterators: decoder.DecodeHopIterators, + ExtractErrorEncrypter: func(*btcec.PublicKey) ( + hop.ErrorEncrypter, lnwire.FailCode) { + + return h.obfuscator, lnwire.CodeNone + }, + FetchLastChannelUpdate: mockGetChanUpdateMessage, + Registry: registry, + FeeEstimator: h.feeEstimator, + PreimageCache: pCache, + UpdateContractSignals: func(*contractcourt.ContractSignals) error { + return nil + }, + NotifyContractUpdate: func(*contractcourt.ContractUpdate) error { + return nil + }, + ChainEvents: &contractcourt.ChainEventSubscription{}, + SyncStates: true, + BatchSize: 10, + BatchTicker: &noopTicker{}, + FwdPkgGCTicker: &noopTicker{}, + PendingCommitTicker: &noopTicker{}, + MinUpdateTimeout: minFeeUpdateTimeout, + MaxUpdateTimeout: maxFeeUpdateTimeout, + OnChannelFailure: func(lnwire.ChannelID, lnwire.ShortChannelID, LinkFailureError) {}, + OutgoingCltvRejectDelta: 3, + MaxOutgoingCltvExpiry: DefaultMaxOutgoingCltvExpiry, + MaxFeeAllocation: DefaultMaxLinkFeeAllocation, + MaxAnchorsCommitFeeRate: chainfee.SatPerKVByte(10 * 1000).FeePerKWeight(), + NotifyActiveLink: func(wire.OutPoint) {}, + NotifyActiveChannel: func(wire.OutPoint) {}, + NotifyInactiveChannel: func(wire.OutPoint) {}, + NotifyInactiveLinkEvent: func(wire.OutPoint) {}, + NotifyChannelUpdate: func(*channeldb.OpenChannel) {}, + HtlcNotifier: &mockHTLCNotifier{}, + GetAliases: func(lnwire.ShortChannelID) []lnwire.ShortChannelID { return nil }, + ShouldFwdExpAccountability: func() bool { return true }, + // Set a large quiescence timeout so the background + // timer never fires during fuzz iterations. + QuiescenceTimeout: time.Hour, + }, + channel, + ) + + chanLink, ok := l.(*channelLink) + require.True(t, ok, "expected *channelLink") + + // Wire the upstream channel directly instead of going through a + // mailbox. The link reads from upstream during syncChanStates, so we + // must set it before calling resumeLink. + chanLink.upstream = upstream + chanLink.mailBox = &mockMailBox{} + + t.Cleanup(func() { + // Stop the link to terminate the fwdPkgGarbager goroutine that + // resumeLink spawns internally. Without this the goroutine + // leaks for the lifetime of the test binary. + chanLink.Stop() + + // Drain the upstream channel to unblock any pending sends. + for len(upstream) > 0 { + <-upstream + } + }) + + return chanLink, upstream +} + // twoHopNetwork is used for managing the created cluster of 2 hops. type twoHopNetwork struct { hopNetwork From cecd1e40cf96261881b0b2d1d2b1e3a81c9424af Mon Sep 17 00:00:00 2001 From: MPins Date: Tue, 21 Apr 2026 10:47:44 -0300 Subject: [PATCH 05/10] htlcswitch: expose link failure reason for test diagnostics Add a failReason string field to channelLink that is populated by failf alongside the existing failed flag. This gives fuzz and unit tests direct access to the human-readable failure reason without requiring a dedicated OnChannelFailure callback or log scraping. --- htlcswitch/link.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/htlcswitch/link.go b/htlcswitch/link.go index 1db005bf82f..f8429549e75 100644 --- a/htlcswitch/link.go +++ b/htlcswitch/link.go @@ -329,6 +329,10 @@ type channelLink struct { // sure we don't process any more updates. failed bool + // failReason stores the formatted reason string from the most recent + // failf call, for diagnostic use in tests. + failReason string + // keystoneBatch represents a volatile list of keystones that must be // written before attempting to sign the next commitment txn. These // represent all the HTLC's forwarded to the link from the switch. Once @@ -3744,6 +3748,7 @@ func (l *channelLink) failf(linkErr LinkFailureError, format string, // Set failed, such that we won't process any more updates, and notify // the peer about the failure. l.failed = true + l.failReason = reason.Error() l.cfg.OnChannelFailure(l.ChanID(), l.ShortChanID(), linkErr) } From a5473e91da30955d4629a4e1727a8a82eadc30d2 Mon Sep 17 00:00:00 2001 From: MPins Date: Thu, 12 Mar 2026 07:24:01 -0300 Subject: [PATCH 06/10] htlcswitch: add FSM fuzz harness for channelLink commit protocol Introduce `fuzz_link_test.go` with a model-based fuzzer that drives the Alice-Bob channel link through arbitrary sequences of protocol events and checks key invariants after each step. fuzz_link_test --- htlcswitch/fuzz_link_test.go | 1310 ++++++++++++++++++++++++++++++++++ 1 file changed, 1310 insertions(+) create mode 100644 htlcswitch/fuzz_link_test.go diff --git a/htlcswitch/fuzz_link_test.go b/htlcswitch/fuzz_link_test.go new file mode 100644 index 00000000000..e282ad5d0e6 --- /dev/null +++ b/htlcswitch/fuzz_link_test.go @@ -0,0 +1,1310 @@ +package htlcswitch + +import ( + "context" + "crypto/sha256" + "fmt" + "math" + "runtime" + "strings" + "testing" + "time" + + "github.com/btcsuite/btcd/btcutil" + "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/htlcswitch/hop" + "github.com/lightningnetwork/lnd/invoices" + "github.com/lightningnetwork/lnd/lntypes" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/stretchr/testify/require" +) + +type Event uint8 + +const ( + EvAliceSendAddHtlc Event = iota + EvBobSendAddHtlc + EvAliceSendCommit + EvBobSendCommit + EvAliceSettleHtlc + EvBobSettleHtlc + EvAliceInvalidHtlcSettlement + EvBobInvalidHtlcSettlement + EvAliceFailHtlc + EvBobFailHtlc + EvAliceFailNonExistentHtlc + EvBobFailNonExistentHtlc + EvAliceFailMalformedHtlc + EvBobFailMalformedHtlc + EvAliceSendUpdateFee + EvBobSendUpdateFee + EvAliceInitQuiescence + EvBobInitQuiescence + EvResumeQuiescence + EvAliceRestartLink + EvBobRestartLink + + NumEvents +) + +const MaxEventsPerRun = 250 + +type fuzzFSM struct { + t *testing.T + alice, bob *testLightningChannel + + // terminated is set to true when a link fails for an expected protocol + // reason (e.g. channel reserve exceeded). Once set, no further events + // should be applied and the test run ends cleanly. + terminated bool + + aliceLink *channelLink + bobLink *channelLink + + // alicePeer captures messages that Alice's link sends to Bob. + // bobPeer captures messages that Bob's link sends to Alice. + alicePeer *mockPeer + bobPeer *mockPeer + + // Registries and circuit maps used by sendHTLC. Alice's link uses + // aliceRegistry (for incoming HTLCs from Bob); Bob's link uses + // bobRegistry (for incoming HTLCs from Alice). + aliceRegistry *mockInvoiceRegistry + bobRegistry *mockInvoiceRegistry + aliceCircuits *mockCircuitMap + bobCircuits *mockCircuitMap + + // Fields required to reconstruct a link on restart. + hopNet *hopNetwork + aliceDecoder *mockIteratorDecoder + bobDecoder *mockIteratorDecoder + alicePCache *mockPreimageCache + bobPCache *mockPreimageCache + bestHeight func() uint32 + + // Preimages for created HTLCs + alicePreimages map[uint64]lntypes.Preimage + bobPreimages map[uint64]lntypes.Preimage + + // HTLC + bobNextHTLCID uint64 + aliceNextHTLCID uint64 + htlcSizeRef uint64 + + // Height regression detection + aliceLocalHeight uint64 + aliceRemoteHeight uint64 + bobLocalHeight uint64 + bobRemoteHeight uint64 + heightsInit bool +} + +func newFuzzFSM(t *testing.T, channelSize, aliceShareGen uint64) *fuzzFSM { + // Redirect all t.TempDir() calls to /dev/shm (tmpfs) so that the + // channeldb bbolt files are kept in RAM rather than written to disk. + // This mitigates the disk I/O bottleneck during fuzzing. + if runtime.GOOS != "linux" { + t.Fatalf("Error: fuzzing on non-Linux OS: %s", runtime.GOOS) + } + t.Setenv("TMPDIR", "/dev/shm") + + // Maximum and minimum limits on channel capacity currently enforced by + // LND. Not considering Wumbo channels here. + chanCapacity := channelSize + maxCapacity := uint64(1<<24) - 1 + minCapacity := uint64(20000) + + if channelSize < minCapacity { + chanCapacity = minCapacity + } else if channelSize > maxCapacity { + chanCapacity = maxCapacity + } + + // 20-79% of the channel capacity + aliceShare := 20 + aliceShareGen%60 + + _, SchanID := genID() + aliceAmount := btcutil.Amount(chanCapacity * aliceShare / 100) + bobAmount := btcutil.Amount(chanCapacity) - aliceAmount + + // The maximum limit on channel reserves is set to be 10% of the channel + // capacity. + aliceReserve := btcutil.Amount(chanCapacity / 10) + bobReserve := btcutil.Amount(chanCapacity / 10) + + blockHeight := 100 + + alice, bob, err := createTestChannel(t, alicePrivKey, bobPrivKey, + aliceAmount, bobAmount, aliceReserve, bobReserve, SchanID, + ) + require.NoError(t, err) + + alicePeer := &mockPeer{ + sentMsgs: make(chan lnwire.Message, 100), + quit: make(chan struct{}), + } + bobPeer := &mockPeer{ + sentMsgs: make(chan lnwire.Message, 100), + quit: make(chan struct{}), + } + + hopNet := newHopNetwork() + + // Each side gets its own registry, preimage cache, and circuit map. + // These are plain in-memory mocks with no background goroutines, so + // there is nothing to race with the test goroutine. + aliceRegistry := newMockRegistry(t) + bobRegistry := newMockRegistry(t) + alicePCache := newMockPreimageCache() + bobPCache := newMockPreimageCache() + aliceCircuits := &mockCircuitMap{lookup: make(chan *PaymentCircuit)} + bobCircuits := &mockCircuitMap{lookup: make(chan *PaymentCircuit)} + + blockHeightVal := uint32(blockHeight) + bestHeight := func() uint32 { return blockHeightVal } + + aliceDecoder := newMockIteratorDecoder() + bobDecoder := newMockIteratorDecoder() + + // Create both links without starting the htlcManager goroutine and + // without a Switch. newFuzzLink sets link.upstream directly so we can + // drive reestablishment synchronously below. + aliceLink, aliceUpstream := hopNet.newFuzzLink( + t, alicePeer, alice.channel, aliceDecoder, + aliceRegistry, alicePCache, aliceCircuits, bestHeight, + ) + bobLink, bobUpstream := hopNet.newFuzzLink( + t, bobPeer, bob.channel, bobDecoder, + bobRegistry, bobPCache, bobCircuits, bestHeight, + ) + + // Generate the ChannelReestablish messages that each side needs to + // receive in order to complete the sync handshake. + aliceSyncMsg, err := alice.channel.State().ChanSyncMsg() + require.NoError(t, err) + bobSyncMsg, err := bob.channel.State().ChanSyncMsg() + require.NoError(t, err) + + // Cross-inject: Alice's link reads from aliceUpstream (gets Bob's msg), + // Bob's link reads from bobUpstream (gets Alice's msg). + aliceUpstream <- bobSyncMsg + bobUpstream <- aliceSyncMsg + + // resumeLink runs syncChanStates synchronously — no goroutine spawned. + require.NoError(t, aliceLink.resumeLink(t.Context())) + require.NoError(t, bobLink.resumeLink(t.Context())) + + return &fuzzFSM{ + t: t, + alice: alice, + bob: bob, + aliceLink: aliceLink, + bobLink: bobLink, + aliceRegistry: aliceRegistry, + bobRegistry: bobRegistry, + aliceCircuits: aliceCircuits, + bobCircuits: bobCircuits, + alicePeer: alicePeer, + bobPeer: bobPeer, + alicePreimages: make(map[uint64]lntypes.Preimage), + bobPreimages: make(map[uint64]lntypes.Preimage), + hopNet: hopNet, + aliceDecoder: aliceDecoder, + bobDecoder: bobDecoder, + alicePCache: alicePCache, + bobPCache: bobPCache, + bestHeight: bestHeight, + } +} + +func (f *fuzzFSM) assertInvariants() { + aliceChanState := f.alice.channel.State() + aliceLocal := aliceChanState.LocalCommitment.CommitHeight + aliceRemote := aliceChanState.RemoteCommitment.CommitHeight + + bobChanState := f.bob.channel.State() + bobLocal := bobChanState.LocalCommitment.CommitHeight + bobRemote := bobChanState.RemoteCommitment.CommitHeight + + if !f.heightsInit { + f.aliceLocalHeight = aliceLocal + f.aliceRemoteHeight = aliceRemote + f.bobLocalHeight = bobLocal + f.bobRemoteHeight = bobRemote + f.heightsInit = true + + return + } + + // Monotonic + if aliceLocal < f.aliceLocalHeight || + aliceRemote < f.aliceRemoteHeight { + + f.t.Fatalf("height regression: aliceLocal=%d "+ + "lastLocalHeight=%d aliceRemote=%d"+ + "lastRemoteHeight=%d", + aliceLocal, f.aliceLocalHeight, aliceRemote, + f.aliceRemoteHeight) + } + + if bobLocal < f.bobLocalHeight || bobRemote < f.bobRemoteHeight { + f.t.Fatalf("height regression: bobLocal=%d "+ + "lastLocalHeight=%d bobRemote=%d lastRemoteHeight=%d", + bobLocal, f.bobLocalHeight, bobRemote, + f.bobRemoteHeight) + } + + f.aliceLocalHeight = aliceLocal + f.aliceRemoteHeight = aliceRemote + f.bobLocalHeight = bobLocal + f.bobRemoteHeight = bobRemote + + // They should be "mirrored" + // We allow a lag of 1 due to transient protocol state. + diff := func(a, b uint64) uint64 { + if a > b { + return a - b + } + + return b - a + } + + if diff(aliceLocal, bobRemote) > 1 { + f.t.Fatalf("commit mismatch: aliceLocal=%d bobRemote=%d", + aliceLocal, bobRemote) + } + + if diff(aliceRemote, bobLocal) > 1 { + f.t.Fatalf("commit mismatch: aliceRemote=%d bobLocal=%d", + aliceRemote, bobLocal) + } + + // Check total balances. + var aliceHtlcAmt, bobHtlcAmt lnwire.MilliSatoshi + aliceTotal := aliceChanState.LocalCommitment.LocalBalance + + aliceChanState.LocalCommitment.RemoteBalance + + lnwire.NewMSatFromSatoshis( + aliceChanState.LocalCommitment.CommitFee, + ) + + for _, htlc := range aliceChanState.LocalCommitment.Htlcs { + aliceHtlcAmt += htlc.Amt + } + + require.Equal( + f.t, aliceTotal+aliceHtlcAmt, + lnwire.NewMSatFromSatoshis(f.alice.channel.Capacity), + ) + + bobTotal := bobChanState.LocalCommitment.LocalBalance + + bobChanState.LocalCommitment.RemoteBalance + + lnwire.NewMSatFromSatoshis( + bobChanState.LocalCommitment.CommitFee, + ) + + for _, htlc := range bobChanState.LocalCommitment.Htlcs { + bobHtlcAmt += htlc.Amt + } + + require.Equal( + f.t, bobTotal+bobHtlcAmt, + lnwire.NewMSatFromSatoshis(f.bob.channel.Capacity), + ) +} + +// htlcMsgStr returns a human-readable string for an lnwire.Message, +// including the HTLC ID for add/settle/fail messages and number of htlcs +// signed for commit msgs. +func htlcMsgStr(msg lnwire.Message) string { + switch m := msg.(type) { + case *lnwire.UpdateAddHTLC: + return fmt.Sprintf("UpdateAddHTLC(id=%d, amount=%v)", m.ID, + m.Amount) + + case *lnwire.UpdateFulfillHTLC: + return fmt.Sprintf("UpdateFulfillHTLC(id=%d)", m.ID) + case *lnwire.UpdateFailHTLC: + return fmt.Sprintf("UpdateFailHTLC(id=%d)", m.ID) + case *lnwire.CommitSig: + return fmt.Sprintf("CommitSig(htlc_sigs=%d)", len(m.HtlcSigs)) + default: + return msg.MsgType().String() + } +} + +// isExpectedLinkFailure returns true if the link failure reason is a known +// protocol boundary condition — i.e., a case where the protocol itself +// requires the link to be torn down rather than a bug in the commit logic. +// Failing links in these cases is correct behaviour; the test only fails if +// an unexpected reason is produced. +func isExpectedLinkFailure(reason string) bool { + expected := []string{ + // Commitment fee pushes one party below their channel reserve. + "below chan reserve", + // Fee-exposure limit exceeded (too many dust HTLCs at this fee + // rate). + "fee threshold exceeded", + // An HTLC update (add/settle/fail) arrived after the peer sent + // stfu, entering quiescence. + "update received after stfu", + } + for _, substr := range expected { + if strings.Contains(reason, substr) { + return true + } + } + + return false +} + +// drainMessages processes all pending messages. +// alicePeer.sentMsgs holds messages Alice sent to Bob → deliver to Bob's link. +// bobPeer.sentMsgs holds messages Bob sent to Alice → deliver to Alice's link. +func (f *fuzzFSM) drainMessages() { + for { + select { + case msg := <-f.alicePeer.sentMsgs: + // Alice sent this message → deliver to Bob's link. + f.t.Logf("Alice→Bob: %v", htlcMsgStr(msg)) + + f.bobLink.handleUpstreamMsg( + f.t.Context(), msg, + ) + if f.bobLink.failed { + reason := f.bobLink.failReason + if isExpectedLinkFailure(reason) { + f.t.Logf("Bob's link correctly "+ + "terminated (expected "+ + "protocol boundary) after %v:"+ + " %v", htlcMsgStr(msg), reason) + f.terminated = true + + return + } + f.t.Fatalf("Bob's link failed "+ + "unexpectedly after handling %v: %v", + htlcMsgStr(msg), reason) + } + + case msg := <-f.bobPeer.sentMsgs: + // Bob sent this message → deliver to Alice's link. + f.t.Logf("Bob→Alice: %v", htlcMsgStr(msg)) + f.aliceLink.handleUpstreamMsg( + f.t.Context(), msg, + ) + if f.aliceLink.failed { + reason := f.aliceLink.failReason + if isExpectedLinkFailure(reason) { + f.t.Logf("Alice's link correctly "+ + "terminated (expected "+ + "protocol boundary) after %v:"+ + " %v", htlcMsgStr(msg), reason) + f.terminated = true + + return + } + f.t.Fatalf("Alice's link failed "+ + "unexpectedly after handling %v: %v", + htlcMsgStr(msg), reason) + } + + default: + return + } + } +} + +// sendHTLC initiates an outgoing HTLC from sender by registering a hodl +// invoice on the receiver's registry, committing the payment circuit on the +// sender's Switch, and injecting the UpdateAddHTLC directly into the link via +// handleDownstreamUpdateAdd. Returns the preimage and true on success, or an +// empty preimage and false if the channel is full (circuit and invoice are +// cleaned up in that case). +func (f *fuzzFSM) sendHTLC(sender *channelLink, htlcID uint64) ( + lntypes.Preimage, bool) { + + var senderCircuits *mockCircuitMap + var invoiceRegistry *mockInvoiceRegistry + switch sender { + case f.aliceLink: + senderCircuits = f.aliceCircuits + invoiceRegistry = f.bobRegistry + case f.bobLink: + senderCircuits = f.bobCircuits + invoiceRegistry = f.aliceRegistry + default: + f.t.Fatal("HTLC sender does not exist") + } + + // HTLC amount is derived from the htlcSizeRef fuzz input and bounded + // by the channel capacity. + maxHTLC := lnwire.MilliSatoshi(sender.channel.Capacity * 1000) + htlcAmt := lnwire.MilliSatoshi(f.htlcSizeRef) % maxHTLC + + htlc, preimage, err := generateSingleHopHtlc( + f.t, htlcID, htlcAmt, + ) + if err != nil { + f.t.Fatalf("failed to generate htlc: %v", err) + } + hodlInvoice := invoices.Invoice{ + CreationDate: time.Now(), + HodlInvoice: true, + Terms: invoices.ContractTerm{ + FinalCltvDelta: testInvoiceCltvExpiry, + Value: htlc.Amount, + Features: lnwire.NewFeatureVector( + nil, lnwire.Features, + ), + PaymentPreimage: &preimage, + }, + } + if err := invoiceRegistry.AddInvoice( + context.Background(), hodlInvoice, htlc.PaymentHash, + ); err != nil { + f.t.Fatalf("AddInvoice (hodl) failed: %v", err) + } + packet := &htlcPacket{ + // hop.Source marks this as a locally-initiated payment. + incomingChanID: hop.Source, + incomingHTLCID: htlcID, + outgoingChanID: sender.ShortChanID(), + htlc: htlc, + amount: htlc.Amount, + } + circuit := newPaymentCircuit(&htlc.PaymentHash, packet) + + _, err = senderCircuits.CommitCircuits(circuit) + if err != nil { + f.t.Fatalf("CommitCircuits failed: %v", err) + } + packet.circuit = circuit + err = sender.handleDownstreamUpdateAdd(f.t.Context(), packet) + if err != nil { + // Channel may be full. Clean up resources already allocated: + // remove the circuit from the map and cancel the hold invoice. + f.t.Logf("sendHTLC skipped: %v", err) + _ = senderCircuits.DeleteCircuits(circuit.Incoming) + _ = invoiceRegistry.CancelInvoice( + context.Background(), htlc.PaymentHash, + ) + + return lntypes.Preimage{}, false + } + + return preimage, true +} + +// sendCommitSig triggers a commitment signature from sender if there are +// pending local or remote updates to commit. It calls updateCommitTx directly, +// bypassing the link's internal event loop. Returns the number of pending +// updates and true if a CommitSig was sent, or 0 and false if there was +// nothing to commit. +func (f *fuzzFSM) sendCommitSig(sender *channelLink) (uint64, bool) { + if f.terminated { + return 0, false + } + + // Send the commit_sig message only if there are pending commitment + // update messages on the sender side, or if the sender is the remote + // node. + pending := sender.channel.NumPendingUpdates( + lntypes.Local, lntypes.Remote, + ) + if pending > 0 { + err := sender.updateCommitTx(f.t.Context()) + if err != nil { + if isExpectedLinkFailure(err.Error()) { + f.t.Logf("sendCommitSig correctly failed "+ + "(expected protocol boundary): %v", err) + f.terminated = true + + return 0, false + } + f.t.Fatalf("failed CommitSig %v", err) + } + + return pending, true + } + + return 0, false +} + +// Directly settle HTLC on the channel state machine, bypassing the +// hodl invoice → htlcManager path entirely. +// +// By calling channel.SettleHTLC + SendMessage directly from the test goroutine, +// htlcManager stays idle (no hodl notification is ever enqueued) and the settle +// path is fully synchronous and race-free. +// +// The hodl invoice in the registry is left in ContractAccepted with a dangling +// subscription — that is intentional. Calling CancelInvoice would send a fail +// notification to the link's hodl subscriber, triggering an unwanted +// UpdateFailHTLC. Since htlcIDs are unique and the test is in-memory, the +// dangling entries cause no issues. +// +// Guard: the HTLC must be locked-in (present in the committed state) before we +// can settle it. +func (f *fuzzFSM) settleHTLC(link *channelLink, htlcID uint64, + preimage lntypes.Preimage) bool { + + var lockedIn bool + for _, h := range link.channel.ActiveHtlcs() { + if h.Incoming && h.HtlcIndex == htlcID { + lockedIn = true + break + } + } + if !lockedIn { + f.t.Logf("settle skipped: HTLC %d not yet locked-in", htlcID) + return false + } + + err := link.channel.SettleHTLC(preimage, htlcID, nil, nil, nil) + if err != nil { + f.t.Logf("settle skipped: %v", err) + + return false + } + + // Emit UpdateFulfillHTLC synchronously into sentMsgs so drainMessages + // delivers it to the other side. + err = link.cfg.Peer.SendMessage(false, &lnwire.UpdateFulfillHTLC{ + ChanID: link.ChanID(), + ID: htlcID, + PaymentPreimage: preimage, + }) + if err != nil { + f.t.Fatalf("failed to send UpdateFulfillHTLC: %v", err) + } + + return true +} + +// Directly fail HTLC on the channel state machine, bypassing the +// hodl invoice → htlcManager path entirely. +// +// By calling channel.FailHTLC + SendMessage directly from the test goroutine, +// htlcManager stays idle (no hodl notification is ever enqueued) and the fail +// path is fully synchronous and race-free. +// +// The hodl invoice in the registry is left in ContractAccepted with a dangling +// subscription — that is intentional. Calling CancelInvoice would send a fail +// notification to the link's hodl subscriber, triggering an unwanted +// UpdateFailHTLC. Since htlcIDs are unique and the test is in-memory, the +// dangling entries cause no issues. +// +// Guard: the HTLC must be locked-in (present in the committed state) before we +// can fail it. +func (f *fuzzFSM) failHTLC(link *channelLink, htlcID uint64) bool { + var lockedIn bool + for _, h := range link.channel.ActiveHtlcs() { + if h.Incoming && h.HtlcIndex == htlcID { + lockedIn = true + break + } + } + if !lockedIn { + f.t.Logf("fail skipped: HTLC %d not yet locked-in", htlcID) + return false + } + + reason := []byte("fuzz test") + err := link.channel.FailHTLC(htlcID, reason, nil, nil, nil) + if err != nil { + f.t.Logf("fail skipped: %v", err) + + return false + } + + // Emit UpdateFailHTLC synchronously into sentMsgs so drainMessages + // delivers it to the other side. + err = link.cfg.Peer.SendMessage(false, &lnwire.UpdateFailHTLC{ + ChanID: link.ChanID(), + ID: htlcID, + Reason: reason, + }) + if err != nil { + f.t.Fatalf("failed to send UpdateFulfillHTLC: %v", err) + } + + return true +} + +// failMalformedHTLC attempts to fail an incoming locked-in HTLC on the given +// link by sending an UpdateFailMalformedHTLC message. It mirrors failHTLC but +// uses MalformedFailHTLC on the channel state machine and signals that the +// onion blob itself was malformed (CodeInvalidOnionHmac). Returns false if the +// update is skipped because the link is quiesced or the HTLC is not yet +// locked-in. +func (f *fuzzFSM) failMalformedHTLC(link *channelLink, htlcID uint64) bool { + var lockedIn bool + for _, h := range link.channel.ActiveHtlcs() { + if h.Incoming && h.HtlcIndex == htlcID { + lockedIn = true + break + } + } + if !lockedIn { + f.t.Logf("fail malformed skipped: HTLC %d not yet locked-in", + htlcID, + ) + + return false + } + + // Use a fixed dummy onion blob; its SHA-256 is what gets sent on the + // wire and stored in the channel state machine. + var onionBlob [lnwire.OnionPacketSize]byte + shaOnionBlob := sha256.Sum256(onionBlob[:]) + code := lnwire.CodeInvalidOnionHmac + + err := link.channel.MalformedFailHTLC(htlcID, code, shaOnionBlob, nil) + if err != nil { + f.t.Logf("fail malformed skipped: %v", err) + return false + } + + err = link.cfg.Peer.SendMessage(false, &lnwire.UpdateFailMalformedHTLC{ + ChanID: link.ChanID(), + ID: htlcID, + ShaOnionBlob: shaOnionBlob, + FailureCode: code, + }) + if err != nil { + f.t.Fatalf("failed to send UpdateFailMalformedHTLC: %v", err) + } + + return true +} + +// updateFee attempts to send a fee update on the given link. +func (f *fuzzFSM) updateFee(link *channelLink, newFee int) (error, bool) { + // After STFU is sent the link must not emit any more update messages; + // the receiving side would call stfuFailf and fail the link. + if !link.quiescer.CanSendUpdates() { + return nil, false + } + + feePerKw := chainfee.SatPerKWeight(newFee) + + err := link.updateChannelFee(f.t.Context(), feePerKw) + if err != nil { + return err, false + } + + return nil, true +} + +// initQuiescence initiates the quiescence handshake on the given link by +// sending a quiescence request. +func (f *fuzzFSM) initQuiescence(link *channelLink) error { + req, _ := fn.NewReq[fn.Unit, fn.Result[lntypes.ChannelParty]](fn.Unit{}) + + err := link.handleQuiescenceReq(req) + if err != nil { + return err + } + + return nil +} + +// resumeQuiescence resumes normal operation on both links after a quiescence +// session. +func (f *fuzzFSM) resumeQuiescence() error { + aliceQ := f.aliceLink.quiescer.IsQuiescent() + bobQ := f.bobLink.quiescer.IsQuiescent() + if !aliceQ || !bobQ { + return fmt.Errorf("Alice quiescenter state: %v, Bob quiescer "+ + "state: %v", aliceQ, bobQ, + ) + } + f.aliceLink.quiescer.Resume() + f.bobLink.quiescer.Resume() + + return nil +} + +// restartLink simulates a disconnect/reconnect for one side. The old link is +// stopped, any in-flight messages are discarded (lost during disconnect), and a +// fresh link is created over the same lnwallet.LightningChannel. The remote's +// current ChannelReestablish is injected into the new link's upstream so that +// resumeLink can complete the sync handshake. The local ChannelReestablish sent +// by the new link is then drained from the peer's sentMsgs — the still-running +// remote link doesn't participate in a second sync round. +func (f *fuzzFSM) restartLink(isAlice bool) { + var ( + oldLink *channelLink + testChan *testLightningChannel + remoteCh *testLightningChannel + peer *mockPeer + registry *mockInvoiceRegistry + pCache *mockPreimageCache + circuits *mockCircuitMap + ) + if isAlice { + oldLink = f.aliceLink + testChan = f.alice + remoteCh = f.bob + peer = f.alicePeer + registry = f.aliceRegistry + pCache = f.alicePCache + circuits = f.aliceCircuits + } else { + oldLink = f.bobLink + testChan = f.bob + remoteCh = f.alice + peer = f.bobPeer + registry = f.bobRegistry + pCache = f.bobPCache + circuits = f.bobCircuits + } + + // Stop the old link to clean up its fwdPkgGarbager goroutine. + oldLink.Stop() + + // Discard any messages that were in-flight when the link went down. + for len(peer.sentMsgs) > 0 { + <-peer.sentMsgs + } + + // Snapshot the remote's current channel state for the sync handshake. + remoteSyncMsg, err := remoteCh.channel.State().ChanSyncMsg() + require.NoError(f.t, err) + + // A real restart clears the Sphinx replay cache. Use a fresh decoder so + // resolveFwdPkgs can re-decode onion blobs from scratch instead of + // hitting stale, already-consumed iterator entries from the prior run. + freshDecoder := newMockIteratorDecoder() + + newLink, newUpstream := f.hopNet.newFuzzLink( + f.t, peer, testChan.channel, freshDecoder, + registry, pCache, circuits, f.bestHeight, + ) + + // Pre-load the remote's reestablish so resumeLink can read it + // synchronously from upstream. + newUpstream <- remoteSyncMsg + require.NoError(f.t, newLink.resumeLink(f.t.Context())) + + // Disconnection cancels the in-progress STFU session on both sides. + // Reset the remote link's quiescer unconditionally: Resume() clears + // sent/received flags, cancels any timeout, and runs OnResume callbacks + // that were deferred during quiescence (those callbacks may emit + // messages that drainMessages will deliver to the new link below). + if isAlice { + f.aliceLink = newLink + f.bobLink.quiescer.Resume() + } else { + f.bobLink = newLink + f.aliceLink.quiescer.Resume() + } + + // Drain the ChannelReestablish the new link sent out plus any messages + // emitted by the remote's OnResume callbacks. + f.drainMessages() +} + +// applyEvent dispatches a single fuzz-generated event to the FSM for either +// Alice or Bob. Events that cannot be applied in the current state are silently +// skipped so the fuzzer can keep making progress without failing the test. +func (f *fuzzFSM) applyEvent(e Event) { + if f.terminated { + return + } + switch e { + case EvAliceSendAddHtlc: + if len(f.bobPreimages) >= maxInflightHtlcs { + f.t.Logf("Alice Add HTLC Skipped: HTLCs pending > %v", + maxInflightHtlcs) + + return + } + // Bob create the Hold Invoice, Alice send the HTLC. + preimage, ok := f.sendHTLC( + f.aliceLink, f.aliceNextHTLCID, + ) + if !ok { + f.t.Log("Alice Add HTLC Skipped: channel full") + return + } + // bobPreimages are those Bob keep track to settle the hold + // invoices. + f.bobPreimages[f.aliceNextHTLCID] = preimage + f.aliceNextHTLCID++ + f.t.Logf("EV Alice Send Add HTLC ID:%v", f.aliceNextHTLCID-1) + case EvBobSendAddHtlc: + if len(f.alicePreimages) >= maxInflightHtlcs { + f.t.Logf("Bob Add HTLC Skipped: HTLCs pending > %v", + maxInflightHtlcs) + + return + } + // Alice create the Hold Invoice, Bob send the HTLC. + preimage, ok := f.sendHTLC( + f.bobLink, f.bobNextHTLCID, + ) + if !ok { + f.t.Log("Bob Add HTLC Skipped: channel full") + return + } + // alicePreimages are those Alice keep track to settle the hold + // invoices. + f.alicePreimages[f.bobNextHTLCID] = preimage + f.bobNextHTLCID++ + f.t.Logf("EV Bob Send Add HTLC ID:%v", f.bobNextHTLCID-1) + case EvAliceSendCommit: + _, ok := f.sendCommitSig(f.aliceLink) + if ok { + f.t.Log("EV Alice Send Commit") + return + } + f.t.Log("Alice skipped Commit") + case EvBobSendCommit: + _, ok := f.sendCommitSig(f.bobLink) + if ok { + f.t.Log("EV Bob Send Commit") + return + } + f.t.Log("Bob skipped Commit") + case EvAliceSettleHtlc: + if len(f.alicePreimages) == 0 { + f.t.Log("No Alice preimages to be settled") + return + } + + // Pick the oldest preimage Alice tracks and settle it on her + // link. + var oldestID uint64 = math.MaxUint64 + for id := range f.alicePreimages { + if id < oldestID { + oldestID = id + } + } + preimage := f.alicePreimages[oldestID] + ok := f.settleHTLC( + f.aliceLink, oldestID, preimage, + ) + if ok { + delete(f.alicePreimages, oldestID) + f.t.Logf("EV Alice Settle HTLC ID:%v", oldestID) + return + } + f.t.Log("Alice Settle HTLC Skipped") + case EvBobSettleHtlc: + if len(f.bobPreimages) == 0 { + f.t.Log("No Bob preimages to be settled") + return + } + + // Pick the oldest preimage Bob tracks and settle it on his + // link. + var oldestID uint64 = math.MaxUint64 + for id := range f.bobPreimages { + if id < oldestID { + oldestID = id + } + } + preimage := f.bobPreimages[oldestID] + ok := f.settleHTLC(f.bobLink, oldestID, preimage) + if ok { + delete(f.bobPreimages, oldestID) + f.t.Logf("EV Bob Settle HTLC ID:%v", oldestID) + return + } + f.t.Log("Bob Settle HTLC Skipped") + // Invalid settlement: + // - if the number of tracked preimages is even, use both invalids + // preimage and HTLC ID. + // - if it is odd, use an existing HTLC ID with an invalid preimage. + case EvAliceInvalidHtlcSettlement: + preimage := lntypes.Preimage{0x01} + htlcID := uint64(MaxEventsPerRun) + numPreimages := len(f.alicePreimages) + if numPreimages%2 != 0 { + for id := range f.alicePreimages { + htlcID = id + break + } + } + err := f.aliceLink.channel.SettleHTLC( + preimage, htlcID, nil, nil, nil, + ) + require.Error(f.t, err) + f.t.Logf("EV Alice Invalid HTLC Settlement: %v", err) + case EvBobInvalidHtlcSettlement: + preimage := lntypes.Preimage{0x01} + htlcID := uint64(MaxEventsPerRun) + numPreimages := len(f.bobPreimages) + if numPreimages%2 != 0 { + for id := range f.bobPreimages { + htlcID = id + break + } + } + err := f.bobLink.channel.SettleHTLC( + preimage, htlcID, nil, nil, nil, + ) + require.Error(f.t, err) + f.t.Logf("EV Bob Invalid HTLC Settlement: %v", err) + case EvAliceFailHtlc: + if len(f.alicePreimages) == 0 { + f.t.Log("No Alice preimages to be failed") + return + } + + // Pick the oldest preimage Alice tracks and fail it on her + // link. + var oldestID uint64 = math.MaxUint64 + for id := range f.alicePreimages { + if id < oldestID { + oldestID = id + } + } + ok := f.failHTLC(f.aliceLink, oldestID) + if ok { + delete(f.alicePreimages, oldestID) + f.t.Logf("EV Alice Fail HTLC ID:%v", oldestID) + return + } + f.t.Log("Alice Fail HTLC Skipped") + case EvBobFailHtlc: + if len(f.bobPreimages) == 0 { + f.t.Log("No Bob preimages to be failed") + return + } + + // Pick the oldest preimage Bob tracks and fail it on his link. + var oldestID uint64 = math.MaxUint64 + for id := range f.bobPreimages { + if id < oldestID { + oldestID = id + } + } + ok := f.failHTLC(f.bobLink, oldestID) + if ok { + delete(f.bobPreimages, oldestID) + f.t.Logf("EV Bob Fail HTLC ID: %v", oldestID) + return + } + f.t.Log("Bob Fail HTLC Skipped") + case EvAliceFailNonExistentHtlc: + htlcID := uint64(MaxEventsPerRun) + reason := []byte("fuzz test") + err := f.aliceLink.channel.FailHTLC( + htlcID, reason, nil, nil, nil, + ) + require.Error(f.t, err) + f.t.Logf("EV Alice Invalid HTLC Failure: %v", err) + case EvBobFailNonExistentHtlc: + htlcID := uint64(MaxEventsPerRun) + reason := []byte("fuzz test") + err := f.bobLink.channel.FailHTLC(htlcID, reason, nil, nil, nil) + require.Error(f.t, err) + f.t.Logf("EV Bob Invalid HTLC Failure: %v", err) + case EvAliceFailMalformedHtlc: + if len(f.alicePreimages) == 0 { + f.t.Log("No Alice preimages to be malformed-failed") + return + } + + // Pick the oldest HTLC Alice tracks and fail it as malformed. + var oldestID uint64 = math.MaxUint64 + for id := range f.alicePreimages { + if id < oldestID { + oldestID = id + } + } + ok := f.failMalformedHTLC(f.aliceLink, oldestID) + if ok { + delete(f.alicePreimages, oldestID) + f.t.Logf("EV Alice Fail Malformed HTLC ID: %v", + oldestID, + ) + + return + } + f.t.Log("Alice Fail Malformed HTLC Skipped") + case EvBobFailMalformedHtlc: + if len(f.bobPreimages) == 0 { + f.t.Log("No Bob preimages to be malformed-failed") + return + } + + // Pick the oldest HTLC Bob tracks and fail it as malformed. + var oldestID uint64 = math.MaxUint64 + for id := range f.bobPreimages { + if id < oldestID { + oldestID = id + } + } + ok := f.failMalformedHTLC(f.bobLink, oldestID) + if ok { + delete(f.bobPreimages, oldestID) + f.t.Logf("EV Bob Fail Malformed HTLC ID: %v", oldestID) + return + } + f.t.Log("Bob Fail Malformed HTLC Skipped") + case EvAliceSendUpdateFee: + newFee := len(f.aliceLink.channel.ActiveHtlcs())*100 + 1000 + err, ok := f.updateFee(f.aliceLink, newFee) + if ok { + f.t.Log("EV Alice Send Update Fee") + return + } + f.t.Logf("Alice skipped Update Fee: %s", err) + case EvBobSendUpdateFee: + newFee := len(f.bobLink.channel.ActiveHtlcs())*100 + 1000 + err, ok := f.updateFee(f.bobLink, newFee) + if ok { + f.t.Log("EV Bob Send Update Fee") + return + } + f.t.Logf("Bob skipped Update Fee: %s", err) + case EvAliceInitQuiescence: + err := f.initQuiescence(f.aliceLink) + if err != nil { + f.t.Logf("Alice skipped Init Quiescence: %s", err) + return + } + f.t.Log("EV Alice Init Quiescence") + case EvBobInitQuiescence: + err := f.initQuiescence(f.bobLink) + if err != nil { + f.t.Logf("Bob skipped Init Quiescence: %s", err) + return + } + f.t.Log("EV Bob Init Quiescence") + case EvResumeQuiescence: + err := f.resumeQuiescence() + if err != nil { + f.t.Logf("skipped Resume Quiescence: %s", err) + return + } + f.t.Log("EV Resume Quiescence") + case EvAliceRestartLink: + f.restartLink(true) + f.t.Log("EV Alice Restart Link") + case EvBobRestartLink: + f.restartLink(false) + f.t.Log("EV Bob Restart Link") + } +} + +// TestChannelLinkFSMScenarios runs deterministic event sequences through the +// fuzz harness to validate each event type before enabling the full fuzzer. +func TestChannelLinkFSMScenarios(t *testing.T) { + run := func(t *testing.T, events []Event) { + t.Helper() + + f := newFuzzFSM(t, uint64(1_000_000), uint64(50)) + + f.htlcSizeRef = uint64(10_000_000) + + for _, evt := range events { + f.applyEvent(evt) + f.drainMessages() + f.assertInvariants() + } + } + // No-op smoke test: all events that should silently skip on a clean + // channel with no pending HTLCs. + t.Run("noop_on_clean_channel", func(t *testing.T) { + run(t, []Event{ + EvAliceSendCommit, + EvBobSendCommit, + EvAliceSettleHtlc, + EvBobSettleHtlc, + EvAliceFailHtlc, + EvBobFailHtlc, + EvAliceFailMalformedHtlc, + EvBobFailMalformedHtlc, + EvBobSendUpdateFee, + }) + }) + + // Alice adds an HTLC and both parties commit it. + t.Run("alice_add_commit", func(t *testing.T) { + run(t, []Event{ + EvAliceSendAddHtlc, + EvAliceSendCommit, + }) + }) + + // Bob adds an HTLC and both parties commit it. + t.Run("bob_add_commit", func(t *testing.T) { + run(t, []Event{ + EvBobSendAddHtlc, + EvBobSendCommit, + }) + }) + + // Multiple HTLCs in both directions, committed in one round. + t.Run("multiple_htlcs_both_directions", func(t *testing.T) { + run(t, []Event{ + EvAliceSendAddHtlc, + EvAliceSendAddHtlc, + EvBobSendAddHtlc, + EvAliceSendCommit, + }) + }) + + // Alice adds an HTLC, both commit, Bob settlesl. + t.Run("alice_add_bob_settle", func(t *testing.T) { + run(t, []Event{ + EvAliceSendAddHtlc, + EvAliceSendCommit, + EvBobSettleHtlc, + EvBobSendCommit, + }) + }) + + // Alice adds an HTLC, both commit, Bob fails. Partial: same numHtlcs + // constraint applies to the final EvAliceSendCommit. + t.Run("alice_add_bob_fail", func(t *testing.T) { + run(t, []Event{ + EvAliceSendAddHtlc, + EvAliceSendCommit, + EvBobFailMalformedHtlc, + EvBobSendCommit, + }) + }) + // Alice restarts mid-session, then reconnects and settles an in-flight + // HTLC and both parties commit the resolution. + t.Run("alice_restart_link", func(t *testing.T) { + run(t, []Event{ + EvAliceSendAddHtlc, + EvAliceSendCommit, + EvAliceRestartLink, + EvBobSettleHtlc, + EvBobSendCommit, + }) + }) + + // Bob restarts mid-session, then reconnects and settles an in-flight + // HTLC and both parties commit the resolution. + t.Run("bob_restart_link", func(t *testing.T) { + run(t, []Event{ + EvBobSendAddHtlc, + EvBobSendCommit, + EvBobRestartLink, + EvAliceSettleHtlc, + EvAliceSendCommit, + }) + }) + + // Alice initiates quiescence while an HTLC is pending but not yet + // committed. + t.Run("alice_quiescence_link", func(t *testing.T) { + run(t, []Event{ + EvAliceSendAddHtlc, + EvAliceInitQuiescence, + EvAliceSendCommit, + EvResumeQuiescence, + }) + }) + + // Alice restarts while in quiescence. + t.Run("alice_restart_during_quiescence_link", func(t *testing.T) { + run(t, []Event{ + EvAliceSendAddHtlc, + EvAliceInitQuiescence, + EvAliceRestartLink, + EvAliceSendCommit, + EvResumeQuiescence, + }) + }) + + // Bob initiates quiescence while an HTLC is pending but not yet + // committed. + t.Run("bob_quiescence_link", func(t *testing.T) { + run(t, []Event{ + EvBobSendAddHtlc, + EvBobInitQuiescence, + EvBobSendCommit, + EvResumeQuiescence, + }) + }) + + t.Run("all_events", func(t *testing.T) { + run(t, []Event{ + EvAliceSendAddHtlc, + EvAliceSendAddHtlc, + EvAliceSendAddHtlc, + EvAliceRestartLink, + EvBobSendAddHtlc, + EvBobSendAddHtlc, + EvBobRestartLink, + EvBobSendUpdateFee, + EvBobSendAddHtlc, + EvBobSendCommit, + EvAliceInitQuiescence, + EvAliceFailHtlc, + EvBobFailHtlc, + EvAliceSettleHtlc, + EvBobSettleHtlc, + EvResumeQuiescence, + EvAliceSendCommit, + EvAliceFailMalformedHtlc, + EvBobFailMalformedHtlc, + EvAliceSendAddHtlc, + EvBobSendAddHtlc, + EvAliceSendCommit, + EvAliceFailHtlc, + }) + }) +} + +// FuzzChannelLinkFSM is a coverage-guided fuzz test for the two-party +// commitment protocol between Alice and Bob. Each byte of the corpus is +// interpreted as one of the NumEvents protocol actions for either peer. After +// every event the pending messages are drained and assertInvariants verifies +// that both sides remain in a consistent state (matching commitment heights, +// balanced totals). The fuzzer explores arbitrary interleavings of these +// actions to find protocol violations that deterministic scenarios might miss. +func FuzzChannelLinkFSM(f *testing.F) { + // seed input + f.Add(uint64(1_000_000), uint64(10_000_000), uint64(50), + []byte{byte(EvAliceSendAddHtlc), byte(EvAliceSendCommit), + byte(EvBobSendAddHtlc), byte(EvBobSendCommit), + byte(EvAliceSettleHtlc), byte(EvBobSettleHtlc), + byte(EvAliceSendUpdateFee), byte(EvAliceSendAddHtlc), + byte(EvAliceSendCommit), byte(EvBobSendAddHtlc), + byte(EvBobSendCommit), byte(EvAliceFailMalformedHtlc), + byte(EvBobFailHtlc), byte(EvBobSendUpdateFee), + byte(EvAliceSendAddHtlc), byte(EvAliceSendCommit), + byte(EvBobSendAddHtlc), byte(EvBobSendCommit), + byte(EvAliceFailHtlc), byte(EvAliceRestartLink), + byte(EvBobFailMalformedHtlc), byte(EvBobInitQuiescence), + byte(EvBobSendUpdateFee), byte(EvAliceSendAddHtlc), + byte(EvAliceSendCommit), byte(EvResumeQuiescence), + byte(EvBobSettleHtlc), byte(EvBobSendAddHtlc), + byte(EvBobSendCommit), byte(EvAliceFailHtlc), + byte(EvResumeQuiescence), byte(EvAliceRestartLink), + byte(EvAliceInitQuiescence)}, + ) + f.Fuzz(func(t *testing.T, channelSize, htlcSizeRef uint64, + aliceShareGen uint64, data []byte) { + + fuzzFSM := newFuzzFSM(t, channelSize, aliceShareGen) + + fuzzFSM.htlcSizeRef = htlcSizeRef + + // Guard against excessively long inputs that would make the + // test run too long. + if len(data) > MaxEventsPerRun { + return + } + + for _, b := range data { + evt := Event(b % uint8(NumEvents)) + fuzzFSM.applyEvent(evt) + fuzzFSM.drainMessages() + if fuzzFSM.terminated { + return + } + fuzzFSM.assertInvariants() + } + }) +} From 5851c212e102c4cd329b5f3f5380d6b273b096bb Mon Sep 17 00:00:00 2001 From: MPins Date: Fri, 27 Mar 2026 13:35:05 -0300 Subject: [PATCH 07/10] lnwallet+htlcswitch: add fuzz-friendly signer and sig verifier hook Introduce fuzzSigner and fuzzSigVerifier in the fuzz harness, along with the SigVerifier hook in LightningChannel (WithSigVerifier, verifySig) and a matching SigPool extension (VerifyFunc field) so the harness can bypass secp256k1 verification end-to-end. Also refactors createTestChannel to accept functional options (testChannelOpt) so the signer and channel options can be injected from tests. --- htlcswitch/fuzz_link_test.go | 93 ++++++++++++++++++++++++++++++++++++ htlcswitch/test_utils.go | 72 +++++++++++++++++++++------- lnwallet/channel.go | 26 +++++++++- lnwallet/mock.go | 21 ++++++++ lnwallet/sigpool.go | 15 +++++- 5 files changed, 209 insertions(+), 18 deletions(-) diff --git a/htlcswitch/fuzz_link_test.go b/htlcswitch/fuzz_link_test.go index e282ad5d0e6..aaa01cdcbd7 100644 --- a/htlcswitch/fuzz_link_test.go +++ b/htlcswitch/fuzz_link_test.go @@ -1,6 +1,7 @@ package htlcswitch import ( + "bytes" "context" "crypto/sha256" "fmt" @@ -10,16 +11,95 @@ import ( "testing" "time" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/ecdsa" "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" "github.com/lightningnetwork/lnd/fn/v2" "github.com/lightningnetwork/lnd/htlcswitch/hop" + "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/invoices" "github.com/lightningnetwork/lnd/lntypes" + "github.com/lightningnetwork/lnd/lnwallet" "github.com/lightningnetwork/lnd/lnwallet/chainfee" "github.com/lightningnetwork/lnd/lnwire" "github.com/stretchr/testify/require" ) +// fuzzScalar returns a 32-byte scalar derived from sigHash with three +// invariants that guarantee a clean round-trip through ecdsa.ParseDERSignature +// and the lnwire.Sig 64-byte compact encoding: +// +// 1. s[0] != 0x00 — extractCanonicalPadding always keeps all 32 bytes, +// so the DER layout is fixed: 0x30 ?? 02 01 01 02 20 [32 bytes]. +// 2. s[0] < 0x80 — no DER sign-extension 0x00 prefix needed. +// 3. s < 2^254 << N/2 — ParseDERSignature never normalizes s to N-s. +// +// Achieved by: clear the top two bits of s[0] and set bit 0. +// Result: s[0] ∈ {0x01,0x03,…,0x3f}, no secp256k1 arithmetic needed. +func fuzzScalar(sigHash []byte) [32]byte { + s := sha256.Sum256(sigHash) + s[0] = s[0]&0x3f | 0x01 + return s +} + +// fuzzDERSig builds a minimal DER-encoded ECDSA signature with r=1 and +// s=fuzzScalar(sigHash). Both r and s are small positives so no sign-extension +// padding is needed. ecdsa.ParseDERSignature accepts the result unchanged. +func fuzzDERSig(sigHash []byte) []byte { + s := fuzzScalar(sigHash) + var inner []byte + inner = append(inner, 0x02, 0x01, 0x01) // r = 1 + inner = append(inner, 0x02, 0x20) // s tag + 32-byte length + inner = append(inner, s[:]...) // s value + + return append([]byte{0x30, byte(len(inner))}, inner...) +} + +// fuzzSigner embeds MockSigner to satisfy input.Signer (MuSig2 methods, +// ComputeInputScript) but overrides SignOutputRaw with a trivial scheme: +// r=1, s=fuzzScalar(sigHash). Zero secp256k1 point-multiplication. +// Returns a real *ecdsa.Signature so lnwire.NewSigFromSignature accepts it. +type fuzzSigner struct { + *input.MockSigner +} + +func (f *fuzzSigner) SignOutputRaw(tx *wire.MsgTx, + signDesc *input.SignDescriptor) (input.Signature, error) { + + sigHash, err := txscript.CalcWitnessSigHash( + signDesc.WitnessScript, signDesc.SigHashes, signDesc.HashType, + tx, signDesc.InputIndex, signDesc.Output.Value, + ) + if err != nil { + return nil, err + } + + return ecdsa.ParseDERSignature(fuzzDERSig(sigHash)) +} + +// fuzzSigVerifier is the paired verifier for fuzzSigner. It extracts s from +// the DER-serialized signature (preserved through the lnwire round-trip) and +// checks s == fuzzScalar(sigHash). +func fuzzSigVerifier(sig input.Signature, sigHash []byte, + _ *btcec.PublicKey) bool { + + expected := fuzzScalar(sigHash) + + // DER layout after round-trip: 0x30 [len] 0x02 0x01 0x01 0x02 0x20 + // [32 bytes s] fuzzScalar guarantees s[0] < 0x40, so no DER + // sign-extension byte is ever added and the s field is always exactly + // 32 bytes. + der := sig.Serialize() + if len(der) < 7+32 { + return false + } + sBytes := der[7 : 7+32] + + return bytes.Equal(sBytes, expected[:]) +} + type Event uint8 const ( @@ -135,8 +215,21 @@ func newFuzzFSM(t *testing.T, channelSize, aliceShareGen uint64) *fuzzFSM { blockHeight := 100 + // Create lightning channels using the trivial fuzz signer so that + // secp256k1 ECDSA is never called during fuzzing (big CPU win). + mkFuzzSigner := func(k *btcec.PrivateKey) input.Signer { + return &fuzzSigner{ + MockSigner: input.NewMockSigner( + []*btcec.PrivateKey{k}, nil, + ), + } + } alice, bob, err := createTestChannel(t, alicePrivKey, bobPrivKey, aliceAmount, bobAmount, aliceReserve, bobReserve, SchanID, + withTestSignerFactory(mkFuzzSigner), + withTestChanOpts( + lnwallet.WithSigVerifier(fuzzSigVerifier), + ), ) require.NoError(t, err) diff --git a/htlcswitch/test_utils.go b/htlcswitch/test_utils.go index 41c411f3002..53be2422700 100644 --- a/htlcswitch/test_utils.go +++ b/htlcswitch/test_utils.go @@ -131,11 +131,44 @@ type testLightningChannel struct { // representations. // // TODO(roasbeef): need to factor out, similar func re-used in many parts of codebase +// testChannelConfig holds optional overrides for createTestChannel. +type testChannelConfig struct { + signerFactory func(*btcec.PrivateKey) input.Signer + chanOpts []lnwallet.ChannelOpt +} + +// testChannelOpt is a functional option for createTestChannel. +type testChannelOpt func(*testChannelConfig) + +// withTestSignerFactory overrides the signer used for both Alice and Bob. +func withTestSignerFactory(f func(*btcec.PrivateKey) input.Signer) testChannelOpt { //nolint + return func(c *testChannelConfig) { + c.signerFactory = f + } +} + +// withTestChanOpts appends extra ChannelOpts passed to NewLightningChannel. +func withTestChanOpts(opts ...lnwallet.ChannelOpt) testChannelOpt { + return func(c *testChannelConfig) { + c.chanOpts = append(c.chanOpts, opts...) + } +} + func createTestChannel(t *testing.T, alicePrivKey, bobPrivKey []byte, aliceAmount, bobAmount, aliceReserve, bobReserve btcutil.Amount, - chanID lnwire.ShortChannelID) (*testLightningChannel, + chanID lnwire.ShortChannelID, + opts ...testChannelOpt) (*testLightningChannel, *testLightningChannel, error) { + cfg := &testChannelConfig{ + signerFactory: func(k *btcec.PrivateKey) input.Signer { + return input.NewMockSigner([]*btcec.PrivateKey{k}, nil) + }, + } + for _, o := range opts { + o(cfg) + } + aliceKeyPriv, aliceKeyPub := btcec.PrivKeyFromBytes(alicePrivKey) bobKeyPriv, bobKeyPub := btcec.PrivKeyFromBytes(bobPrivKey) @@ -336,19 +369,23 @@ func createTestChannel(t *testing.T, alicePrivKey, bobPrivKey []byte, return nil, nil, err } - aliceSigner := input.NewMockSigner( - []*btcec.PrivateKey{aliceKeyPriv}, nil, - ) - bobSigner := input.NewMockSigner( - []*btcec.PrivateKey{bobKeyPriv}, nil, - ) + aliceSigner := cfg.signerFactory(aliceKeyPriv) + bobSigner := cfg.signerFactory(bobKeyPriv) - alicePool := lnwallet.NewSigPool(runtime.NumCPU(), aliceSigner) signerMock := lnwallet.NewDefaultAuxSignerMock(t) - channelAlice, err := lnwallet.NewLightningChannel( - aliceSigner, aliceChannelState, alicePool, + baseOpts := []lnwallet.ChannelOpt{ lnwallet.WithLeafStore(&lnwallet.MockAuxLeafStore{}), lnwallet.WithAuxSigner(signerMock), + } + chanOptsAlice := baseOpts + chanOptsAlice = append(chanOptsAlice, cfg.chanOpts...) + chanOptsBob := baseOpts + chanOptsBob = append(chanOptsBob, cfg.chanOpts...) + + alicePool := lnwallet.NewSigPool(runtime.NumCPU(), aliceSigner) + channelAlice, err := lnwallet.NewLightningChannel( + aliceSigner, aliceChannelState, alicePool, + chanOptsAlice..., ) if err != nil { return nil, nil, err @@ -358,8 +395,7 @@ func createTestChannel(t *testing.T, alicePrivKey, bobPrivKey []byte, bobPool := lnwallet.NewSigPool(runtime.NumCPU(), bobSigner) channelBob, err := lnwallet.NewLightningChannel( bobSigner, bobChannelState, bobPool, - lnwallet.WithLeafStore(&lnwallet.MockAuxLeafStore{}), - lnwallet.WithAuxSigner(signerMock), + chanOptsBob..., ) if err != nil { return nil, nil, err @@ -417,8 +453,10 @@ func createTestChannel(t *testing.T, alicePrivKey, bobPrivKey []byte, newAliceChannel, err := lnwallet.NewLightningChannel( aliceSigner, aliceStoredChannel, alicePool, - lnwallet.WithLeafStore(&lnwallet.MockAuxLeafStore{}), - lnwallet.WithAuxSigner(signerMock), + append([]lnwallet.ChannelOpt{ + lnwallet.WithLeafStore(&lnwallet.MockAuxLeafStore{}), //nolint:ll + lnwallet.WithAuxSigner(signerMock), + }, cfg.chanOpts...)..., ) if err != nil { return nil, fmt.Errorf("unable to create new "+ @@ -465,8 +503,10 @@ func createTestChannel(t *testing.T, alicePrivKey, bobPrivKey []byte, newBobChannel, err := lnwallet.NewLightningChannel( bobSigner, bobStoredChannel, bobPool, - lnwallet.WithLeafStore(&lnwallet.MockAuxLeafStore{}), - lnwallet.WithAuxSigner(signerMock), + append([]lnwallet.ChannelOpt{ + lnwallet.WithLeafStore(&lnwallet.MockAuxLeafStore{}), //nolint:ll + lnwallet.WithAuxSigner(signerMock), + }, cfg.chanOpts...)..., ) if err != nil { return nil, fmt.Errorf("unable to create new "+ diff --git a/lnwallet/channel.go b/lnwallet/channel.go index 02f5f9ccff4..faddf9007b4 100644 --- a/lnwallet/channel.go +++ b/lnwallet/channel.go @@ -860,6 +860,10 @@ type channelOpts struct { // validation on HTLCs before they are added to the channel state. auxHtlcValidator fn.Option[AuxHtlcValidator] + // sigVerifier is an optional custom signature verifier. If nil, the + // standard sig.Verify method is used. + sigVerifier SigVerifier + skipNonceInit bool } @@ -926,6 +930,18 @@ func defaultChannelOpts() *channelOpts { return &channelOpts{} } +// verifySig verifies a signature using the injected SigVerifier if one is +// configured, or falls back to the standard sig.Verify method. +func (lc *LightningChannel) verifySig(sig input.Signature, sigHash []byte, + pubKey *btcec.PublicKey) bool { + + if lc.opts.sigVerifier != nil { + return lc.opts.sigVerifier(sig, sigHash, pubKey) + } + + return sig.Verify(sigHash, pubKey) +} + // NewLightningChannel creates a new, active payment channel given an // implementation of the chain notifier, channel database, and the current // settled channel state. Throughout state transitions, then channel will @@ -5398,6 +5414,14 @@ func (lc *LightningChannel) ReceiveNewCommitment(commitSigs *CommitSigs) error { return err } + // If a custom sig verifier is configured, propagate it to every HTLC + // verify job so the SigPool workers use the same scheme. + if lc.opts.sigVerifier != nil { + for i := range verifyJobs { + verifyJobs[i].VerifyFunc = lc.opts.sigVerifier + } + } + cancelChan := make(chan struct{}) verifyResps := lc.sigPool.SubmitVerifyBatch(verifyJobs, cancelChan) @@ -5489,7 +5513,7 @@ func (lc *LightningChannel) ReceiveNewCommitment(commitSigs *CommitSigs) error { if err != nil { return err } - if !cSig.Verify(sigHash, verifyKey) { + if !lc.verifySig(cSig, sigHash, verifyKey) { close(cancelChan) // If we fail to validate their commitment signature, diff --git a/lnwallet/mock.go b/lnwallet/mock.go index 39e520d2760..99576c14969 100644 --- a/lnwallet/mock.go +++ b/lnwallet/mock.go @@ -19,6 +19,7 @@ import ( "github.com/lightningnetwork/lnd/chainntnfs" "github.com/lightningnetwork/lnd/channeldb" "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/lntypes" "github.com/lightningnetwork/lnd/lnwallet/chainfee" "github.com/lightningnetwork/lnd/tlv" @@ -520,3 +521,23 @@ func (*MockAuxContractResolver) ResolveContract( return fn.Ok[tlv.Blob](nil) } + +// SigVerifier is an optional function that overrides the default ECDSA +// signature verification for commitment and HTLC signatures. Both the +// commitment sig (ReceiveNewCommitment) and HTLC sigs (SigPool) use this +// hook. When nil, the standard sig.Verify method is used. +// +// This is intended for testing scenarios (e.g. fuzz harnesses) that use a +// trivial signing scheme instead of secp256k1, allowing both sides to share +// the same fast sign+verify implementation without modifying production code. +type SigVerifier func(sig input.Signature, sigHash []byte, + pubKey *btcec.PublicKey) bool + +// WithSigVerifier injects a custom signature verifier into the channel, +// overriding the default secp256k1 ECDSA verification for both commitment and +// HTLC signatures. Both signing sides must use a consistent scheme. +func WithSigVerifier(v SigVerifier) ChannelOpt { + return func(o *channelOpts) { + o.sigVerifier = v + } +} diff --git a/lnwallet/sigpool.go b/lnwallet/sigpool.go index 2296e170317..40a86991e09 100644 --- a/lnwallet/sigpool.go +++ b/lnwallet/sigpool.go @@ -45,6 +45,12 @@ type VerifyJob struct { // party's update log. HtlcIndex uint64 + // VerifyFunc is an optional custom verification function. When set, it + // replaces the default sig.Verify call in the pool worker. This allows + // injecting alternative signing schemes (e.g. for fuzz testing) without + // modifying production verification logic. + VerifyFunc SigVerifier + // Cancel is a channel that is closed by the caller if they wish to // cancel all pending verification jobs part of a single batch. This // channel is closed in the case that a single signature in a batch has @@ -240,7 +246,14 @@ func (s *SigPool) poolWorker() { rawSig := verifyMsg.Sig - if !rawSig.Verify(sigHash, verifyMsg.PubKey) { + verify := rawSig.Verify + if verifyMsg.VerifyFunc != nil { + fn := verifyMsg.VerifyFunc + verify = func(h []byte, k *btcec.PublicKey) bool { //nolint + return fn(rawSig, h, k) + } + } + if !verify(sigHash, verifyMsg.PubKey) { err := fmt.Errorf("invalid signature "+ "sighash: %x, sig: %x", sigHash, rawSig.Serialize()) From e6e2493c84cfcd4311cd9fc7ad2247d2e4b53210 Mon Sep 17 00:00:00 2001 From: MPins Date: Tue, 31 Mar 2026 23:42:58 -0300 Subject: [PATCH 08/10] lnwallet+htlcswitch: add fuzz-friendly commitment key deriver hook Introduce CommitKeyDeriverFunc and WithCommitKeyDeriver to allow LightningChannel to bypass the secp256k1-based DeriveCommitmentKeys on every commit round. All internal call sites are migrated to lc.deriveCommitmentKeys. The fuzz harness injects fuzzCommitKeyDeriver, a trivial identity deriver that avoids scalar-multiplication overhead. --- htlcswitch/fuzz_link_test.go | 48 ++++++++++++++++++++++++++++++++++++ lnwallet/channel.go | 39 +++++++++++++++++++++++------ lnwallet/mock.go | 17 +++++++++++++ 3 files changed, 96 insertions(+), 8 deletions(-) diff --git a/htlcswitch/fuzz_link_test.go b/htlcswitch/fuzz_link_test.go index aaa01cdcbd7..be006a5bc10 100644 --- a/htlcswitch/fuzz_link_test.go +++ b/htlcswitch/fuzz_link_test.go @@ -16,6 +16,7 @@ import ( "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" + "github.com/lightningnetwork/lnd/channeldb" "github.com/lightningnetwork/lnd/fn/v2" "github.com/lightningnetwork/lnd/htlcswitch/hop" "github.com/lightningnetwork/lnd/input" @@ -100,6 +101,52 @@ func fuzzSigVerifier(sig input.Signature, sigHash []byte, return bytes.Equal(sBytes, expected[:]) } +// fuzzCommitKeyDeriver is a trivial CommitKeyDeriverFunc for fuzz harnesses. +// It mirrors the local/remote base-point selection of DeriveCommitmentKeys but +// returns the raw base points without any secp256k1 scalar multiplication, +// eliminating the ~30% CPU overhead of TweakPubKey/DeriveRevocationPubkey on +// every commit round. Both Alice and Bob call this with mirrored arguments and +// arrive at the same underlying public keys, so commitment tx scripts remain +// consistent across both sides. +func fuzzCommitKeyDeriver(commitPoint *btcec.PublicKey, + whoseCommit lntypes.ChannelParty, _ channeldb.ChannelType, localChanCfg, + remoteChanCfg *channeldb.ChannelConfig) *lnwallet.CommitmentKeyRing { + + localBasePoint := localChanCfg.PaymentBasePoint + if whoseCommit.IsLocal() { + localBasePoint = localChanCfg.DelayBasePoint + } + + var toLocalKey, toRemoteKey, revocationKey *btcec.PublicKey + if whoseCommit.IsLocal() { + toLocalKey = localChanCfg.DelayBasePoint.PubKey + toRemoteKey = remoteChanCfg.PaymentBasePoint.PubKey + revocationKey = remoteChanCfg.RevocationBasePoint.PubKey + } else { + toLocalKey = remoteChanCfg.DelayBasePoint.PubKey + toRemoteKey = localChanCfg.PaymentBasePoint.PubKey + revocationKey = localChanCfg.RevocationBasePoint.PubKey + } + + return &lnwallet.CommitmentKeyRing{ + CommitPoint: commitPoint, + // Tweaks are cheap (just SHA256), keep them accurate. + LocalCommitKeyTweak: input.SingleTweakBytes( + commitPoint, localBasePoint.PubKey, + ), + LocalHtlcKeyTweak: input.SingleTweakBytes( + commitPoint, localChanCfg.HtlcBasePoint.PubKey, + ), + // Skip TweakPubKey/DeriveRevocationPubkey — return base points + // directly to avoid secp256k1 scalar multiplications. + LocalHtlcKey: localChanCfg.HtlcBasePoint.PubKey, + RemoteHtlcKey: remoteChanCfg.HtlcBasePoint.PubKey, + ToLocalKey: toLocalKey, + ToRemoteKey: toRemoteKey, + RevocationKey: revocationKey, + } +} + type Event uint8 const ( @@ -229,6 +276,7 @@ func newFuzzFSM(t *testing.T, channelSize, aliceShareGen uint64) *fuzzFSM { withTestSignerFactory(mkFuzzSigner), withTestChanOpts( lnwallet.WithSigVerifier(fuzzSigVerifier), + lnwallet.WithCommitKeyDeriver(fuzzCommitKeyDeriver), ), ) require.NoError(t, err) diff --git a/lnwallet/channel.go b/lnwallet/channel.go index faddf9007b4..217d27fb0da 100644 --- a/lnwallet/channel.go +++ b/lnwallet/channel.go @@ -647,7 +647,7 @@ func (lc *LightningChannel) diskCommitToMemCommit( // haven't yet received a responding commitment from the remote party. var commitKeys lntypes.Dual[*CommitmentKeyRing] if localCommitPoint != nil { - commitKeys.SetForParty(lntypes.Local, DeriveCommitmentKeys( + commitKeys.SetForParty(lntypes.Local, lc.deriveCommitmentKeys( localCommitPoint, lntypes.Local, lc.channelState.ChanType, &lc.channelState.LocalChanCfg, @@ -655,7 +655,7 @@ func (lc *LightningChannel) diskCommitToMemCommit( )) } if remoteCommitPoint != nil { - commitKeys.SetForParty(lntypes.Remote, DeriveCommitmentKeys( + commitKeys.SetForParty(lntypes.Remote, lc.deriveCommitmentKeys( remoteCommitPoint, lntypes.Remote, lc.channelState.ChanType, &lc.channelState.LocalChanCfg, @@ -864,6 +864,10 @@ type channelOpts struct { // standard sig.Verify method is used. sigVerifier SigVerifier + // commitKeyDeriver is an optional override for DeriveCommitmentKeys. + // When nil, the real secp256k1-based function is used. + commitKeyDeriver CommitKeyDeriverFunc + skipNonceInit bool } @@ -942,6 +946,25 @@ func (lc *LightningChannel) verifySig(sig input.Signature, sigHash []byte, return sig.Verify(sigHash, pubKey) } +// deriveCommitmentKeys calls the injected CommitKeyDeriverFunc if one is set, +// otherwise falls back to the real secp256k1-based DeriveCommitmentKeys. +func (lc *LightningChannel) deriveCommitmentKeys(commitPoint *btcec.PublicKey, + whoseCommit lntypes.ChannelParty, chanType channeldb.ChannelType, + localChanCfg, remoteChanCfg *channeldb.ChannelConfig) *CommitmentKeyRing { //nolint:ll + + if lc.opts.commitKeyDeriver != nil { + return lc.opts.commitKeyDeriver( + commitPoint, whoseCommit, chanType, + localChanCfg, remoteChanCfg, + ) + } + + return DeriveCommitmentKeys( + commitPoint, whoseCommit, chanType, + localChanCfg, remoteChanCfg, + ) +} + // NewLightningChannel creates a new, active payment channel given an // implementation of the chain notifier, channel database, and the current // settled channel state. Throughout state transitions, then channel will @@ -1565,7 +1588,7 @@ func (lc *LightningChannel) restoreCommitState( // We'll also re-create the set of commitment keys needed to // fully re-derive the state. - pendingRemoteKeyChain = DeriveCommitmentKeys( + pendingRemoteKeyChain = lc.deriveCommitmentKeys( pendingCommitPoint, lntypes.Remote, lc.channelState.ChanType, &lc.channelState.LocalChanCfg, @@ -4164,7 +4187,7 @@ func (lc *LightningChannel) SignNextCommitment( // Grab the next commitment point for the remote party. This will be // used within fetchCommitmentView to derive all the keys necessary to // construct the commitment state. - keyRing := DeriveCommitmentKeys( + keyRing := lc.deriveCommitmentKeys( commitPoint, lntypes.Remote, lc.channelState.ChanType, &lc.channelState.LocalChanCfg, &lc.channelState.RemoteChanCfg, ) @@ -5365,7 +5388,7 @@ func (lc *LightningChannel) ReceiveNewCommitment(commitSigs *CommitSigs) error { return err } commitPoint := input.ComputeCommitmentPoint(commitSecret[:]) - keyRing := DeriveCommitmentKeys( + keyRing := lc.deriveCommitmentKeys( commitPoint, lntypes.Local, lc.channelState.ChanType, &lc.channelState.LocalChanCfg, &lc.channelState.RemoteChanCfg, ) @@ -8901,7 +8924,7 @@ func (lc *LightningChannel) NewAnchorResolutions() (*AnchorResolutions, return nil, err } localCommitPoint := input.ComputeCommitmentPoint(revocation[:]) - localKeyRing := DeriveCommitmentKeys( + localKeyRing := lc.deriveCommitmentKeys( localCommitPoint, lntypes.Local, lc.channelState.ChanType, &lc.channelState.LocalChanCfg, &lc.channelState.RemoteChanCfg, ) @@ -8915,7 +8938,7 @@ func (lc *LightningChannel) NewAnchorResolutions() (*AnchorResolutions, resolutions.Local = localRes // Add anchor for remote commitment tx, if any. - remoteKeyRing := DeriveCommitmentKeys( + remoteKeyRing := lc.deriveCommitmentKeys( lc.channelState.RemoteCurrentRevocation, lntypes.Remote, lc.channelState.ChanType, &lc.channelState.LocalChanCfg, &lc.channelState.RemoteChanCfg, @@ -8936,7 +8959,7 @@ func (lc *LightningChannel) NewAnchorResolutions() (*AnchorResolutions, } if remotePendingCommit != nil { - pendingRemoteKeyRing := DeriveCommitmentKeys( + pendingRemoteKeyRing := lc.deriveCommitmentKeys( lc.channelState.RemoteNextRevocation, lntypes.Remote, lc.channelState.ChanType, &lc.channelState.LocalChanCfg, &lc.channelState.RemoteChanCfg, diff --git a/lnwallet/mock.go b/lnwallet/mock.go index 99576c14969..db24ba8953a 100644 --- a/lnwallet/mock.go +++ b/lnwallet/mock.go @@ -541,3 +541,20 @@ func WithSigVerifier(v SigVerifier) ChannelOpt { o.sigVerifier = v } } + +// CommitKeyDeriverFunc is an optional function that overrides +// DeriveCommitmentKeys inside LightningChannel. When nil, the real +// secp256k1-based derivation is used. Inject a trivial version in fuzz/test +// harnesses to avoid scalar-multiplication overhead on every commit round. +type CommitKeyDeriverFunc func(commitPoint *btcec.PublicKey, + whoseCommit lntypes.ChannelParty, chanType channeldb.ChannelType, + localChanCfg, remoteChanCfg *channeldb.ChannelConfig) *CommitmentKeyRing + +// WithCommitKeyDeriver injects a custom commitment key derivation function, +// overriding the default secp256k1-based DeriveCommitmentKeys on every commit +// round. Intended for fuzz/test harnesses that need to avoid scalar-mult cost. +func WithCommitKeyDeriver(fn CommitKeyDeriverFunc) ChannelOpt { + return func(o *channelOpts) { + o.commitKeyDeriver = fn + } +} From a742696698518ad6ce9b4e2b0d802c6483c46217 Mon Sep 17 00:00:00 2001 From: MPins Date: Fri, 10 Apr 2026 14:46:25 -0300 Subject: [PATCH 09/10] htlcswitch: stop SigPools in createTestChannel cleanup createTestChannel started alicePool and bobPool but never stopped them. During fuzzing this caused goroutines to leak per. Register t.Cleanup handlers to call Stop() on both pools so all workers are torn down when the test ends. --- htlcswitch/test_utils.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/htlcswitch/test_utils.go b/htlcswitch/test_utils.go index 53be2422700..0238b5288c9 100644 --- a/htlcswitch/test_utils.go +++ b/htlcswitch/test_utils.go @@ -391,6 +391,7 @@ func createTestChannel(t *testing.T, alicePrivKey, bobPrivKey []byte, return nil, nil, err } alicePool.Start() + t.Cleanup(func() { require.NoError(t, alicePool.Stop()) }) bobPool := lnwallet.NewSigPool(runtime.NumCPU(), bobSigner) channelBob, err := lnwallet.NewLightningChannel( @@ -401,6 +402,7 @@ func createTestChannel(t *testing.T, alicePrivKey, bobPrivKey []byte, return nil, nil, err } bobPool.Start() + t.Cleanup(func() { require.NoError(t, bobPool.Stop()) }) // Now that the channel are open, simulate the start of a session by // having Alice and Bob extend their revocation windows to each other. From d8ef428673cd4963fbd314634921d0c8b033c28a Mon Sep 17 00:00:00 2001 From: MPins Date: Fri, 10 Apr 2026 14:48:18 -0300 Subject: [PATCH 10/10] htlcswitch: stop InvoiceRegistry in newMockRegistry cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit newMockRegistry started an InvoiceRegistry but never stopped it. InvoiceRegistry internally starts two background goroutines — invoiceEventLoop and the InvoiceExpiryWatcher mainLoop — that run for the lifetime of the registry. Without a matching Stop() call both goroutines leaked for every test that called newMockRegistry, accumulating thousands of goroutines during fuzzing. Register a t.Cleanup to call registry.Stop() so both loops are torn down when the test ends. --- htlcswitch/mock.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/htlcswitch/mock.go b/htlcswitch/mock.go index 53d2743db11..7a90f0583e7 100644 --- a/htlcswitch/mock.go +++ b/htlcswitch/mock.go @@ -1019,6 +1019,11 @@ func newMockRegistry(t testing.TB) *mockInvoiceRegistry { }, ) registry.Start() + t.Cleanup(func() { + if err := registry.Stop(); err != nil { + t.Errorf("registry.Stop: %v", err) + } + }) return &mockInvoiceRegistry{ registry: registry,