From cfbf74161b94742b84fd18130d2b86e5114d805d Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Tue, 11 Aug 2026 11:38:27 +0200 Subject: [PATCH] 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. --- instantout/actions.go | 26 ++++++++++++++++++++++++++ instantout/instantout_test.go | 26 ++++++++++++++++++++++++++ instantout/manager.go | 5 ++++- 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/instantout/actions.go b/instantout/actions.go index 8a1ca3f0..d1ba889f 100644 --- a/instantout/actions.go +++ b/instantout/actions.go @@ -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, diff --git a/instantout/instantout_test.go b/instantout/instantout_test.go index fd45b788..f95218bd 100644 --- a/instantout/instantout_test.go +++ b/instantout/instantout_test.go @@ -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", + ) +} diff --git a/instantout/manager.go b/instantout/manager.go index 37ccb681..9413f5bb 100644 --- a/instantout/manager.go +++ b/instantout/manager.go @@ -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",