What changed, and why it matters
This commit rewrites how LND tracks HTLCs that are paused by an external interceptor, distinguishing between normal off-chain forwards and ones that have moved to on-chain contract resolution. It fixes a likely bug where a previously off-chain held HTLC, once forced on-chain, could still be settled through the old link path instead of the on-chain witness beacon, and it prevents on-chain HTLCs from being incorrectly auto-failed or released on interceptor disconnect. The change is defensive and correctness-oriented rather than a clear remote-exploitable vulnerability.
Treat as a security-relevant correctness fix. Review the new held entry lifecycle, especially the promotion from off-chain to on-chain and the settle-only enforcement, to ensure no edge cases allow an on-chain HTLC to be failed back or resumed. Consider whether the projected AutoFailHeight for on-chain packets could mislead interceptor clients about semantics, and update documentation/RPC comments if needed.
Security signals we found
Prevents settlement of on-chain HTLCs through stale off-chain link mailbox path
Prevents premature auto-fail of on-chain HTLCs at off-chain auto-fail height
Prevents loss of on-chain settlement opportunity when external interceptor disconnects
Adds explicit error propagation for disallowed resume/fail actions on on-chain held HTLCs
Adds deadline-type validation and distinct internal expiry semantics
Adds replay/retention semantics for on-chain held HTLCs across interceptor reconnects
Evidence from the diff
The patch refactors heldHtlcSet from a single map[models.CircuitKey]InterceptedForward into a map of heldEntry interfaces with two concrete types: offChainHeld (auto-fail height, supports resume/fail/settle) and onChainHeld (settle-only, pruned at on-chain expiry). When contractcourt re-offers a circuit already held off-chain, addOnChain replaces the entry so subsequent SETTLE reaches the witness beacon. On-chain entries are no longer released when the interceptor disconnects, are not auto-failed at the off-chain deadline, and only accept settle resolutions. The InterceptedPacket.Deadline field becomes an Either[OffChainAutoFailHeight, OnChainSettleDeadline], while the RPC AutoFailHeight field is projected from either variant for wire compatibility. A new RemoveOnChainIntercept API lets contractcourt clean up the held entry when its resolver finishes.
Changed components
htlcswitch/held_htlc_set.gohtlcswitch/interceptable_switch.gohtlcswitch/interfaces.golnrpc/routerrpc/forward_interceptor.gowitness_beacon.goInspect captured patch +1067 / −192
diff --git a/htlcswitch/held_htlc_set.go b/htlcswitch/held_htlc_set.go
index c04880d..7c2ac14 100644
--- a/htlcswitch/held_htlc_set.go
+++ b/htlcswitch/held_htlc_set.go
@@ -5,62 +5,333 @@ import (
"fmt"
"github.com/lightningnetwork/lnd/graph/db/models"
+ "github.com/lightningnetwork/lnd/lnwire"
)
-// heldHtlcSet keeps track of outstanding intercepted forwards. It exposes
-// several methods to manipulate the underlying map structure in a consistent
-// way.
+var (
+ // ErrCannotResumeOnChain is returned when an on-chain held HTLC is
+ // resolved with a resume action.
+ ErrCannotResumeOnChain = errors.New(
+ "cannot resume held htlc in the on-chain flow",
+ )
+
+ // ErrCannotFailOnChain is returned when an on-chain held HTLC is
+ // resolved with a fail action.
+ ErrCannotFailOnChain = errors.New(
+ "cannot fail held htlc in the on-chain flow",
+ )
+
+ // errNilHeldForward is returned when the held HTLC constructors or
+ // add helpers are given a nil InterceptedForward.
+ errNilHeldForward = errors.New("nil held htlc forward")
+
+ // errInvalidHeldDeadline is returned when a held HTLC has an
+ // interceptor deadline that is not a positive block height.
+ errInvalidHeldDeadline = errors.New(
+ "invalid held htlc interceptor deadline",
+ )
+
+ // errInvalidHeldDeadlineType is returned when a held HTLC entry is
+ // created with the wrong deadline type for its source.
+ errInvalidHeldDeadlineType = errors.New(
+ "invalid held htlc interceptor deadline type",
+ )
+)
+
+// heldEntry models the behavior of a held HTLC based on whether it is still
+// controlled by the off-chain link flow or the on-chain contractcourt flow.
+type heldEntry interface {
+ // interceptedForward returns the forward that should be replayed to the
+ // external interceptor.
+ interceptedForward() InterceptedForward
+
+ // resolve applies an interceptor resolution to the held entry.
+ resolve(*FwdResolution) error
+
+ // expire expires the held entry at the given block height. The boolean
+ // return value indicates whether the entry should be removed.
+ expire(height uint32) (bool, error)
+}
+
+// offChainHeld is a held HTLC that is still controlled by the off-chain link
+// flow.
+type offChainHeld struct {
+ fwd InterceptedForward
+
+ // autoFailHeight is the block height at which the held off-chain HTLC
+ // must be failed back to avoid forcing the incoming channel closed.
+ autoFailHeight uint32
+}
+
+// Assert that offChainHeld implements heldEntry.
+var _ heldEntry = (*offChainHeld)(nil)
+
+// newOffChainHeld creates a held off-chain HTLC entry and validates that it has
+// a positive auto-fail height.
+func newOffChainHeld(fwd InterceptedForward) (*offChainHeld, error) {
+ if fwd == nil {
+ return nil, errNilHeldForward
+ }
+
+ autoFailHeight, err := fwd.Packet().Deadline.LeftToSome().UnwrapOrErr(
+ errInvalidHeldDeadlineType,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ if autoFailHeight <= 0 {
+ return nil, fmt.Errorf("%w: %v", errInvalidHeldDeadline,
+ autoFailHeight)
+ }
+
+ return &offChainHeld{
+ fwd: fwd,
+ autoFailHeight: uint32(autoFailHeight),
+ }, nil
+}
+
+// interceptedForward returns the intercepted forward backing the off-chain
+// entry.
+func (h *offChainHeld) interceptedForward() InterceptedForward {
+ return h.fwd
+}
+
+// release resumes the held off-chain HTLC into the normal link forwarding
+// flow.
+func (h *offChainHeld) release() error {
+ return h.fwd.Resume()
+}
+
+// resolve applies an interceptor resolution to the held off-chain HTLC.
+func (h *offChainHeld) resolve(res *FwdResolution) error {
+ switch res.Action {
+ case FwdActionResume:
+ return h.fwd.Resume()
+
+ case FwdActionResumeModified:
+ return h.fwd.ResumeModified(
+ res.InAmountMsat, res.OutAmountMsat,
+ res.OutWireCustomRecords,
+ )
+
+ case FwdActionSettle:
+ return h.fwd.Settle(res.Preimage)
+
+ case FwdActionFail:
+ if len(res.FailureMessage) > 0 {
+ return h.fwd.Fail(res.FailureMessage)
+ }
+
+ return h.fwd.FailWithCode(res.FailureCode)
+
+ default:
+ return fmt.Errorf("unrecognized action %v", res.Action)
+ }
+}
+
+// expire fails back the held off-chain HTLC once its auto-fail height has been
+// reached.
+func (h *offChainHeld) expire(height uint32) (bool, error) {
+ if h.autoFailHeight > height {
+ return false, nil
+ }
+
+ err := h.fwd.FailWithCode(lnwire.CodeTemporaryChannelFailure)
+ if err != nil {
+ return false, err
+ }
+
+ return true, nil
+}
+
+// onChainHeld is a held HTLC that is controlled by the on-chain contractcourt
+// flow.
+type onChainHeld struct {
+ fwd InterceptedForward
+
+ // settleDeadline is the on-chain HTLC expiry. Once this height is
+ // reached, the remote party can also sweep the HTLC using the timeout
+ // path, so any late preimage would race that spend. At that point the
+ // interceptor entry is pruned locally instead of failed back through
+ // the link.
+ settleDeadline uint32
+}
+
+// Assert that onChainHeld implements heldEntry.
+var _ heldEntry = (*onChainHeld)(nil)
+
+// newOnChainHeld creates a held on-chain HTLC entry and validates that it has a
+// positive settlement deadline.
+func newOnChainHeld(fwd InterceptedForward) (*onChainHeld, error) {
+ if fwd == nil {
+ return nil, errNilHeldForward
+ }
+
+ settleDeadline, err := fwd.Packet().Deadline.RightToSome().UnwrapOrErr(
+ errInvalidHeldDeadlineType,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ if settleDeadline <= 0 {
+ return nil, fmt.Errorf("%w: %v", errInvalidHeldDeadline,
+ settleDeadline)
+ }
+
+ return &onChainHeld{
+ fwd: fwd,
+ settleDeadline: uint32(settleDeadline),
+ }, nil
+}
+
+// interceptedForward returns the intercepted forward backing the on-chain
+// entry.
+func (h *onChainHeld) interceptedForward() InterceptedForward {
+ return h.fwd
+}
+
+// resolve applies an interceptor resolution to the held on-chain HTLC.
+func (h *onChainHeld) resolve(res *FwdResolution) error {
+ switch res.Action {
+ case FwdActionSettle:
+ return h.fwd.Settle(res.Preimage)
+
+ case FwdActionFail:
+ return ErrCannotFailOnChain
+
+ case FwdActionResume:
+ return ErrCannotResumeOnChain
+
+ case FwdActionResumeModified:
+ return ErrCannotResumeOnChain
+
+ default:
+ return fmt.Errorf("unrecognized action %v", res.Action)
+ }
+}
+
+// expire reports whether the held on-chain HTLC should be pruned locally
+// because its settlement deadline has been reached.
+func (h *onChainHeld) expire(height uint32) (bool, error) {
+ return h.settleDeadline <= height, nil
+}
+
+// heldHtlcExpireError records an error returned while expiring a held HTLC.
+type heldHtlcExpireError struct {
+ key models.CircuitKey
+ err error
+}
+
+// heldHtlcReleaseError records an error returned while releasing a held HTLC.
+type heldHtlcReleaseError struct {
+ key models.CircuitKey
+ err error
+}
+
+// heldHtlcSet keeps track of outstanding intercepted forwards. It models
+// whether each forward is still controlled by the off-chain link flow or has
+// moved to the on-chain contractcourt flow.
type heldHtlcSet struct {
- set map[models.CircuitKey]InterceptedForward
+ set map[models.CircuitKey]heldEntry
}
func newHeldHtlcSet() *heldHtlcSet {
return &heldHtlcSet{
- set: make(map[models.CircuitKey]InterceptedForward),
+ set: make(map[models.CircuitKey]heldEntry),
}
}
// forEach iterates over all held forwards and calls the given callback for each
// of them.
func (h *heldHtlcSet) forEach(cb func(InterceptedForward)) {
- for _, fwd := range h.set {
- cb(fwd)
+ for _, entry := range h.set {
+ cb(entry.interceptedForward())
+ }
+}
+
+// releaseAllOffChainHeld releases off-chain entries when the optional
+// interceptor disconnects. On-chain entries are kept because there is no link
+// flow to resume, preserving the replay/settle handle while contractcourt waits
+// for the preimage or on-chain expiry.
+func (h *heldHtlcSet) releaseAllOffChainHeld() []heldHtlcReleaseError {
+ var errs []heldHtlcReleaseError
+
+ for key, entry := range h.set {
+ offChain, ok := entry.(*offChainHeld)
+ if !ok {
+ continue
+ }
+
+ if err := offChain.release(); err != nil {
+ errs = append(errs, heldHtlcReleaseError{
+ key: key,
+ err: err,
+ })
+
+ // Keep the entry tracked so it can still be resolved or
+ // failed back by the normal expiry path.
+ continue
+ }
+
+ delete(h.set, key)
}
+
+ return errs
}
-// popAll calls the callback for each forward and removes them from the set.
-func (h *heldHtlcSet) popAll(cb func(InterceptedForward)) {
- for _, fwd := range h.set {
- cb(fwd)
+// removeOnChainHeld removes an on-chain held entry by circuit key. Off-chain
+// entries are left untouched because their lifecycle is owned by the link flow,
+// not contractcourt.
+func (h *heldHtlcSet) removeOnChainHeld(key models.CircuitKey) bool {
+ if _, ok := h.set[key].(*onChainHeld); !ok {
+ return false
}
- h.set = make(map[models.CircuitKey]InterceptedForward)
+ delete(h.set, key)
+
+ return true
}
-// popAutoFails calls the callback for each forward that has an auto-fail height
-// equal or less then the specified pop height and removes them from the set.
-func (h *heldHtlcSet) popAutoFails(height uint32, cb func(InterceptedForward)) {
- for key, fwd := range h.set {
- if uint32(fwd.Packet().AutoFailHeight) > height {
+// expire expires held forwards whose deadline has passed.
+func (h *heldHtlcSet) expire(height uint32) []heldHtlcExpireError {
+ var errs []heldHtlcExpireError
+
+ for key, entry := range h.set {
+ remove, err := entry.expire(height)
+ if err != nil {
+ errs = append(errs, heldHtlcExpireError{
+ key: key,
+ err: err,
+ })
+
continue
}
- cb(fwd)
-
- delete(h.set, key)
+ if remove {
+ delete(h.set, key)
+ }
}
+
+ return errs
}
-// pop returns the specified forward and removes it from the set.
-func (h *heldHtlcSet) pop(key models.CircuitKey) (InterceptedForward, error) {
- intercepted, ok := h.set[key]
+// resolve applies the given resolution and removes the forward from the set if
+// the resolution succeeds.
+func (h *heldHtlcSet) resolve(res *FwdResolution) error {
+ entry, ok := h.set[res.Key]
if !ok {
- return nil, fmt.Errorf("fwd %v not found", key)
+ return fmt.Errorf("%w: %v", ErrFwdNotExists, res.Key)
}
- delete(h.set, key)
+ if err := entry.resolve(res); err != nil {
+ return err
+ }
+
+ delete(h.set, res.Key)
- return intercepted, nil
+ return nil
}
// exists tests whether the specified forward is part of the set.
@@ -70,20 +341,56 @@ func (h *heldHtlcSet) exists(key models.CircuitKey) bool {
return ok
}
-// push adds the specified forward to the set. An error is returned if the
-// forward exists already.
-func (h *heldHtlcSet) push(key models.CircuitKey,
- fwd InterceptedForward) error {
-
+// addOffChain adds an off-chain forward to the set. If the forward already
+// exists, the duplicate is ignored because callers should have handled it
+// before insertion.
+func (h *heldHtlcSet) addOffChain(fwd InterceptedForward) error {
if fwd == nil {
- return errors.New("nil fwd pushed")
+ return errNilHeldForward
}
+ key := fwd.Packet().IncomingCircuit
if h.exists(key) {
- return errors.New("htlc already exists in set")
+ log.Warnf("Ignoring duplicate off-chain held htlc %v", key)
+
+ return nil
+ }
+
+ entry, err := newOffChainHeld(fwd)
+ if err != nil {
+ return err
+ }
+
+ h.set[key] = entry
+
+ return nil
+}
+
+// addOnChain adds an on-chain forward to the set. If the same HTLC is currently
+// held off-chain, it is replaced so future resolutions go to the witness beacon
+// instead of the old link mailbox path.
+func (h *heldHtlcSet) addOnChain(fwd InterceptedForward) error {
+ if fwd == nil {
+ return errNilHeldForward
+ }
+
+ key := fwd.Packet().IncomingCircuit
+
+ if _, ok := h.set[key].(*onChainHeld); ok {
+ return nil
+ }
+
+ if _, ok := h.set[key].(*offChainHeld); ok {
+ log.Infof("Promoting held htlc %v from off-chain to "+
+ "on-chain resolution", key)
+ }
+
+ entry, err := newOnChainHeld(fwd)
+ if err != nil {
+ return err
}
- h.set[key] = fwd
+ h.set[key] = entry
return nil
}
diff --git a/htlcswitch/held_htlc_set_test.go b/htlcswitch/held_htlc_set_test.go
index ca1a175..bf38bd5 100644
--- a/htlcswitch/held_htlc_set_test.go
+++ b/htlcswitch/held_htlc_set_test.go
@@ -1,126 +1,602 @@
package htlcswitch
import (
+ "errors"
"testing"
+ "time"
+ "github.com/lightningnetwork/lnd/chainntnfs"
+ "github.com/lightningnetwork/lnd/fn/v2"
"github.com/lightningnetwork/lnd/graph/db/models"
+ lntestmock "github.com/lightningnetwork/lnd/lntest/mock"
+ "github.com/lightningnetwork/lnd/lntypes"
"github.com/lightningnetwork/lnd/lnwire"
+ "github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
-func TestHeldHtlcSetEmpty(t *testing.T) {
- set := newHeldHtlcSet()
+var errTestForward = errors.New("test forward error")
- // Test operations on an empty set.
- require.False(t, set.exists(models.CircuitKey{}))
+// mockInterceptedForward is an InterceptedForward test double that records
+// resolution calls and returns configured errors.
+type mockInterceptedForward struct {
+ mock.Mock
+
+ packet InterceptedPacket
+}
- _, err := set.pop(models.CircuitKey{})
- require.Error(t, err)
+// newMockInterceptedForward creates a mock intercepted forward with the given
+// circuit key and auto-fail deadline.
+func newMockInterceptedForward(key models.CircuitKey,
+ deadline int32) *mockInterceptedForward {
- set.popAll(
- func(_ InterceptedForward) {
- require.Fail(t, "unexpected fwd")
+ return &mockInterceptedForward{
+ packet: InterceptedPacket{
+ IncomingCircuit: key,
+ Deadline: fn.NewLeft[
+ OffChainAutoFailHeight, OnChainSettleDeadline,
+ ](OffChainAutoFailHeight(deadline)),
},
- )
+ }
}
-func TestHeldHtlcSet(t *testing.T) {
- set := newHeldHtlcSet()
+// newMockOnChainInterceptedForward creates a mock on-chain intercepted forward
+// with the given circuit key and settlement deadline.
+func newMockOnChainInterceptedForward(key models.CircuitKey,
+ deadline int32) *mockInterceptedForward {
+
+ return &mockInterceptedForward{
+ packet: InterceptedPacket{
+ IncomingCircuit: key,
+ Deadline: fn.NewRight[
+ OffChainAutoFailHeight, OnChainSettleDeadline,
+ ](OnChainSettleDeadline(deadline)),
+ },
+ }
+}
+
+// Packet returns the intercepted packet represented by the mock.
+func (m *mockInterceptedForward) Packet() InterceptedPacket {
+ return m.packet
+}
+
+// Resume records a resume call and returns the configured return error.
+func (m *mockInterceptedForward) Resume() error {
+ args := m.Called()
+
+ return args.Error(0)
+}
+
+// ResumeModified records a modified resume call and returns the configured
+// return error.
+func (m *mockInterceptedForward) ResumeModified(
+ _ fn.Option[lnwire.MilliSatoshi],
+ _ fn.Option[lnwire.MilliSatoshi],
+ _ fn.Option[lnwire.CustomRecords]) error {
+
+ args := m.Called()
- key := models.CircuitKey{
+ return args.Error(0)
+}
+
+// Settle records a settle call and returns the configured return error.
+func (m *mockInterceptedForward) Settle(preimage lntypes.Preimage) error {
+ args := m.Called(preimage)
+
+ return args.Error(0)
+}
+
+// Fail records an encrypted failure call and returns the configured return
+// error.
+func (m *mockInterceptedForward) Fail(reason []byte) error {
+ args := m.Called(reason)
+
+ return args.Error(0)
+}
+
+// FailWithCode records a failure-code call and returns the configured
+// return error.
+func (m *mockInterceptedForward) FailWithCode(code lnwire.FailCode) error {
+ args := m.Called(code)
+
+ return args.Error(0)
+}
+
+// testCircuitKey returns a stable circuit key for held HTLC set tests.
+func testCircuitKey() models.CircuitKey {
+ return models.CircuitKey{
ChanID: lnwire.NewShortChanIDFromInt(1),
HtlcID: 2,
}
+}
+
+// TestHeldHtlcSetEmpty verifies empty held HTLC set behavior.
+func TestHeldHtlcSetEmpty(t *testing.T) {
+ set := newHeldHtlcSet()
+
+ require.False(t, set.exists(models.CircuitKey{}))
+ require.ErrorIs(t, set.resolve(&FwdResolution{}), ErrFwdNotExists)
+ require.Empty(t, set.releaseAllOffChainHeld())
+}
+
+// TestHeldHtlcSetRejectsInvalidDeadline verifies invalid deadlines are
+// rejected for both off-chain and on-chain held entries.
+func TestHeldHtlcSetRejectsInvalidDeadline(t *testing.T) {
+ set := newHeldHtlcSet()
+ key := testCircuitKey()
+
+ require.Error(t, set.addOffChain(newMockInterceptedForward(key, 0)))
+ require.Error(t, set.addOffChain(newMockInterceptedForward(key, -1)))
+ require.Error(t, set.addOffChain(
+ newMockOnChainInterceptedForward(key, 100),
+ ))
+ require.Error(t, set.addOnChain(
+ newMockOnChainInterceptedForward(key, 0),
+ ))
+ require.Error(t, set.addOnChain(
+ newMockOnChainInterceptedForward(key, -1),
+ ))
+ require.Error(t, set.addOnChain(newMockInterceptedForward(key, 100)))
+}
+
+// TestHeldHtlcSetOffChainResolve verifies off-chain resolutions call through
+// to the backing intercepted forward and remove the held entry.
+func TestHeldHtlcSetOffChainResolve(t *testing.T) {
+ set := newHeldHtlcSet()
+ key := testCircuitKey()
+ fwd := newMockInterceptedForward(key, 100)
+ fwd.On("Resume").Return(nil).Once()
- // Test pushing a nil forward.
- require.Error(t, set.push(key, nil))
+ require.NoError(t, set.addOffChain(fwd))
+ require.NoError(t, set.resolve(&FwdResolution{
+ Key: key,
+ Action: FwdActionResume,
+ }))
+ fwd.AssertExpectations(t)
+ require.False(t, set.exists(key))
+}
+
+// TestHeldHtlcSetAddOffChainKeepsExisting verifies that duplicate off-chain
+// forwards keep the existing held entry.
+func TestHeldHtlcSetAddOffChainKeepsExisting(t *testing.T) {
+ set := newHeldHtlcSet()
+ key := testCircuitKey()
+ first := newMockInterceptedForward(key, 100)
+ second := newMockInterceptedForward(key, 100)
+ first.On("Resume").Return(nil).Once()
- // Test pushing a forward.
- fwd := &interceptedForward{
- htlc: &lnwire.UpdateAddHTLC{},
+ require.NoError(t, set.addOffChain(first))
+ require.NoError(t, set.addOffChain(second))
+ require.NoError(t, set.resolve(&FwdResolution{
+ Key: key,
+ Action: FwdActionResume,
+ }))
+
+ first.AssertExpectations(t)
+ second.AssertNotCalled(t, "Resume")
+}
+
+// TestInterceptableSwitchForwardOffChainAlreadyHeld verifies that normal
+// off-chain forwarding handles duplicates before adding them to the held set.
+func TestInterceptableSwitchForwardOffChainAlreadyHeld(t *testing.T) {
+ key := testCircuitKey()
+ fwd := newMockInterceptedForward(key, 100)
+ s := &InterceptableSwitch{
+ heldHtlcSet: newHeldHtlcSet(),
}
- require.NoError(t, set.push(key, fwd))
- // Re-pushing should fail.
- require.Error(t, set.push(key, fwd))
+ require.NoError(t, s.heldHtlcSet.addOffChain(fwd))
- // Test popping the fwd.
- poppedFwd, err := set.pop(key)
+ handled, err := s.forwardOffChain(
+ newMockInterceptedForward(key, 100), true,
+ )
require.NoError(t, err)
- require.Equal(t, fwd, poppedFwd)
+ require.True(t, handled)
+}
- _, err = set.pop(key)
- require.Error(t, err)
+// TestHeldHtlcSetResolveKeepsEntryOnError verifies failed resolutions keep the
+// held entry available for retry.
+func TestHeldHtlcSetResolveKeepsEntryOnError(t *testing.T) {
+ set := newHeldHtlcSet()
+ key := testCircuitKey()
+ fwd := newMockInterceptedForward(key, 100)
+ fwd.On("Settle", lntypes.Preimage{}).Return(errTestForward).Once()
- // Pushing the forward again.
- require.NoError(t, set.push(key, fwd))
+ require.NoError(t, set.addOffChain(fwd))
+ require.ErrorIs(t, set.resolve(&FwdResolution{
+ Key: key,
+ Action: FwdActionSettle,
+ }), errTestForward)
- // Test for each.
- var cbCalled bool
- set.forEach(func(_ InterceptedForward) {
- cbCalled = true
+ require.True(t, set.exists(key))
+ fwd.AssertExpectations(t)
+}
- require.Equal(t, fwd, poppedFwd)
- })
- require.True(t, cbCalled)
+// TestHeldHtlcSetReleaseAllOffChainHeld verifies an optional interceptor
+// disconnect resumes off-chain entries and clears them from the set.
+func TestHeldHtlcSetReleaseAllOffChainHeld(t *testing.T) {
+ set := newHeldHtlcSet()
+ key := testCircuitKey()
+ fwd := newMockInterceptedForward(key, 100)
+ fwd.On("Resume").Return(nil).Once()
- // Test popping all forwards.
- cbCalled = false
- set.popAll(
- func(_ InterceptedForward) {
- cbCalled = true
+ require.NoError(t, set.addOffChain(fwd))
+ require.Empty(t, set.releaseAllOffChainHeld())
+ require.False(t, set.exists(key))
+ fwd.AssertExpectations(t)
+}
- require.Equal(t, fwd, poppedFwd)
- },
- )
- require.True(t, cbCalled)
+// TestHeldHtlcSetReleaseAllOffChainHeldKeepsOnChain verifies an optional
+// interceptor disconnect keeps on-chain entries available for replay if the
+// interceptor reconnects before expiry.
+func TestHeldHtlcSetReleaseAllOffChainHeldKeepsOnChain(t *testing.T) {
+ set := newHeldHtlcSet()
+ key := testCircuitKey()
+ fwd := newMockOnChainInterceptedForward(key, 100)
- _, err = set.pop(key)
- require.Error(t, err)
+ require.NoError(t, set.addOnChain(fwd))
+ require.Empty(t, set.releaseAllOffChainHeld())
+ require.True(t, set.exists(key))
+ fwd.AssertNotCalled(t, "Resume")
}
-func TestHeldHtlcSetAutoFails(t *testing.T) {
+// TestHeldHtlcSetReleaseAllOffChainHeldKeepsReleaseErrors verifies release
+// errors leave off-chain entries available for later resolution or expiry.
+func TestHeldHtlcSetReleaseAllOffChainHeldKeepsReleaseErrors(t *testing.T) {
set := newHeldHtlcSet()
+ key := testCircuitKey()
+ fwd := newMockInterceptedForward(key, 100)
+ fwd.On("Resume").Return(errTestForward).Once()
- key := models.CircuitKey{
- ChanID: lnwire.NewShortChanIDFromInt(1),
- HtlcID: 2,
+ require.NoError(t, set.addOffChain(fwd))
+
+ errs := set.releaseAllOffChainHeld()
+ require.Len(t, errs, 1)
+ require.Equal(t, key, errs[0].key)
+ require.ErrorIs(t, errs[0].err, errTestForward)
+ require.True(t, set.exists(key))
+ fwd.AssertExpectations(t)
+}
+
+// TestHeldHtlcSetRemoveOnChainHeld verifies contractcourt teardown only removes
+// on-chain entries.
+func TestHeldHtlcSetRemoveOnChainHeld(t *testing.T) {
+ set := newHeldHtlcSet()
+ key := testCircuitKey()
+
+ offChain := newMockInterceptedForward(key, 100)
+ require.NoError(t, set.addOffChain(offChain))
+ require.False(t, set.removeOnChainHeld(key))
+ require.True(t, set.exists(key))
+
+ onChain := newMockOnChainInterceptedForward(key, 100)
+ require.NoError(t, set.addOnChain(onChain))
+ require.True(t, set.removeOnChainHeld(key))
+ require.False(t, set.exists(key))
+ require.False(t, set.removeOnChainHeld(key))
+}
+
+// TestHeldHtlcSetOffChainExpire verifies off-chain expiry fails the HTLC back.
+func TestHeldHtlcSetOffChainExpire(t *testing.T) {
+ set := newHeldHtlcSet()
+ key := testCircuitKey()
+ fwd := newMockInterceptedForward(key, 100)
+
+ require.NoError(t, set.addOffChain(fwd))
+
+ require.Empty(t, set.expire(99))
+ require.True(t, set.exists(key))
+ fwd.AssertNotCalled(t, "FailWithCode", mock.Anything)
+
+ fwd.On(
+ "FailWithCode",
+ lnwire.CodeTemporaryChannelFailure,
+ ).Return(nil).Once()
+ require.Empty(t, set.expire(100))
+ require.False(t, set.exists(key))
+ fwd.AssertExpectations(t)
+}
+
+// TestHeldHtlcSetOffChainExpireKeepsEntryOnError verifies expiry errors keep
+// the off-chain entry available for retry.
+func TestHeldHtlcSetOffChainExpireKeepsEntryOnError(t *testing.T) {
+ set := newHeldHtlcSet()
+ key := testCircuitKey()
+ fwd := newMockInterceptedForward(key, 100)
+ fwd.On(
+ "FailWithCode", lnwire.CodeTemporaryChannelFailure,
+ ).Return(errTestForward).Once()
+
+ require.NoError(t, set.addOffChain(fwd))
+
+ errs := set.expire(100)
+ require.Len(t, errs, 1)
+ require.ErrorIs(t, errs[0].err, errTestForward)
+ require.Equal(t, key, errs[0].key)
+ require.True(t, set.exists(key))
+ fwd.AssertExpectations(t)
+}
+
+// TestHeldHtlcSetOnChainResolve verifies on-chain entries reject non-settle
+// resolutions directly and remain held until settlement.
+func TestHeldHtlcSetOnChainResolve(t *testing.T) {
+ set := newHeldHtlcSet()
+ key := testCircuitKey()
+ fwd := newMockOnChainInterceptedForward(key, 100)
+ fwd.On("Settle", lntypes.Preimage{}).Return(nil).Once()
+
+ require.NoError(t, set.addOnChain(fwd))
+ require.ErrorIs(t, set.resolve(&FwdResolution{
+ Key: key,
+ Action: FwdActionFail,
+ FailureCode: lnwire.CodeTemporaryChannelFailure,
+ }), ErrCannotFailOnChain)
+ require.True(t, set.exists(key))
+
+ require.ErrorIs(t, set.resolve(&FwdResolution{
+ Key: key,
+ Action: FwdActionResume,
+ }), ErrCannotResumeOnChain)
+ require.True(t, set.exists(key))
+
+ require.ErrorIs(t, set.resolve(&FwdResolution{
+ Key: key,
+ Action: FwdActionResumeModified,
+ }), ErrCannotResumeOnChain)
+ require.True(t, set.exists(key))
+
+ require.NoError(t, set.resolve(&FwdResolution{
+ Key: key,
+ Action: FwdActionSettle,
+ }))
+ fwd.AssertExpectations(t)
+ fwd.AssertNotCalled(t, "Fail", mock.Anything)
+ fwd.AssertNotCalled(t, "FailWithCode", mock.Anything)
+ fwd.AssertNotCalled(t, "Resume")
+ fwd.AssertNotCalled(t, "ResumeModified", mock.Anything, mock.Anything,
+ mock.Anything)
+ require.False(t, set.exists(key))
+}
+
+// TestHeldHtlcSetOnChainExpirePrunes verifies on-chain expiry only prunes the
+// local held entry.
+func TestHeldHtlcSetOnChainExpirePrunes(t *testing.T) {
+ set := newHeldHtlcSet()
+ key := testCircuitKey()
+ fwd := newMockOnChainInterceptedForward(key, 100)
+
+ require.NoError(t, set.addOnChain(fwd))
+
+ require.Empty(t, set.expire(99))
+ require.True(t, set.exists(key))
+
+ require.Empty(t, set.expire(100))
+ require.False(t, set.exists(key))
+ fwd.AssertNotCalled(t, "FailWithCode", mock.Anything)
+}
+
+// TestHeldHtlcSetOnChainReplacesOffChain verifies on-chain entries replace
+// earlier off-chain entries with the same circuit key.
+func TestHeldHtlcSetOnChainReplacesOffChain(t *testing.T) {
+ set := newHeldHtlcSet()
+ key := testCircuitKey()
+ offChain := newMockInterceptedForward(key, 100)
+ onChain := newMockOnChainInterceptedForward(key, 100)
+ onChain.On("Settle", lntypes.Preimage{}).Return(nil).Once()
+
+ require.NoError(t, set.addOffChain(offChain))
+ require.NoError(t, set.addOnChain(onChain))
+
+ require.NoError(t, set.resolve(&FwdResolution{
+ Key: key,
+ Action: FwdActionSettle,
+ }))
+
+ offChain.AssertNotCalled(t, "Settle", mock.Anything)
+ onChain.AssertExpectations(t)
+}
+
+// TestInterceptableSwitchForwardOnChain verifies on-chain intercept handling
+// for fresh and already-held HTLCs.
+func TestInterceptableSwitchForwardOnChain(t *testing.T) {
+ key := testCircuitKey()
+
+ var intercepted []InterceptedPacket
+ interceptor := func(packet InterceptedPacket) error {
+ intercepted = append(intercepted, packet)
+
+ return nil
}
- const autoFailHeight = 100
- fwd := &interceptedForward{
- packet: &htlcPacket{},
- htlc: &lnwire.UpdateAddHTLC{},
- autoFailHeight: autoFailHeight,
+ t.Run("fresh on-chain htlc is sent", func(t *testing.T) {
+ s := &InterceptableSwitch{
+ heldHtlcSet: newHeldHtlcSet(),
+ interceptor: interceptor,
+ }
+ fwd := newMockOnChainInterceptedForward(key, 100)
+
+ require.NoError(t, s.interceptOnChain(fwd))
+ require.Len(t, intercepted, 1)
+ require.Equal(t, key, intercepted[0].IncomingCircuit)
+ })
+
+ t.Run("on-chain htlc replaces off-chain htlc and notifies",
+ func(t *testing.T) {
+ intercepted = nil
+ s := &InterceptableSwitch{
+ heldHtlcSet: newHeldHtlcSet(),
+ interceptor: interceptor,
+ }
+ offChain := newMockInterceptedForward(key, 80)
+ onChain := newMockOnChainInterceptedForward(key, 100)
+ onChain.On("Settle", lntypes.Preimage{}).Return(
+ nil,
+ ).Once()
+
+ require.NoError(t, s.heldHtlcSet.addOffChain(offChain))
+ require.NoError(t, s.interceptOnChain(onChain))
+ require.Len(t, intercepted, 1)
+ require.Equal(t, key, intercepted[0].IncomingCircuit)
+ require.Equal(
+ t, int32(100), intercepted[0].AutoFailHeight(),
+ )
+
+ require.NoError(t, s.resolve(&FwdResolution{
+ Key: key,
+ Action: FwdActionSettle,
+ }))
+ offChain.AssertNotCalled(t, "Settle", mock.Anything)
+ onChain.AssertExpectations(t)
+ })
+
+ t.Run("on-chain htlc replays after disconnect", func(t *testing.T) {
+ intercepted = nil
+ s := &InterceptableSwitch{
+ heldHtlcSet: newHeldHtlcSet(),
+ interceptor: interceptor,
+ }
+ fwd := newMockOnChainInterceptedForward(key, 100)
+ fwd.On("Settle", lntypes.Preimage{}).Return(nil).Once()
+
+ require.NoError(t, s.interceptOnChain(fwd))
+ require.Len(t, intercepted, 1)
+
+ s.setInterceptor(nil)
+ require.True(t, s.heldHtlcSet.exists(key))
+
+ intercepted = nil
+ s.setInterceptor(interceptor)
+ require.Len(t, intercepted, 1)
+ require.Equal(t, key, intercepted[0].IncomingCircuit)
+
+ require.NoError(t, s.resolve(&FwdResolution{
+ Key: key,
+ Action: FwdActionSettle,
+ }))
+ fwd.AssertExpectations(t)
+ require.False(t, s.heldHtlcSet.exists(key))
+ })
+
+ t.Run("duplicate on-chain htlc is not sent", func(t *testing.T) {
+ intercepted = nil
+ s := &InterceptableSwitch{
+ heldHtlcSet: newHeldHtlcSet(),
+ interceptor: interceptor,
+ }
+ fwd := newMockOnChainInterceptedForward(key, 100)
+
+ require.NoError(t, s.interceptOnChain(fwd))
+ require.Len(t, intercepted, 1)
+
+ intercepted = nil
+ require.NoError(t, s.interceptOnChain(fwd))
+ require.Empty(t, intercepted)
+ })
+
+ t.Run("on-chain htlc removed after teardown", func(t *testing.T) {
+ intercepted = nil
+ s := &InterceptableSwitch{
+ heldHtlcSet: newHeldHtlcSet(),
+ interceptor: interceptor,
+ }
+ fwd := newMockOnChainInterceptedForward(key, 100)
+
+ require.NoError(t, s.interceptOnChain(fwd))
+ require.Len(t, intercepted, 1)
+
+ s.removeOnChainIntercept(key)
+ require.False(t, s.heldHtlcSet.exists(key))
+
+ intercepted = nil
+ s.setInterceptor(interceptor)
+ require.Empty(t, intercepted)
+ })
+}
+
+// TestInterceptableSwitchRemoveOnChainIntercept verifies that the public
+// teardown path removes an on-chain hold through the switch run loop.
+func TestInterceptableSwitchRemoveOnChainIntercept(t *testing.T) {
+ notifier := &lntestmock.ChainNotifier{
+ EpochChan: make(chan *chainntnfs.BlockEpoch, 1),
}
- require.NoError(t, set.push(key, fwd))
-
- // Test popping auto fails up to one block before the auto-fail height
- // of our forward.
- set.popAutoFails(
- autoFailHeight-1,
- func(_ InterceptedForward) {
- require.Fail(t, "unexpected fwd")
- },
- )
+ notifier.EpochChan <- &chainntnfs.BlockEpoch{Height: 1}
- // Popping succeeds at the auto-fail height.
- cbCalled := false
- set.popAutoFails(
- autoFailHeight,
- func(poppedFwd InterceptedForward) {
- cbCalled = true
+ s, err := NewInterceptableSwitch(&InterceptableSwitchConfig{
+ Notifier: notifier,
+ CltvRejectDelta: 10,
+ CltvInterceptDelta: 13,
+ })
+ require.NoError(t, err)
+ require.NoError(t, s.Start())
+ defer func() {
+ require.NoError(t, s.Stop())
+ }()
- require.Equal(t, fwd, poppedFwd)
- },
- )
- require.True(t, cbCalled)
+ intercepted := make(chan InterceptedPacket, 2)
+ s.SetInterceptor(func(packet InterceptedPacket) error {
+ intercepted <- packet
- // After this, there should be nothing more to pop.
- set.popAutoFails(
- autoFailHeight,
- func(_ InterceptedForward) {
- require.Fail(t, "unexpected fwd")
- },
- )
+ return nil
+ })
+
+ key := testCircuitKey()
+ require.NoError(t, s.ForwardPacket(
+ newMockOnChainInterceptedForward(key, 100),
+ ))
+ select {
+ case packet := <-intercepted:
+ require.Equal(t, key, packet.IncomingCircuit)
+
+ case <-time.After(time.Second):
+ require.Fail(t, "on-chain hold not intercepted")
+ }
+
+ require.NoError(t, s.RemoveOnChainIntercept(key))
+
+ // Re-registering the interceptor replays all currently held HTLCs.
+ // The removed on-chain hold should not be replayed.
+ s.SetInterceptor(func(packet InterceptedPacket) error {
+ intercepted <- packet
+
+ return nil
+ })
+
+ // Synchronize with the switch event loop so any replay triggered by the
+ // interceptor registration above has already run.
+ require.NoError(t, s.RemoveOnChainIntercept(models.CircuitKey{}))
+
+ select {
+ case packet := <-intercepted:
+ require.Failf(t, "unexpected replay", "packet=%v", packet)
+
+ default:
+ }
+}
+
+// TestInterceptableSwitchForwardPacketReturnsHoldError verifies that
+// ForwardPacket returns the error produced while adding the on-chain hold.
+func TestInterceptableSwitchForwardPacketReturnsHoldError(t *testing.T) {
+ notifier := &lntestmock.ChainNotifier{
+ EpochChan: make(chan *chainntnfs.BlockEpoch, 1),
+ }
+ notifier.EpochChan <- &chainntnfs.BlockEpoch{Height: 1}
+
+ s, err := NewInterceptableSwitch(&InterceptableSwitchConfig{
+ Notifier: notifier,
+ CltvRejectDelta: 10,
+ CltvInterceptDelta: 13,
+ })
+ require.NoError(t, err)
+ require.NoError(t, s.Start())
+ defer func() {
+ require.NoError(t, s.Stop())
+ }()
+
+ key := testCircuitKey()
+ err = s.ForwardPacket(newMockInterceptedForward(key, 100))
+ require.ErrorIs(t, err, errInvalidHeldDeadlineType)
+ require.False(t, s.heldHtlcSet.exists(key))
+
+ err = s.ForwardPacket(nil)
+ require.ErrorIs(t, err, errNilHeldForward)
}
diff --git a/htlcswitch/interceptable_switch.go b/htlcswitch/interceptable_switch.go
index 3d0bd90..ac2d24c 100644
--- a/htlcswitch/interceptable_switch.go
+++ b/htlcswitch/interceptable_switch.go
@@ -50,7 +50,11 @@ type InterceptableSwitch struct {
// interceptor client.
resolutionChan chan *fwdResolution
- onchainIntercepted chan InterceptedForward
+ onchainIntercepted chan *onchainInterceptRequest
+
+ // onchainInterceptDone receives circuit keys for on-chain intercepted
+ // forwards whose contractcourt resolver has finished.
+ onchainInterceptDone chan models.CircuitKey
// interceptorRegistration is a channel that we use to synchronize
// client connect and disconnect.
@@ -99,6 +103,11 @@ type interceptedPackets struct {
isReplay bool
}
+type onchainInterceptRequest struct {
+ fwd InterceptedForward
+ errChan chan error
+}
+
// FwdAction defines the various resolution types.
type FwdAction int
@@ -195,7 +204,8 @@ func NewInterceptableSwitch(cfg *InterceptableSwitchConfig) (
return &InterceptableSwitch{
htlcSwitch: cfg.Switch,
intercepted: make(chan *interceptedPackets),
- onchainIntercepted: make(chan InterceptedForward),
+ onchainIntercepted: make(chan *onchainInterceptRequest),
+ onchainInterceptDone: make(chan models.CircuitKey),
interceptorRegistration: make(chan ForwardInterceptor),
heldHtlcSet: newHeldHtlcSet(),
resolutionChan: make(chan *fwdResolution),
@@ -317,18 +327,20 @@ func (s *InterceptableSwitch) run() error {
log.Errorf("Cannot forward packets: %v", err)
}
- case fwd := <-s.onchainIntercepted:
- // For on-chain interceptions, we don't know if it has
- // already been offered before. This information is in
- // the forwarding package which isn't easily accessible
- // from contractcourt. It is likely though that it was
- // already intercepted in the off-chain flow. And even
- // if not, it is safe to signal replay so that we won't
- // unexpectedly skip over this htlc.
- if _, err := s.forward(fwd, true); err != nil {
- return err
+ case req := <-s.onchainIntercepted:
+ notify, err := s.holdOnChain(req.fwd)
+ req.errChan <- err
+ if err != nil {
+ continue
}
+ if s.interceptor != nil && notify {
+ s.sendForward(req.fwd)
+ }
+
+ case key := <-s.onchainInterceptDone:
+ s.removeOnChainIntercept(key)
+
case res := <-s.resolutionChan:
res.errChan <- s.resolve(res.resolution)
@@ -339,8 +351,10 @@ func (s *InterceptableSwitch) run() error {
s.currentHeight = currentBlock.Height
- // A new block is appended. Fail any held htlcs that
- // expire at this height to prevent channel force-close.
+ // A new block is appended. Expire any held HTLCs whose
+ // deadline has passed. Off-chain HTLCs fail back, while
+ // on-chain HTLCs are only pruned from the local hold
+ // set.
s.failExpiredHtlcs()
case <-s.quit:
@@ -350,17 +364,11 @@ func (s *InterceptableSwitch) run() error {
}
func (s *InterceptableSwitch) failExpiredHtlcs() {
- s.heldHtlcSet.popAutoFails(
- uint32(s.currentHeight),
- func(fwd InterceptedForward) {
- err := fwd.FailWithCode(
- lnwire.CodeTemporaryChannelFailure,
- )
- if err != nil {
- log.Errorf("Cannot fail packet: %v", err)
- }
- },
- )
+ errs := s.heldHtlcSet.expire(uint32(s.currentHeight))
+ for _, expireErr := range errs {
+ log.Errorf("Cannot expire held htlc %v: %v", expireErr.key,
+ expireErr.err)
+ }
}
func (s *InterceptableSwitch) sendForward(fwd InterceptedForward) {
@@ -394,48 +402,20 @@ func (s *InterceptableSwitch) setInterceptor(interceptor ForwardInterceptor) {
return
}
- // Interceptor is not required. Release held forwards.
+ // Interceptor is not required. Release off-chain held forwards.
log.Infof("Interceptor disconnected, resolving held packets")
- s.heldHtlcSet.popAll(func(fwd InterceptedForward) {
- err := fwd.Resume()
- if err != nil {
- log.Errorf("Failed to resume hold forward %v", err)
- }
- })
+ errs := s.heldHtlcSet.releaseAllOffChainHeld()
+ for _, releaseErr := range errs {
+ log.Errorf("Failed to resume hold forward %v: %v",
+ releaseErr.key, releaseErr.err)
+ }
}
// resolve processes a HTLC given the resolution type specified by the
// intercepting client.
func (s *InterceptableSwitch) resolve(res *FwdResolution) error {
- intercepted, err := s.heldHtlcSet.pop(res.Key)
- if err != nil {
- return err
- }
-
- switch res.Action {
- case FwdActionResume:
- return intercepted.Resume()
-
- case FwdActionResumeModified:
- return intercepted.ResumeModified(
- res.InAmountMsat, res.OutAmountMsat,
- res.OutWireCustomRecords,
- )
-
- case FwdActionSettle:
- return intercepted.Settle(res.Preimage)
-
- case FwdActionFail:
- if len(res.FailureMessage) > 0 {
- return intercepted.Fail(res.FailureMessage)
- }
-
- return intercepted.FailWithCode(res.FailureCode)
-
- default:
- return fmt.Errorf("unrecognized action %v", res.Action)
- }
+ return s.heldHtlcSet.resolve(res)
}
// Resolve resolves an intercepted packet.
@@ -487,12 +467,38 @@ func (s *InterceptableSwitch) ForwardPackets(linkQuit <-chan struct{},
return nil
}
-// ForwardPacket forwards a single htlc to the external interceptor.
+// ForwardPacket records a single on-chain HTLC for interception. It returns
+// once the switch run loop has accepted or rejected the held entry.
func (s *InterceptableSwitch) ForwardPacket(
fwd InterceptedForward) error {
+ errChan := make(chan error, 1)
+ select {
+ case s.onchainIntercepted <- &onchainInterceptRequest{
+ fwd: fwd,
+ errChan: errChan,
+ }:
+
+ case <-s.quit:
+ return errors.New("interceptable switch quit")
+ }
+
select {
- case s.onchainIntercepted <- fwd:
+ case err := <-errChan:
+ return err
+
+ case <-s.quit:
+ return errors.New("interceptable switch quit")
+ }
+}
+
+// RemoveOnChainIntercept removes an on-chain intercepted forward from the held
+// set once its contractcourt resolver has finished.
+func (s *InterceptableSwitch) RemoveOnChainIntercept(
+ key models.CircuitKey) error {
+
+ select {
+ case s.onchainInterceptDone <- key:
case <-s.quit:
return errors.New("interceptable switch quit")
@@ -542,15 +548,16 @@ func (s *InterceptableSwitch) interceptForward(packet *htlcPacket,
return true, nil
}
- return s.forward(intercepted, isReplay)
+ return s.forwardOffChain(intercepted, isReplay)
default:
return false, nil
}
}
-// forward records the intercepted htlc and forwards it to the interceptor.
-func (s *InterceptableSwitch) forward(
+// forwardOffChain records an off-chain intercepted htlc and forwards it to the
+// interceptor if needed.
+func (s *InterceptableSwitch) forwardOffChain(
fwd InterceptedForward, isReplay bool) (bool, error) {
inKey := fwd.Packet().IncomingCircuit
@@ -585,16 +592,16 @@ func (s *InterceptableSwitch) forward(
// This packet is a replay. It is not safe to fail back, because the
// interceptor may still signal otherwise upon reconnect. Keep the
// packet in the queue until then.
- if err := s.heldHtlcSet.push(inKey, fwd); err != nil {
+ if err := s.heldHtlcSet.addOffChain(fwd); err != nil {
return false, err
}
return true, nil
}
- // There is an interceptor registered. We can forward the packet right now.
- // Hold it in the queue too to track what is outstanding.
- if err := s.heldHtlcSet.push(inKey, fwd); err != nil {
+ // There is an interceptor registered. We can notify it right now. Hold
+ // the packet in the queue too to track what is outstanding.
+ if err := s.heldHtlcSet.addOffChain(fwd); err != nil {
return false, err
}
@@ -603,6 +610,57 @@ func (s *InterceptableSwitch) forward(
return true, nil
}
+// interceptOnChain records an on-chain intercepted htlc. This doesn't resume or
+// forward the htlc through the link. If this HTLC is not already held on-chain,
+// the interceptor is notified so the client can settle it. If it is currently
+// held off-chain, the stored entry is replaced and the client is notified again
+// with the on-chain deadline and settle-only semantics.
+func (s *InterceptableSwitch) interceptOnChain(fwd InterceptedForward) error {
+ notify, err := s.holdOnChain(fwd)
+ if err != nil {
+ return err
+ }
+
+ if s.interceptor != nil && notify {
+ s.sendForward(fwd)
+ }
+
+ return nil
+}
+
+// holdOnChain records an on-chain intercepted HTLC and reports whether it
+// should be offered to the external interceptor.
+func (s *InterceptableSwitch) holdOnChain(
+ fwd InterceptedForward) (bool, error) {
+
+ if fwd == nil {
+ return false, errNilHeldForward
+ }
+
+ inKey := fwd.Packet().IncomingCircuit
+
+ // An already on-chain held HTLC has already been offered with its
+ // on-chain deadline. Treat duplicate contractcourt offers as no-ops to
+ // avoid re-notifying the interceptor for the same on-chain state.
+ if _, ok := s.heldHtlcSet.set[inKey].(*onChainHeld); ok {
+ return false, nil
+ }
+
+ if err := s.heldHtlcSet.addOnChain(fwd); err != nil {
+ return false, err
+ }
+
+ return true, nil
+}
+
+// removeOnChainIntercept removes an on-chain held HTLC after contractcourt no
+// longer needs the interceptor replay handle.
+func (s *InterceptableSwitch) removeOnChainIntercept(key models.CircuitKey) {
+ if s.heldHtlcSet.removeOnChainHeld(key) {
+ log.Debugf("Removed on-chain held htlc %v", key)
+ }
+}
+
// handleExpired checks that the htlc isn't too close to the channel
// force-close broadcast height. If it is, it is cancelled back.
func (s *InterceptableSwitch) handleExpired(fwd *interceptedForward) (
@@ -654,8 +712,10 @@ func (f *interceptedForward) Packet() InterceptedPacket {
IncomingExpiry: f.packet.incomingTimeout,
InOnionCustomRecords: f.packet.inOnionCustomRecords,
OnionBlob: f.htlc.OnionBlob,
- AutoFailHeight: f.autoFailHeight,
- InWireCustomRecords: f.packet.inWireCustomRecords,
+ Deadline: fn.NewLeft[
+ OffChainAutoFailHeight, OnChainSettleDeadline,
+ ](OffChainAutoFailHeight(f.autoFailHeight)),
+ InWireCustomRecords: f.packet.inWireCustomRecords,
}
}
diff --git a/htlcswitch/interfaces.go b/htlcswitch/interfaces.go
index 75974a7..53d1c59 100644
--- a/htlcswitch/interfaces.go
+++ b/htlcswitch/interfaces.go
@@ -419,9 +419,34 @@ type InterceptedPacket struct {
// were defined by the peer that forwarded this HTLC to us.
InWireCustomRecords lnwire.CustomRecords
- // AutoFailHeight is the block height at which this intercept will be
- // failed back automatically.
- AutoFailHeight int32
+ // Deadline describes how long this intercepted HTLC remains actionable.
+ // Off-chain forwards are auto-failed at this height, while on-chain
+ // forwards can be settled until this height.
+ Deadline fn.Either[OffChainAutoFailHeight, OnChainSettleDeadline]
+}
+
+// OffChainAutoFailHeight is the block height at which an off-chain intercepted
+// HTLC will be failed back automatically to prevent the incoming channel from
+// force-closing.
+type OffChainAutoFailHeight int32
+
+// OnChainSettleDeadline is the block height until which an on-chain
+// intercepted HTLC can be settled before the timeout path becomes available.
+type OnChainSettleDeadline int32
+
+// AutoFailHeight returns the legacy RPC auto_fail_height projection for an
+// intercepted packet. For on-chain packets, the value is the settlement
+// deadline exposed through the existing RPC field for compatibility.
+func (p InterceptedPacket) AutoFailHeight() int32 {
+ return fn.ElimEither(
+ p.Deadline,
+ func(h OffChainAutoFailHeight) int32 {
+ return int32(h)
+ },
+ func(d OnChainSettleDeadline) int32 {
+ return int32(d)
+ },
+ )
}
// InterceptedForward is passed to the ForwardInterceptor for every forwarded
diff --git a/htlcswitch/switch_test.go b/htlcswitch/switch_test.go
index 93d16b3..d210669 100644
--- a/htlcswitch/switch_test.go
+++ b/htlcswitch/switch_test.go
@@ -4210,7 +4210,7 @@ func TestInterceptableSwitchWatchDog(t *testing.T) {
require.Equal(t,
int32(packet.incomingTimeout-c.cltvRejectDelta),
- intercepted.AutoFailHeight,
+ intercepted.AutoFailHeight(),
)
// Htlc expires before a resolution from the interceptor.
diff --git a/lnrpc/routerrpc/forward_interceptor.go b/lnrpc/routerrpc/forward_interceptor.go
index 6d6b3cf..61adf8f 100644
--- a/lnrpc/routerrpc/forward_interceptor.go
+++ b/lnrpc/routerrpc/forward_interceptor.go
@@ -96,7 +96,7 @@ func (r *forwardInterceptor) onIntercept(
IncomingExpiry: htlc.IncomingExpiry,
CustomRecords: htlc.InOnionCustomRecords,
OnionBlob: htlc.OnionBlob[:],
- AutoFailHeight: htlc.AutoFailHeight,
+ AutoFailHeight: htlc.AutoFailHeight(),
InWireCustomRecords: htlc.InWireCustomRecords,
}
diff --git a/witness_beacon.go b/witness_beacon.go
index eba0bca..7226182 100644
--- a/witness_beacon.go
+++ b/witness_beacon.go
@@ -6,6 +6,7 @@ import (
"github.com/lightningnetwork/lnd/channeldb"
"github.com/lightningnetwork/lnd/contractcourt"
+ "github.com/lightningnetwork/lnd/fn/v2"
"github.com/lightningnetwork/lnd/graph/db/models"
"github.com/lightningnetwork/lnd/htlcswitch"
"github.com/lightningnetwork/lnd/htlcswitch/hop"
@@ -106,6 +107,12 @@ func (p *preimageBeacon) SubscribeUpdates(
OutgoingAmount: payload.FwdInfo.AmountToForward,
InOnionCustomRecords: payload.CustomRecords(),
InWireCustomRecords: htlc.CustomRecords,
+ // Keep the on-chain intercept available to the
+ // interceptor until the HTLC expires on chain.
+ Deadline: fn.NewRight[
+ htlcswitch.OffChainAutoFailHeight,
+ htlcswitch.OnChainSettleDeadline,
+ ](htlcswitch.OnChainSettleDeadline(htlc.RefundTimeout)),
}
copy(packet.OnionBlob[:], nextHopOnionBlob)
Why this scored 59/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.