From a4a3279d6f2f09db254c6339cc913e84c477e7d5 Mon Sep 17 00:00:00 2001 From: MPins Date: Thu, 12 Mar 2026 07:01:03 -0300 Subject: [PATCH 01/11] 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 93a3a72925eb1e1ce76423ac69fb2ab59e746b2f Mon Sep 17 00:00:00 2001 From: MPins Date: Thu, 12 Mar 2026 07:10:19 -0300 Subject: [PATCH 02/11] 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 | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/htlcswitch/link_test.go b/htlcswitch/link_test.go index 29b4f902d0b..694bc71503f 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,44 @@ 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, + preimageSeed uint64) (*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 + var buf [8]byte + binary.BigEndian.PutUint64(buf[:], preimageSeed) + preimage = sha256.Sum256(buf[:]) + + 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 49187693376be19d370368eb19734af4168ee950 Mon Sep 17 00:00:00 2001 From: MPins Date: Fri, 27 Mar 2026 13:15:56 -0300 Subject: [PATCH 03/11] 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 a97aea04b1f5044ee0202cfec555b61d55b57954 Mon Sep 17 00:00:00 2001 From: MPins Date: Sun, 29 Mar 2026 16:41:40 -0300 Subject: [PATCH 04/11] 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 | 104 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/htlcswitch/test_utils.go b/htlcswitch/test_utils.go index 2e084250943..84143ccd111 100644 --- a/htlcswitch/test_utils.go +++ b/htlcswitch/test_utils.go @@ -1202,6 +1202,110 @@ 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, + maxFeeExposure lnwire.MilliSatoshi, + maxFeeAllocation float64, +) (*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: maxFeeAllocation, + MaxFeeExposure: maxFeeExposure, + 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 aa1adc88297968a83ba756a78504689459ac9937 Mon Sep 17 00:00:00 2001 From: MPins Date: Tue, 21 Apr 2026 10:47:44 -0300 Subject: [PATCH 05/11] 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 a61000e98114957e27eb4c3f33f960c3979290ac Mon Sep 17 00:00:00 2001 From: MPins Date: Wed, 13 May 2026 17:57:15 -0300 Subject: [PATCH 06/11] htlcswitch: add onion-failure injection knobs to mock decoder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a one-shot nextOnionFailMode flag on mockIteratorDecoder and matching payloadFail / extractFail fields on mockHopIterator so that fuzz and unit tests can deterministically exercise the three error branches of channelLink.processRemoteAdds: - onionFailDecode → DecodeHopIterator returns a non-CodeNone failcode (CodeTemporaryChannelFailure). - onionFailPayload → HopPayload returns hop.ErrInvalidPayload. - onionFailExtract → ExtractErrorEncrypter returns a non-CodeNone failcode (CodeInvalidOnionVersion). The flag is consumed and cleared on each DecodeHopIterator call so it affects exactly one HTLC. Default behaviour is unchanged when no mode is armed, so existing callers see no difference. newMockHopIterator now returns *mockHopIterator (instead of hop.Iterator) so the decoder can set the per-iterator failure flags after construction; the concrete type still satisfies hop.Iterator and the only external caller in test_utils.go is unaffected. --- htlcswitch/mock.go | 60 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 58 insertions(+), 2 deletions(-) diff --git a/htlcswitch/mock.go b/htlcswitch/mock.go index 53d2743db11..b81ac8abe52 100644 --- a/htlcswitch/mock.go +++ b/htlcswitch/mock.go @@ -33,6 +33,7 @@ import ( "github.com/lightningnetwork/lnd/lntypes" "github.com/lightningnetwork/lnd/lnwallet/chainfee" "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/record" "github.com/lightningnetwork/lnd/ticker" "github.com/lightningnetwork/lnd/tlv" ) @@ -321,17 +322,45 @@ func (s *mockServer) QuitSignal() <-chan struct{} { return s.quit } +// onionFailMode selects which branch of processRemoteAdds the mock onion +// pipeline should fail in. Used by fuzz/test harnesses to exercise the +// error-handling paths of processRemoteAdds. +type onionFailMode int + +const ( + onionFailNone onionFailMode = 0 + onionFailDecode onionFailMode = 1 + onionFailPayload onionFailMode = 2 + onionFailExtract onionFailMode = 3 +) + // mockHopIterator represents the test version of hop iterator which instead // of encrypting the path in onion blob just stores the path as a list of hops. type mockHopIterator struct { hops []*hop.Payload + + // payloadFail, when true, makes HopPayload return a + // hop.ErrInvalidPayload instead of the next hop. Used by the bad-onion + // fuzz event. + payloadFail bool + + // extractFail, when true, makes ExtractErrorEncrypter return a + // non-CodeNone failcode. Used by the bad-onion fuzz event. + extractFail bool } -func newMockHopIterator(hops ...*hop.Payload) hop.Iterator { +func newMockHopIterator(hops ...*hop.Payload) *mockHopIterator { return &mockHopIterator{hops: hops} } func (r *mockHopIterator) HopPayload() (*hop.Payload, hop.RouteRole, error) { + if r.payloadFail { + return nil, hop.RouteRoleCleartext, hop.ErrInvalidPayload{ + Type: record.AmtOnionType, + Violation: hop.OmittedViolation, + FinalHop: true, + } + } h := r.hops[0] r.hops = r.hops[1:] return h, hop.RouteRoleCleartext, nil @@ -345,6 +374,9 @@ func (r *mockHopIterator) ExtractErrorEncrypter( extracter hop.ErrorEncrypterExtracter, _ bool) (hop.ErrorEncrypter, lnwire.FailCode) { + if r.extractFail { + return nil, lnwire.CodeInvalidOnionVersion + } return extracter(nil) } @@ -482,6 +514,11 @@ type mockIteratorDecoder struct { responses map[[32]byte][]hop.DecodeHopIteratorResponse decodeFail bool + + // nextOnionFailMode, when non-zero, makes the next DecodeHopIterator + // call produce an iterator that fails in the matching processRemoteAdds + // branch. The flag is one-shot: it is cleared after being consumed. + nextOnionFailMode onionFailMode } func newMockIteratorDecoder() *mockIteratorDecoder { @@ -493,6 +530,17 @@ func newMockIteratorDecoder() *mockIteratorDecoder { func (p *mockIteratorDecoder) DecodeHopIterator(r io.Reader, rHash []byte, cltv uint32) (hop.Iterator, lnwire.FailCode) { + // Consume any pending one-shot fail-mode set by the bad-onion fuzz + // event. The mode applies to this single decode call only. + p.mu.Lock() + mode := p.nextOnionFailMode + p.nextOnionFailMode = onionFailNone + p.mu.Unlock() + + if mode == onionFailDecode { + return nil, lnwire.CodeTemporaryChannelFailure + } + var b [4]byte _, err := r.Read(b[:]) if err != nil { @@ -518,7 +566,15 @@ func (p *mockIteratorDecoder) DecodeHopIterator(r io.Reader, rHash []byte, }) } - return newMockHopIterator(hops...), lnwire.CodeNone + iterator := newMockHopIterator(hops...) + switch mode { + case onionFailPayload: + iterator.payloadFail = true + case onionFailExtract: + iterator.extractFail = true + } + + return iterator, lnwire.CodeNone } func (p *mockIteratorDecoder) DecodeHopIterators(id []byte, From 94e37ff63e3bc66d33c341a0886846679335d252 Mon Sep 17 00:00:00 2001 From: MPins Date: Thu, 12 Mar 2026 07:24:01 -0300 Subject: [PATCH 07/11] 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. --- htlcswitch/fuzz_link_test.go | 1680 ++++++++++++++++++++++++++++++++++ 1 file changed, 1680 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..c406cb6a22e --- /dev/null +++ b/htlcswitch/fuzz_link_test.go @@ -0,0 +1,1680 @@ +package htlcswitch + +import ( + "context" + "fmt" + "math" + "runtime" + "sort" + "strings" + "testing" + "time" + + "github.com/btcsuite/btcd/btcutil" + "github.com/lightningnetwork/lnd/channeldb" + "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" + "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 + EvAliceSendUpdateFee + EvBobSendUpdateFee + EvAliceInitQuiescence + EvBobInitQuiescence + EvResumeQuiescence + EvAliceRestartLink + EvBobRestartLink + EvAliceSendCommitNoWindow + EvBobSendCommitNoWindow + EvAliceSendWarning + EvBobSendWarning + EvAliceSendBadOnion + EvBobSendBadOnion + + NumEvents +) + +const MaxEventsPerRun = 500 + +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 + htlcRef uint64 + + // Monotonically-increasing attempt counters used to derive unique + // preimageSeed. + aliceHTLCAttempts uint64 + bobHTLCAttempts uint64 + + // restartSyncHeight, when non-zero, overrides NextLocalCommitHeight in + // the remote's ChannelReestablish during restartLink. + restartSyncHeight uint64 + + // maxFeeExposure is the per-link MaxFeeExposure threshold passed to + // newFuzzLink for both Alice and Bob (kept on the FSM so restartLink + // can rebuild a link with the same value). + maxFeeExposure lnwire.MilliSatoshi + + // maxFeeAllocation is the per-link MaxFeeAllocation fraction (0..1] + // of channel balance allowed for the commitment fee. Kept on the FSM so + // restartLink can rebuild a link with the same value. + maxFeeAllocation float64 + + // Height regression detection + aliceLocalHeight uint64 + aliceRemoteHeight uint64 + bobLocalHeight uint64 + bobRemoteHeight uint64 + heightsInit bool + + // Shadow balances. Updated immediately on every confirmed settle so the + // strong invariant in assertInvariants can detect misallocation between + // Alice and Bob. + expectedAliceMSat lnwire.MilliSatoshi + expectedBobMSat lnwire.MilliSatoshi + + // Settle round-trip tracking. After settleHTLC is called the HTLC stays + // in LocalCommitment.Htlcs until the next commit round completes on + // both sides; during that window the strong invariant needs to know + // which still-committed HTLCs have already been claimed. Keys are the + // sender's HtlcIndex (same value lnwallet stores in channeldb.HTLC). + // aliceSettlesPending: B→A HTLCs Alice has settled. + // bobSettlesPending: A→B HTLCs Bob has settled. + aliceSettlesPending map[uint64]struct{} + bobSettlesPending map[uint64]struct{} +} + +func newFuzzFSM(t *testing.T, channelSize, aliceShareGen, + maxFeeExposureGen, maxFeeAllocationGen 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{}), + } + + // Map maxFeeExposureGen to a per-link MaxFeeExposure threshold: + // gen == 0 → DefaultMaxFeeExposure (current harness behaviour) + // gen != 0 → [10_000, 750_000_000) mSAT, covering tight values + // that frequently trigger "fee threshold exceeded" up to + // loose values close to the default. + maxFeeExposure := DefaultMaxFeeExposure + if maxFeeExposureGen != 0 { + maxFeeExposure = lnwire.MilliSatoshi( + 10_000 + maxFeeExposureGen%(750_000_000-10_000), + ) + } + + // Map maxFeeAllocationGen to a per-link MaxFeeAllocation in (0, 1]: + // gen == 0 → DefaultMaxLinkFeeAllocation (current harness behaviour) + // gen != 0 → ((gen % 100) + 1) / 100.0 ∈ {0.01, …, 1.00} + maxFeeAllocation := DefaultMaxLinkFeeAllocation + if maxFeeAllocationGen != 0 { + maxFeeAllocation = float64(maxFeeAllocationGen%100+1) / 100.0 + } + + 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, + maxFeeExposure, maxFeeAllocation, + ) + bobLink, bobUpstream := hopNet.newFuzzLink( + t, bobPeer, bob.channel, bobDecoder, + bobRegistry, bobPCache, bobCircuits, bestHeight, + maxFeeExposure, maxFeeAllocation, + ) + + // 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, + maxFeeExposure: maxFeeExposure, + maxFeeAllocation: maxFeeAllocation, + expectedAliceMSat: lnwire.NewMSatFromSatoshis(aliceAmount), + expectedBobMSat: lnwire.NewMSatFromSatoshis(bobAmount), + aliceSettlesPending: make(map[uint64]struct{}), + bobSettlesPending: make(map[uint64]struct{}), + } +} + +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) + } + + // Strong invariant: detect silent fund misallocation between Alice and + // Bob. + // + // Each party's "claim" at any moment is the sum of: + // - LocalBalance on their local commitment. + // - CommitFee on their local commitment, only if they are the + // initiator (the initiator pays the on-chain fee, so those funds + // still belong to them). + // - Every HTLC in their local commitment whose funds *would still + // return to them* on resolution: + // * Incoming HTLC they have already settled → funds are theirs + // even though the commit round hasn't lifted the HTLC yet. + // * Outgoing HTLC the peer has NOT settled → refund possible. + // Incoming HTLCs they have not settled, and outgoing HTLCs the + // peer has already settled, are skipped: the funds belong to the + // other side. + // + // Compared against expectedAliceMSat / expectedBobMSat, which track the + // running "final settled balance" assuming every observed settle goes + // through. Any mismatch means funds were silently reassigned by the + // link. + + // Build presence sets so we can both look up direction membership and + // recognise when a pending settle has fully propagated. + aliceIncoming := make(map[uint64]struct{}) + aliceOutgoing := make(map[uint64]struct{}) + for _, h := range aliceChanState.LocalCommitment.Htlcs { + if h.Incoming { + aliceIncoming[h.HtlcIndex] = struct{}{} + } else { + aliceOutgoing[h.HtlcIndex] = struct{}{} + } + } + bobIncoming := make(map[uint64]struct{}) + bobOutgoing := make(map[uint64]struct{}) + for _, h := range bobChanState.LocalCommitment.Htlcs { + if h.Incoming { + bobIncoming[h.HtlcIndex] = struct{}{} + } else { + bobOutgoing[h.HtlcIndex] = struct{}{} + } + } + + // A pending settle is final once the HTLC has been dropped from both + // sides' LocalCommitment (the second commit round has landed). The + // new LocalBalance already reflects the settle, so we can stop carrying + // the pending entry. + for id := range f.aliceSettlesPending { + _, inAlice := aliceIncoming[id] + _, inBob := bobOutgoing[id] + if !inAlice && !inBob { + delete(f.aliceSettlesPending, id) + } + } + for id := range f.bobSettlesPending { + _, inBob := bobIncoming[id] + _, inAlice := aliceOutgoing[id] + if !inBob && !inAlice { + delete(f.bobSettlesPending, id) + } + } + + aliceClaim := aliceChanState.LocalCommitment.LocalBalance + if aliceChanState.IsInitiator { + aliceClaim += lnwire.NewMSatFromSatoshis( + aliceChanState.LocalCommitment.CommitFee, + ) + } + for _, h := range aliceChanState.LocalCommitment.Htlcs { + if h.Incoming { + // B→A HTLC: Alice's only if she has settled it. + if _, ok := f.aliceSettlesPending[h.HtlcIndex]; ok { + aliceClaim += h.Amt + } + } else { + // A→B HTLC: still Alice's unless Bob has settled. + if _, ok := f.bobSettlesPending[h.HtlcIndex]; !ok { + aliceClaim += h.Amt + } + } + } + require.Equal(f.t, f.expectedAliceMSat, aliceClaim, + "alice balance mismatch: expected=%v actual=%v", + f.expectedAliceMSat, aliceClaim) + + bobClaim := bobChanState.LocalCommitment.LocalBalance + if bobChanState.IsInitiator { + bobClaim += lnwire.NewMSatFromSatoshis( + bobChanState.LocalCommitment.CommitFee, + ) + } + for _, h := range bobChanState.LocalCommitment.Htlcs { + if h.Incoming { + // A→B HTLC: Bob's only if he has settled it. + if _, ok := f.bobSettlesPending[h.HtlcIndex]; ok { + bobClaim += h.Amt + } + } else { + // B→A HTLC: still Bob's unless Alice has settled. + if _, ok := f.aliceSettlesPending[h.HtlcIndex]; !ok { + bobClaim += h.Amt + } + } + } + require.Equal(f.t, f.expectedBobMSat, bobClaim, + "bob balance mismatch: expected=%v actual=%v", + f.expectedBobMSat, bobClaim) +} + +// 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", + // The remote's NextLocalCommitHeight is behind what we have + // already ACKed — the remote likely lost state. + "possible remote commitment state data loss", + // The remote's NextLocalCommitHeight is too far ahead — we + // cannot safely sync commit chains. + "unable to sync commit chains", + } + 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 + } + } +} + +// pickHTLCID selects an HTLC ID from preimages using htlcRef as an index, +// giving the fuzzer control over which pending HTLC gets resolved. +func (f *fuzzFSM) pickHTLCID(preimages map[uint64]lntypes.Preimage) uint64 { + ids := make([]uint64, 0, len(preimages)) + for id := range preimages { + ids = append(ids, id) + } + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + + return ids[f.htlcRef%uint64(len(ids))] +} + +// 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 + // preimageSeed derives a unique preimage per attempt. + var preimageSeed uint64 + switch sender { + case f.aliceLink: + senderCircuits = f.aliceCircuits + invoiceRegistry = f.bobRegistry + preimageSeed = MaxEventsPerRun + f.aliceHTLCAttempts + f.htlcRef + f.aliceHTLCAttempts++ + case f.bobLink: + senderCircuits = f.bobCircuits + invoiceRegistry = f.aliceRegistry + preimageSeed = MaxEventsPerRun*2 + f.bobHTLCAttempts + f.htlcRef + f.bobHTLCAttempts++ + default: + f.t.Fatal("HTLC sender does not exist") + } + + // HTLC amount is derived from the htlcRef fuzz input and bounded + // by the channel capacity. + maxHTLC := lnwire.MilliSatoshi(sender.channel.Capacity * 1000) + htlcAmt := lnwire.MilliSatoshi(f.htlcRef) % maxHTLC + + // HTLC preimage is derived from the htlcRef. + htlc, preimage, err := generateSingleHopHtlc( + f.t, htlcID, htlcAmt, preimageSeed, + ) + 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 +} + +// sendBadOnionHTLC sends an HTLC from sender after arming the receiver's +// onion decoder with a one-shot failure. The failure mode is picked from +// htlcRef so the fuzz corpus drives which branch of processRemoteAdds is +// exercised: +// - htlcRef%3 == 0 → onionFailDecode (DecodeHopIterator returns failcode) +// - htlcRef%3 == 1 → onionFailPayload (HopPayload returns ErrInvalidPayload) +// - htlcRef%3 == 2 → onionFailExtract (ExtractErrorEncrypter returns +// failcode) +// +// The HTLC is failed-back by the receiver rather than settled, so the preimage +// is not tracked in alice/bobPreimages. +func (f *fuzzFSM) sendBadOnionHTLC(sender *channelLink, + receiverDec *mockIteratorDecoder) { + + var ( + nextID *uint64 + who string + ) + switch sender { + case f.aliceLink: + nextID = &f.aliceNextHTLCID + who = "Alice" + case f.bobLink: + nextID = &f.bobNextHTLCID + who = "Bob" + default: + f.t.Fatal("HTLC sender does not exist") + } + + // Bad-onion HTLCs are sent normally and then failed back by the + // receiver, so they must be caped also by maxInflightHtlcs. + if active := len(sender.channel.ActiveHtlcs()); active >= + maxInflightHtlcs { + + f.t.Logf("%s Bad Onion Skipped: active HTLCs %d >= %d", who, + active, maxInflightHtlcs) + return + } + + mode := onionFailMode(f.htlcRef%3) + 1 + + receiverDec.mu.Lock() + receiverDec.nextOnionFailMode = mode + receiverDec.mu.Unlock() + + htlcID := *nextID + _, ok := f.sendHTLC(sender, htlcID) + if !ok { + // sendHTLC didn't go through — clear the armed flag so it + // doesn't fire on an unrelated future decode. + receiverDec.mu.Lock() + receiverDec.nextOnionFailMode = onionFailNone + receiverDec.mu.Unlock() + f.t.Logf("%s Bad Onion Skipped: channel full", who) + + return + } + *nextID++ + f.t.Logf("EV %s Send Bad Onion ID:%v mode:%v", who, htlcID, mode) +} + +// 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, + ) + + 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 +} + +// findLockedInAdd walks the settler/failer's fwdPkgs and returns the +// matching Add together with its AddRef. Returns ok=false when no fwdPkg +// contains the Add yet (i.e. the lock-in revoke round hasn't completed) — +// that is the correct signal that the HTLC isn't ready to be resolved. +func (f *fuzzFSM) findLockedInAdd(link *channelLink, + htlcID uint64) (*lnwire.UpdateAddHTLC, channeldb.AddRef, bool) { + + fwdPkgs, err := link.channel.LoadFwdPkgs() + if err != nil { + f.t.Fatalf("LoadFwdPkgs failed: %v", err) + } + for _, pkg := range fwdPkgs { + for i, lu := range pkg.Adds { + add, ok := lu.UpdateMsg.(*lnwire.UpdateAddHTLC) + if !ok { + continue + } + if add.ID == htlcID { + return add, channeldb.AddRef{ + Height: pkg.Height, + Index: uint16(i), + }, true + } + } + } + + return nil, channeldb.AddRef{}, false +} + +// settleHTLC settles an incoming HTLC on the settler's link via +// channelLink.settleHTLC. This exercises the real link settle path +// (SettleHTLC + UpdateFulfillHTLC + HtlcNotifier) and feeds in a valid +// fwdPkg sourceRef so AckAddHtlcs bookkeeping runs on the next commit. +// +// 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 (in a fwdPkg) before we can settle it. +// On success the original Add amount is returned so the caller can update the +// shadow balance accounting consumed by assertInvariants. +func (f *fuzzFSM) settleHTLC(link *channelLink, htlcID uint64, + preimage lntypes.Preimage) (lnwire.MilliSatoshi, bool) { + + add, sourceRef, ok := f.findLockedInAdd(link, htlcID) + if !ok { + f.t.Logf("settle skipped: HTLC %d not yet locked-in / no "+ + "fwdPkg", htlcID) + return 0, false + } + + if err := link.settleHTLC(preimage, htlcID, sourceRef); err != nil { + f.t.Logf("settle skipped: %v", err) + return 0, false + } + + return add.Amount, true +} + +// failHTLC fails an incoming locked-in HTLC on the failer's link via the +// real link fail path. The fail variant is picked from htlcRef so the fuzz +// corpus drives the choice: +// - htlcRef%2 == 0 → channelLink.sendHTLCError (regular UpdateFailHTLC +// with a TemporaryChannelFailure obfuscated by the mock encrypter). +// - htlcRef%2 == 1 → channelLink.sendMalformedHTLCError +// (UpdateFailMalformedHTLC with CodeInvalidOnionHmac). +// +// Both paths feed a real fwdPkg sourceRef into channel.FailHTLC / +// channel.MalformedFailHTLC so AckAddHtlcs bookkeeping runs on the next +// commit. +// +// Guard: the HTLC must be locked-in (present in a fwdPkg) before we can +// fail it. +func (f *fuzzFSM) failHTLC(link *channelLink, htlcID uint64) bool { + add, sourceRef, ok := f.findLockedInAdd(link, htlcID) + if !ok { + f.t.Logf("fail skipped: HTLC %d not yet locked-in / no fwdPkg", + htlcID) + return false + } + + if f.htlcRef%2 == 0 { + // Regular failure path. The mock obfuscator wraps the + // FailureMessage with a fake HMAC; the channel.FailHTLC call + // inside sendHTLCError logs but does not return an error, so + // we treat findLockedInAdd as the source of truth. + failure := NewLinkError(lnwire.NewTemporaryChannelFailure(nil)) + link.sendHTLCError( + *add, sourceRef, failure, NewMockObfuscator(), true, + ) + f.t.Logf("fail HTLC %d via sendHTLCError", htlcID) + + return true + } + + // Malformed failure path. The Add's onion blob from the fwdPkg + // becomes the ShaOnionBlob the sender sees on the wire. + link.sendMalformedHTLCError( + htlcID, lnwire.CodeInvalidOnionHmac, add.OnionBlob, &sourceRef, + ) + f.t.Logf("fail HTLC %d via sendMalformedHTLCError", htlcID) + + 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 +} + +// sendWarning emits an lnwire.Warning from sender's peer so that drainMessages +// delivers it to the other side's link. The payload alternates between +// printable ASCII and a binary blob (driven by htlcRef) to cover both branches +// of Warning.Warning(). +func (f *fuzzFSM) sendWarning(sender *channelLink) { + data := []byte(fmt.Sprintf("fuzz warning ref=%d", f.htlcRef)) + // Inject a binary blob every third call to cover the Warning.Warning() + // branch that returns the raw data instead of a string. + if f.htlcRef%3 == 0 { + data = append(data, 0xff, 0x00, 0xfe) + } + + err := sender.cfg.Peer.SendMessage(false, &lnwire.Warning{ + ChanID: sender.ChanID(), + Data: data, + }) + if err != nil { + f.t.Fatalf("failed to send Warning: %v", err) + } +} + +// 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) + + // When restartSyncHeight is non-zero, inject a mutated height so the + // fuzzer can reach syncChanStates paths that are unreachable with + // canonical messages. + if f.restartSyncHeight != 0 { + remoteSyncMsg.NextLocalCommitHeight = f.restartSyncHeight + } + + // 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, + f.maxFeeExposure, f.maxFeeAllocation, + ) + + // Pre-load the remote's reestablish so resumeLink can read it + // synchronously from upstream. + newUpstream <- remoteSyncMsg + + err = newLink.resumeLink(f.t.Context()) + if err != nil { + if f.restartSyncHeight == 0 { + // Canonical sync message — any error is a real bug. + require.NoError(f.t, err) + } + + // Simulate the disconnect the real peer would perform: send an + // Error to the remote so drainMessages can detect the failure + // via processRemoteError → bobLink.failed. + _ = peer.SendMessage(false, &lnwire.Error{ + Data: []byte(err.Error()), + }) + + return + } + + // 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 + } + + chosenID := f.pickHTLCID(f.alicePreimages) + preimage := f.alicePreimages[chosenID] + amt, ok := f.settleHTLC( + f.aliceLink, chosenID, preimage, + ) + if ok { + // B→A settle: Alice claims amt, Bob loses it. + f.expectedAliceMSat += amt + f.expectedBobMSat -= amt + f.aliceSettlesPending[chosenID] = struct{}{} + delete(f.alicePreimages, chosenID) + f.t.Logf("EV Alice Settle HTLC ID:%v amt:%v", + chosenID, amt) + + 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 + } + + chosenID := f.pickHTLCID(f.bobPreimages) + preimage := f.bobPreimages[chosenID] + amt, ok := f.settleHTLC(f.bobLink, chosenID, preimage) + if ok { + // A→B settle: Bob claims amt, Alice loses it. + f.expectedBobMSat += amt + f.expectedAliceMSat -= amt + f.bobSettlesPending[chosenID] = struct{}{} + delete(f.bobPreimages, chosenID) + f.t.Logf("EV Bob Settle HTLC ID:%v amt:%v", + chosenID, amt) + + 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 + } + + chosenID := f.pickHTLCID(f.alicePreimages) + ok := f.failHTLC(f.aliceLink, chosenID) + if ok { + delete(f.alicePreimages, chosenID) + f.t.Logf("EV Alice Fail HTLC ID:%v", chosenID) + 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 + } + + chosenID := f.pickHTLCID(f.bobPreimages) + ok := f.failHTLC(f.bobLink, chosenID) + if ok { + delete(f.bobPreimages, chosenID) + f.t.Logf("EV Bob Fail HTLC ID: %v", chosenID) + 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 EvAliceSendUpdateFee: + newFee := ((len(f.aliceLink.channel.ActiveHtlcs()))+ + int(f.htlcRef))*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()))+ + int(f.htlcRef))*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") + // Two back-to-back commits without draining Bob's revoke_and_ack + // exercise the ErrNoWindow. + case EvAliceSendCommitNoWindow: + p1, _ := f.sendCommitSig(f.aliceLink) + p2, _ := f.sendCommitSig(f.aliceLink) + f.t.Logf("EV Alice Send Commit NoWindow pending1=%d "+ + "pending2=%d", p1, p2) + case EvBobSendCommitNoWindow: + p1, _ := f.sendCommitSig(f.bobLink) + p2, _ := f.sendCommitSig(f.bobLink) + f.t.Logf("EV Bob Send Commit NoWindow pending1=%d pending2=%d", + p1, p2) + // BOLT #1 lets a peer signal a non-fatal protocol issue via Warning. + case EvAliceSendWarning: + f.sendWarning(f.aliceLink) + f.t.Log("EV Alice Send Warning") + case EvBobSendWarning: + f.sendWarning(f.bobLink) + f.t.Log("EV Bob Send Warning") + // Send an HTLC whose onion will fail to decode on the receiver side, + // exercising the three error branches in processRemoteAdds. The mode + // (decode / payload / extract) is picked from the fuzz corpus via + // htlcRef so the fuzzer explores all three paths. + case EvAliceSendBadOnion: + f.sendBadOnionHTLC(f.aliceLink, f.bobDecoder) + case EvBobSendBadOnion: + f.sendBadOnionHTLC(f.bobLink, f.aliceDecoder) + } +} + +// 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), uint64(0), uint64(0), + ) + + f.htlcRef = uint64(10_000_000) + + for _, evt := range events { + f.applyEvent(evt) + f.drainMessages() + f.assertInvariants() + } + } + + // runWithSyncHeight is like run but overrides NextLocalCommitHeight in + // the remote ChannelReestablish on every restart event, so that + // syncChanStates error paths are exercised deterministically. + runWithSyncHeight := func(t *testing.T, syncHeight uint64, + events []Event) { + + t.Helper() + + f := newFuzzFSM( + t, uint64(1_000_000), uint64(50), uint64(0), uint64(0), + ) + f.htlcRef = uint64(10_000_000) + f.restartSyncHeight = syncHeight + + for _, evt := range events { + f.applyEvent(evt) + f.drainMessages() + f.assertInvariants() + } + + require.True(t, f.terminated, + "expected link failure due to invalid sync height") + } + // 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, + EvBobSendUpdateFee, + EvAliceSendCommit, + EvBobSendCommit, + }) + }) + + // 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, + EvBobFailHtlc, + 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, + }) + }) + + // Alice restarts with a sync height below the remote tail — triggers + // ErrCommitSyncRemoteDataLoss in syncChanStates. + t.Run("alice_restart_sync_height_too_low", func(t *testing.T) { + runWithSyncHeight(t, 1, []Event{ + EvAliceSendAddHtlc, + EvAliceSendCommit, + EvAliceRestartLink, + }) + }) + + // Alice restarts with a sync height far above the remote tip — triggers + // ErrCannotSyncCommitChains in syncChanStates. + t.Run("alice_restart_sync_height_too_high", func(t *testing.T) { + runWithSyncHeight(t, math.MaxUint64, []Event{ + EvAliceSendAddHtlc, + EvAliceSendCommit, + EvAliceRestartLink, + }) + }) + + // 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, + }) + }) + + // Alice signs two commitments back-to-back without delivering Bob's + // revoke_and_ack in between. The second SignNextCommitment hits the + // ErrNoWindow path. + t.Run("alice_commit_no_window", func(t *testing.T) { + run(t, []Event{ + EvAliceSendAddHtlc, + EvAliceSendCommitNoWindow, + EvBobSettleHtlc, + EvBobSendCommit, + }) + }) + + // Same as above for Bob. + t.Run("bob_commit_no_window", func(t *testing.T) { + run(t, []Event{ + EvBobSendAddHtlc, + EvBobSendCommitNoWindow, + EvAliceSettleHtlc, + EvAliceSendCommit, + }) + }) + + // Warnings are non-fatal per BOLT #1. The link logs and keeps going. + t.Run("alice_warning_interleaved", func(t *testing.T) { + run(t, []Event{ + EvAliceSendAddHtlc, + EvAliceSendWarning, + EvAliceSendCommit, + EvBobSendWarning, + EvBobSettleHtlc, + EvBobSendCommit, + }) + }) + + // Direct assertion that the ErrNoWindow path is reachable: after one + // SignNextCommitment the remote chain is unacked, so a second call + // must return ErrNoWindow. This complements the scenarios above by + // pinning the precondition the new events rely on. + t.Run("sign_next_commitment_no_window", func(t *testing.T) { + f := newFuzzFSM( + t, uint64(1_000_000), uint64(50), uint64(0), uint64(0), + ) + f.htlcRef = uint64(10_000_000) + + f.applyEvent(EvAliceSendAddHtlc) + + _, err := f.aliceLink.channel.SignNextCommitment(t.Context()) + require.NoError(t, err) + + _, err = f.aliceLink.channel.SignNextCommitment(t.Context()) + require.ErrorIs(t, err, lnwallet.ErrNoWindow) + }) + + t.Run("all_events", func(t *testing.T) { + run(t, []Event{ + // Warm-up: warnings and traffic. + EvAliceSendWarning, + EvBobSendWarning, + EvAliceSendAddHtlc, + EvAliceSendAddHtlc, + EvAliceSendAddHtlc, + EvAliceRestartLink, + EvBobSendAddHtlc, + EvBobSendAddHtlc, + EvBobRestartLink, + EvBobSendUpdateFee, + EvAliceSendUpdateFee, + EvBobSendAddHtlc, + EvAliceSendCommitNoWindow, + EvBobSendCommit, + EvAliceInvalidHtlcSettlement, + EvBobInvalidHtlcSettlement, + EvAliceFailNonExistentHtlc, + EvBobFailNonExistentHtlc, + EvBobSendCommitNoWindow, + EvBobInitQuiescence, + EvBobSendCommit, + EvResumeQuiescence, + EvAliceInitQuiescence, + EvAliceFailHtlc, + EvBobFailHtlc, + EvAliceSettleHtlc, + EvBobSettleHtlc, + EvResumeQuiescence, + EvAliceSendCommit, + EvAliceSendAddHtlc, + EvBobSendAddHtlc, + EvAliceSendCommit, + EvAliceFailHtlc, + }) + }) + + // Bad-onion scenarios — each htlcRef value selects one of the three + // failure branches in processRemoteAdds (decode / payload / extract). + // Driving Alice→Bob and Bob→Alice for each mode runs the bad HTLC + // through a full add/commit/revoke cycle, so the receiver hits the + // targeted branch and fails the HTLC back via UpdateFailHTLC. + badOnionEvents := []Event{ + EvAliceSendBadOnion, + EvAliceSendCommit, + EvBobSendBadOnion, + EvBobSendCommit, + } + runWithHtlcRef := func(t *testing.T, htlcRef uint64, events []Event) { + t.Helper() + f := newFuzzFSM( + t, uint64(1_000_000), uint64(50), uint64(0), uint64(0), + ) + f.htlcRef = htlcRef + for _, evt := range events { + f.applyEvent(evt) + f.drainMessages() + f.assertInvariants() + } + } + t.Run("bad_onion_decode", func(t *testing.T) { + // htlcRef%3 == 0 → onionFailDecode. + runWithHtlcRef(t, uint64(3_000_000), badOnionEvents) + }) + t.Run("bad_onion_payload", func(t *testing.T) { + // htlcRef%3 == 1 → onionFailPayload. + runWithHtlcRef(t, uint64(3_000_001), badOnionEvents) + }) + t.Run("bad_onion_extract", func(t *testing.T) { + // htlcRef%3 == 2 → onionFailExtract. + runWithHtlcRef(t, uint64(3_000_002), badOnionEvents) + }) +} + +// 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 + // restartSyncHeight=0 seeds the canonical case (no height mutation). + // maxFeeExposureGen=0 → DefaultMaxFeeExposure (no override). + // maxFeeAllocationGen=0 → DefaultMaxLinkFeeAllocation (no override). + f.Add(uint64(1_000_000), uint64(10_000_000), uint64(50), uint64(0), + uint64(0), uint64(0), + []byte{byte(EvAliceSendAddHtlc), byte(EvAliceSendCommit), + byte(EvBobSendAddHtlc), byte(EvBobSendCommit), + byte(EvAliceSettleHtlc), byte(EvBobSettleHtlc), + byte(EvAliceSendUpdateFee), byte(EvAliceSendAddHtlc), + byte(EvAliceSendWarning), byte(EvBobRestartLink), + byte(EvAliceSendCommit), byte(EvBobSendAddHtlc), + byte(EvBobSendCommit), byte(EvAliceFailHtlc), + byte(EvBobSendWarning), byte(EvBobFailNonExistentHtlc), + byte(EvBobFailHtlc), byte(EvBobSendUpdateFee), + byte(EvAliceSendAddHtlc), byte(EvAliceSendCommit), + byte(EvBobSendAddHtlc), byte(EvBobSendCommit), + byte(EvBobInvalidHtlcSettlement), + byte(EvBobSendCommitNoWindow), + byte(EvAliceSendBadOnion), + byte(EvAliceFailNonExistentHtlc), + byte(EvAliceFailHtlc), byte(EvAliceRestartLink), + byte(EvBobFailHtlc), byte(EvBobInitQuiescence), + byte(EvBobSendUpdateFee), byte(EvAliceSendAddHtlc), + byte(EvAliceSendCommit), byte(EvResumeQuiescence), + byte(EvAliceSendCommitNoWindow), + byte(EvBobSendBadOnion), + byte(EvBobSettleHtlc), byte(EvBobSendAddHtlc), + byte(EvAliceInvalidHtlcSettlement), + byte(EvBobSendCommit), byte(EvAliceFailHtlc), + byte(EvResumeQuiescence), byte(EvAliceRestartLink), + byte(EvAliceInitQuiescence)}, + ) + f.Fuzz(func(t *testing.T, channelSize, htlcRef uint64, + aliceShareGen, restartSyncHeight, maxFeeExposureGen, + maxFeeAllocationGen uint64, data []byte) { + + fuzzFSM := newFuzzFSM( + t, channelSize, aliceShareGen, maxFeeExposureGen, + maxFeeAllocationGen, + ) + + fuzzFSM.htlcRef = htlcRef + fuzzFSM.restartSyncHeight = restartSyncHeight + + // 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 051646205aac6d707a4ff228c39df6f5527185a9 Mon Sep 17 00:00:00 2001 From: MPins Date: Fri, 27 Mar 2026 13:35:05 -0300 Subject: [PATCH 08/11] 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 c406cb6a22e..cd095534637 100644 --- a/htlcswitch/fuzz_link_test.go +++ b/htlcswitch/fuzz_link_test.go @@ -1,7 +1,9 @@ package htlcswitch import ( + "bytes" "context" + "crypto/sha256" "fmt" "math" "runtime" @@ -10,10 +12,15 @@ 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/channeldb" "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" @@ -22,6 +29,79 @@ import ( "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 ( @@ -177,8 +257,21 @@ func newFuzzFSM(t *testing.T, channelSize, aliceShareGen, 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 84143ccd111..e96ea8cd17c 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 820e81a59d62d4b03aef01bbfb94d9a3506ca6f1 Mon Sep 17 00:00:00 2001 From: MPins Date: Tue, 31 Mar 2026 23:42:58 -0300 Subject: [PATCH 09/11] 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 | 47 ++++++++++++++++++++++++++++++++++++ lnwallet/channel.go | 39 ++++++++++++++++++++++++------ lnwallet/mock.go | 17 +++++++++++++ 3 files changed, 95 insertions(+), 8 deletions(-) diff --git a/htlcswitch/fuzz_link_test.go b/htlcswitch/fuzz_link_test.go index cd095534637..defc856e20b 100644 --- a/htlcswitch/fuzz_link_test.go +++ b/htlcswitch/fuzz_link_test.go @@ -102,6 +102,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 ( @@ -271,6 +317,7 @@ func newFuzzFSM(t *testing.T, channelSize, aliceShareGen, 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 b98a267999cd2ec95b119606e4b4983be5b6701c Mon Sep 17 00:00:00 2001 From: MPins Date: Fri, 10 Apr 2026 14:46:25 -0300 Subject: [PATCH 10/11] 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 e96ea8cd17c..330c24b1b20 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 5204c289de63ea14eee73dc6ae490e3ea929100e Mon Sep 17 00:00:00 2001 From: MPins Date: Fri, 10 Apr 2026 14:48:18 -0300 Subject: [PATCH 11/11] 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 b81ac8abe52..86e318ac776 100644 --- a/htlcswitch/mock.go +++ b/htlcswitch/mock.go @@ -1075,6 +1075,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,