instantout: recheck reservation timing during recovery

Compare reservation expiry with the current height during recovery and
use the HTLC path when the remaining window is too short.
This commit is contained in:
Slyghtning 2026-08-11 11:38:27 +02:00
parent adc181ea83
commit cfbf74161b
No known key found for this signature in database
GPG key ID: F82D456EA023C9BF
3 changed files with 56 additions and 1 deletions

View file

@ -63,6 +63,12 @@ type InitInstantOutCtx struct {
sweepAddress btcutil.Address
}
// RecoverInstantOutCtx contains the chain height at which an instant out is
// resumed after restart.
type RecoverInstantOutCtx struct {
currentHeight int32
}
// 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 +388,26 @@ 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 recoverCtx, ok := eventCtx.(*RecoverInstantOutCtx); ok {
minReservationExpiry := int64(recoverCtx.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"
@ -113,3 +114,28 @@ 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{},
InstantOut: &InstantOut{
Reservations: []*reservation.Reservation{
{
ID: reservation.ID{1},
Expiry: 139,
},
},
},
}
event := instantOutFSM.PushPreimageAction(
t.Context(), &RecoverInstantOutCtx{currentHeight: 100},
)
require.Equal(t, OnErrorPublishHtlc, event)
require.ErrorContains(
t, instantOutFSM.LastActionError, "before recovery safety height",
)
}

View file

@ -119,8 +119,11 @@ func (m *Manager) recoverInstantOuts(ctx context.Context) error {
// As SendEvent can block, we'll start a goroutine to process
// the event.
recoverCtx := &RecoverInstantOutCtx{
currentHeight: m.currentHeight,
}
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",