htlcswitch: track held HTLC source

Store held forwards as off-chain or on-chain entries instead of a raw
InterceptedForward map. Off-chain entries keep the existing resume, fail,
settle and auto-fail behavior. On-chain entries are settle-only and
expire by pruning local interceptor state.

When contractcourt re-offers a circuit that is already held off-chain,
replace the stored entry with the on-chain forward so a later SETTLE
reaches the witness beacon instead of the old link mailbox path.

Also set the on-chain interceptor deadline to the HTLC refund timeout.
This keeps the public interceptor deadline populated while ensuring only
off-chain held entries use that value to fail back.

Only off-chain held HTLCs can be released when an optional interceptor
disconnects, because they can resume into the link forwarding flow.

On-chain held HTLCs have no link flow to resume. Keep them in the held
set so a reconnecting interceptor can replay and settle them while
contractcourt waits for the preimage or on-chain expiry.

Use distinct internal deadline types for off-chain auto-fail heights and
on-chain settlement deadlines instead of overloading the intercepted packet
field.

Project both variants back into the existing router RPC auto_fail_height
field to preserve wire compatibility. Reject mismatched held HTLC deadline
types in tests.

On-chain intercepted HTLCs can only be settled. Resume and fail actions
already return concrete errors through the on-chain intercepted forward, so
let those errors propagate to the interceptor client instead of converting
them to success.

Keep the held entry tracked on these errors so the client can reconnect and
settle the HTLC later.

(cherry picked from commit eb1193f80b)
This commit is contained in:
ziggie 2026-06-22 08:35:04 -03:00 committed by github-actions[bot]
parent 8ea903dec0
commit 96eacb60cc
7 changed files with 1084 additions and 209 deletions

View file

@ -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())
}
}
// 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)
}
// 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
h.set = make(map[models.CircuitKey]InterceptedForward)
}
// 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 {
for key, entry := range h.set {
offChain, ok := entry.(*offChainHeld)
if !ok {
continue
}
cb(fwd)
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
}
// 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]
if !ok {
return nil, fmt.Errorf("fwd %v not found", key)
// 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
}
delete(h.set, key)
return intercepted, nil
return true
}
// 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
}
if remove {
delete(h.set, key)
}
}
return errs
}
// 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 fmt.Errorf("%w: %v", ErrFwdNotExists, res.Key)
}
if err := entry.resolve(res); err != nil {
return err
}
delete(h.set, res.Key)
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
}
h.set[key] = fwd
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] = entry
return nil
}

View file

@ -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"
)
var errTestForward = errors.New("test forward error")
// mockInterceptedForward is an InterceptedForward test double that records
// resolution calls and returns configured errors.
type mockInterceptedForward struct {
mock.Mock
packet InterceptedPacket
}
// newMockInterceptedForward creates a mock intercepted forward with the given
// circuit key and auto-fail deadline.
func newMockInterceptedForward(key models.CircuitKey,
deadline int32) *mockInterceptedForward {
return &mockInterceptedForward{
packet: InterceptedPacket{
IncomingCircuit: key,
Deadline: fn.NewLeft[
OffChainAutoFailHeight, OnChainSettleDeadline,
](OffChainAutoFailHeight(deadline)),
},
}
}
// 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()
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()
// Test operations on an empty set.
require.False(t, set.exists(models.CircuitKey{}))
_, err := set.pop(models.CircuitKey{})
require.Error(t, err)
set.popAll(
func(_ InterceptedForward) {
require.Fail(t, "unexpected fwd")
},
)
require.ErrorIs(t, set.resolve(&FwdResolution{}), ErrFwdNotExists)
require.Empty(t, set.releaseAllOffChainHeld())
}
func TestHeldHtlcSet(t *testing.T) {
// TestHeldHtlcSetRejectsInvalidDeadline verifies invalid deadlines are
// rejected for both off-chain and on-chain held entries.
func TestHeldHtlcSetRejectsInvalidDeadline(t *testing.T) {
set := newHeldHtlcSet()
key := testCircuitKey()
key := models.CircuitKey{
ChanID: lnwire.NewShortChanIDFromInt(1),
HtlcID: 2,
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()
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()
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(),
}
// Test pushing a nil forward.
require.Error(t, set.push(key, nil))
require.NoError(t, s.heldHtlcSet.addOffChain(fwd))
// Test pushing a forward.
fwd := &interceptedForward{
htlc: &lnwire.UpdateAddHTLC{},
}
require.NoError(t, set.push(key, fwd))
// Re-pushing should fail.
require.Error(t, set.push(key, 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)
_, err = set.pop(key)
require.Error(t, err)
// Pushing the forward again.
require.NoError(t, set.push(key, fwd))
// Test for each.
var cbCalled bool
set.forEach(func(_ InterceptedForward) {
cbCalled = true
require.Equal(t, fwd, poppedFwd)
})
require.True(t, cbCalled)
// Test popping all forwards.
cbCalled = false
set.popAll(
func(_ InterceptedForward) {
cbCalled = true
require.Equal(t, fwd, poppedFwd)
},
)
require.True(t, cbCalled)
_, err = set.pop(key)
require.Error(t, err)
require.True(t, handled)
}
func TestHeldHtlcSetAutoFails(t *testing.T) {
// 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()
key := models.CircuitKey{
ChanID: lnwire.NewShortChanIDFromInt(1),
HtlcID: 2,
}
require.NoError(t, set.addOffChain(fwd))
require.ErrorIs(t, set.resolve(&FwdResolution{
Key: key,
Action: FwdActionSettle,
}), errTestForward)
const autoFailHeight = 100
fwd := &interceptedForward{
packet: &htlcPacket{},
htlc: &lnwire.UpdateAddHTLC{},
autoFailHeight: autoFailHeight,
}
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")
},
)
// Popping succeeds at the auto-fail height.
cbCalled := false
set.popAutoFails(
autoFailHeight,
func(poppedFwd InterceptedForward) {
cbCalled = true
require.Equal(t, fwd, poppedFwd)
},
)
require.True(t, cbCalled)
// After this, there should be nothing more to pop.
set.popAutoFails(
autoFailHeight,
func(_ InterceptedForward) {
require.Fail(t, "unexpected fwd")
},
)
require.True(t, set.exists(key))
fwd.AssertExpectations(t)
}
// 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()
require.NoError(t, set.addOffChain(fwd))
require.Empty(t, set.releaseAllOffChainHeld())
require.False(t, set.exists(key))
fwd.AssertExpectations(t)
}
// 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)
require.NoError(t, set.addOnChain(fwd))
require.Empty(t, set.releaseAllOffChainHeld())
require.True(t, set.exists(key))
fwd.AssertNotCalled(t, "Resume")
}
// 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()
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
}
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),
}
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())
}()
intercepted := make(chan InterceptedPacket, 2)
s.SetInterceptor(func(packet InterceptedPacket) error {
intercepted <- packet
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)
}

View file

@ -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 <- fwd:
case s.onchainIntercepted <- &onchainInterceptRequest{
fwd: fwd,
errChan: errChan,
}:
case <-s.quit:
return errors.New("interceptable switch quit")
}
select {
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,
}
}

View file

@ -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

View file

@ -4216,7 +4216,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.

View file

@ -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,
}

View file

@ -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)