instantout: recheck reservation timing during recovery

Refresh the chain height when a swap resumes, verify both the
reservation and HTLC windows, and select the HTLC path when either
remaining window is too short.
This commit is contained in:
Slyghtning 2026-08-11 11:38:27 +02:00
parent b689e361c1
commit b596eabab3
No known key found for this signature in database
GPG key ID: F82D456EA023C9BF
3 changed files with 125 additions and 1 deletions

View file

@ -51,6 +51,10 @@ const (
// htlcExpiryDelta is the delta in blocks we require between the htlc
// expiry and reservation expiry.
htlcExpiryDelta = int32(40)
// htlcRecoverySafetyDelta leaves one urgent confirmation target for
// the HTLC and another for its preimage sweep after recovery.
htlcRecoverySafetyDelta = 2 * urgentConfTarget
)
// InitInstantOutCtx contains the context for the InitInstantOutAction.
@ -63,6 +67,9 @@ type InitInstantOutCtx struct {
sweepAddress btcutil.Address
}
// RecoverInstantOutCtx marks an action as being resumed after restart.
type RecoverInstantOutCtx struct{}
// InitInstantOutAction is the first action that is executed when the instant
// out FSM is started. It will send the instant out request to the server.
func (f *FSM) InitInstantOutAction(ctx context.Context,
@ -382,6 +389,46 @@ func (f *FSM) BuildHTLCAction(ctx context.Context,
func (f *FSM) PushPreimageAction(ctx context.Context,
eventCtx fsm.EventContext) fsm.EventType {
// A recovered swap may have been offline long enough that the server's
// reservation timeout is now close. Fall back to the already finalized
// HTLC instead of revealing the preimage without enough time to publish
// that safety transaction.
if _, ok := eventCtx.(*RecoverInstantOutCtx); ok {
info, err := f.cfg.LndClient.GetInfo(ctx)
if err != nil {
f.LastActionError = fmt.Errorf(
"unable to get recovery chain height: %w", err,
)
return OnErrorPublishHtlc
}
currentHeight := int64(info.BlockHeight)
minHtlcExpiry := currentHeight +
int64(htlcRecoverySafetyDelta)
if int64(f.InstantOut.CltvExpiry) < minHtlcExpiry {
f.LastActionError = fmt.Errorf("instant out HTLC expires at "+
"height %d, before recovery safety height %d",
f.InstantOut.CltvExpiry, minHtlcExpiry)
return OnErrorPublishHtlc
}
minReservationExpiry := currentHeight +
int64(htlcExpiryDelta)
for _, res := range f.InstantOut.Reservations {
if int64(res.Expiry) >= minReservationExpiry {
continue
}
f.LastActionError = fmt.Errorf("reservation %x expires at "+
"height %d, before recovery safety height %d",
res.ID, res.Expiry, minReservationExpiry)
return OnErrorPublishHtlc
}
}
// First we'll create the musig2 context.
coopSessions, coopClientNonces, err := f.InstantOut.createMusig2Session(
ctx, f.cfg.Signer,

View file

@ -8,6 +8,7 @@ import (
"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/wire"
"github.com/lightninglabs/lndclient"
"github.com/lightninglabs/loop/fsm"
"github.com/lightninglabs/loop/instantout/reservation"
"github.com/lightningnetwork/lnd/input"
"github.com/stretchr/testify/require"
@ -29,6 +30,23 @@ type cleanupTrackingSigner struct {
cleaned [][32]byte
}
type fixedHeightLightningClient struct {
lndclient.LightningClient
height uint32
err error
}
func (c *fixedHeightLightningClient) GetInfo(context.Context) (
*lndclient.Info, error) {
if c.err != nil {
return nil, c.err
}
return &lndclient.Info{BlockHeight: c.height}, nil
}
func (s *cleanupTrackingSigner) MuSig2Cleanup(_ context.Context,
sessionID [32]byte) error {
@ -113,3 +131,61 @@ func TestCleanupMuSig2Sessions(t *testing.T) {
require.NoError(t, err)
require.Equal(t, [][32]byte{firstID, secondID}, signer.cleaned)
}
// TestPushPreimageRejectsExpiringReservation verifies that recovery takes the
// on-chain fallback before revealing the preimage when a reservation is too
// close to its server-controlled timeout.
func TestPushPreimageRejectsExpiringReservation(t *testing.T) {
instantOutFSM := &FSM{
StateMachine: &fsm.StateMachine{},
cfg: &Config{
LndClient: &fixedHeightLightningClient{height: 100},
},
InstantOut: &InstantOut{
CltvExpiry: 200,
Reservations: []*reservation.Reservation{
{
ID: reservation.ID{1},
Expiry: 139,
},
},
},
}
event := instantOutFSM.PushPreimageAction(
t.Context(), &RecoverInstantOutCtx{},
)
require.Equal(t, OnErrorPublishHtlc, event)
require.ErrorContains(
t, instantOutFSM.LastActionError, "before recovery safety height",
)
}
// TestPushPreimageRejectsExpiringHtlc verifies that recovery uses a fresh
// chain height and leaves time to confirm both fallback transactions.
func TestPushPreimageRejectsExpiringHtlc(t *testing.T) {
instantOutFSM := &FSM{
StateMachine: &fsm.StateMachine{},
cfg: &Config{
LndClient: &fixedHeightLightningClient{height: 100},
},
InstantOut: &InstantOut{
CltvExpiry: 105,
Reservations: []*reservation.Reservation{
{
ID: reservation.ID{1},
Expiry: 200,
},
},
},
}
event := instantOutFSM.PushPreimageAction(
t.Context(), &RecoverInstantOutCtx{},
)
require.Equal(t, OnErrorPublishHtlc, event)
require.ErrorContains(
t, instantOutFSM.LastActionError,
"instant out HTLC expires at height 105",
)
}

View file

@ -119,8 +119,9 @@ func (m *Manager) recoverInstantOuts(ctx context.Context) error {
// As SendEvent can block, we'll start a goroutine to process
// the event.
recoverCtx := &RecoverInstantOutCtx{}
go func() {
err := instantOutFSM.SendEvent(ctx, OnRecover, nil)
err := instantOutFSM.SendEvent(ctx, OnRecover, recoverCtx)
if err != nil {
log.Errorf("FSM %v Error sending recover "+
"event %v, state: %v",