Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,820 changes: 1,820 additions & 0 deletions htlcswitch/fuzz_link_test.go

Large diffs are not rendered by default.

43 changes: 43 additions & 0 deletions htlcswitch/htlc_commitment_state_machine.md
Original file line number Diff line number Diff line change
@@ -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 <<terminal>>
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`. |
5 changes: 5 additions & 0 deletions htlcswitch/link.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}

Expand Down
40 changes: 40 additions & 0 deletions htlcswitch/link_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -2277,6 +2278,7 @@ func newSingleLinkTestHarness(t *testing.T, chanAmt,
aliceBatchTicker: bticker.Force,
start: start,
aliceRestore: aliceLc.restore,
invoiceRegistry: invoiceRegistry,
}

return harness, nil
Expand Down Expand Up @@ -5012,6 +5014,44 @@ func generateHtlcAndInvoice(t *testing.T,
return htlc, invoice
}

// generateSingleHopHtlc generate a single hop htlc to send to the receiver.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

According to the repository style guide, function comments should be complete sentences. Please correct the comment to use 'generates' instead of 'generate' to make it a complete sentence.

Suggested change
// generateSingleHopHtlc generate a single hop htlc to send to the receiver.
// generateSingleHopHtlc generates a single hop htlc to send to the receiver.
References
  1. Function comments should be complete sentences. (link)

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) {
Expand Down
143 changes: 141 additions & 2 deletions htlcswitch/mock.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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
Expand All @@ -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)
}

Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -1019,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,
Expand Down Expand Up @@ -1179,3 +1240,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() {}
Loading
Loading