From b596eabab3e27c927534ad100f2c359faccf1e4e 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 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. --- instantout/actions.go | 47 ++++++++++++++++++++++ instantout/instantout_test.go | 76 +++++++++++++++++++++++++++++++++++ instantout/manager.go | 3 +- 3 files changed, 125 insertions(+), 1 deletion(-) diff --git a/instantout/actions.go b/instantout/actions.go index fffe987c..2d623811 100644 --- a/instantout/actions.go +++ b/instantout/actions.go @@ -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, diff --git a/instantout/instantout_test.go b/instantout/instantout_test.go index fd45b788..48195c32 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" @@ -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", + ) +} diff --git a/instantout/manager.go b/instantout/manager.go index 37ccb681..0d806251 100644 --- a/instantout/manager.go +++ b/instantout/manager.go @@ -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",