instantout: unlock reservations on OnRecover from in-flight states

SendPaymentAndPollAccepted and BuildHtlc both run after
PollPaymentAcceptedAction has called LockReservation on every
reservation backing the swap. Their OnRecover transitions pointed
directly to Failed, whose action is fsm.NoOpAction -- so on daemon
restart while in either state, the FSM moved to Failed without ever
unlocking the reservations. The local store kept them in the Locked
state until on-chain expiry (typically tens of hours later), making
them unusable for any subsequent swap. For users who pay for
reservations (PR #883's invoice-requested flow) that is a direct
material loss.

Add an intermediate UnlockReservationsOnRecover state whose action
calls handleErrorAndUnlockReservations and then routes to Failed via
the normal OnError edge. SendPaymentAndPollAccepted.OnRecover and
BuildHtlc.OnRecover now point at this state instead of Failed
directly.

Init.OnRecover -> Failed is left alone because at that point the
InstantOut row has not yet been persisted and no reservation locks
have been taken; there is nothing to clean up. Post-PushPreimage
states (PushPreimage.OnRecover -> PushPreimage, etc.) are also left
alone since they self-loop on recovery rather than terminate.

The cleanup helper itself still derives its context from the caller's
context (see existing handleErrorAndUnlockReservations); fixing that
context-cancel hazard is a separate change.
This commit is contained in:
Slyghtning 2026-05-11 16:43:52 +02:00
parent 17df6e86e6
commit 10512bfa19
No known key found for this signature in database
GPG key ID: F82D456EA023C9BF
2 changed files with 37 additions and 2 deletions

View file

@ -691,6 +691,21 @@ func (f *FSM) WaitForHtlcSweepConfirmedAction(ctx context.Context,
}
}
// unlockReservationsOnRecoverAction is the action of the
// UnlockReservationsOnRecover state. It is entered via OnRecover from any
// in-flight state where the reservations are already locked, and it unlocks
// them before routing to Failed. Without this, a crash between
// PollPaymentAcceptedAction's LockReservation and the swap reaching a
// terminal state would leave the reservations permanently Locked in the
// local store, blocking any future InstantOut that wants to spend them.
func (f *FSM) unlockReservationsOnRecoverAction(ctx context.Context,
_ fsm.EventContext) fsm.EventType {
return f.handleErrorAndUnlockReservations(
ctx, errors.New("instant out recovered from in-flight state"),
)
}
// handleErrorAndUnlockReservations handles an error and unlocks the
// reservations.
func (f *FSM) handleErrorAndUnlockReservations(ctx context.Context,

View file

@ -85,6 +85,14 @@ var (
// FailedHtlcSweep is the state where the htlc sweep failed.
FailedHtlcSweep = fsm.StateType("FailedHtlcSweep")
// UnlockReservationsOnRecover is a transient state entered via
// OnRecover from any in-flight state that had already locked the
// underlying reservations. Its action unlocks them and routes the
// FSM to Failed, so a crash mid-swap does not leave reservations
// stuck Locked in the local store.
UnlockReservationsOnRecover = fsm.StateType(
"UnlockReservationsOnRecover")
// Failed is the state where the swap failed.
Failed = fsm.StateType("InstantOutFailed")
)
@ -246,7 +254,11 @@ func (f *FSM) GetV1ReservationStates() fsm.States {
Transitions: fsm.Transitions{
OnPaymentAccepted: BuildHtlc,
fsm.OnError: Failed,
OnRecover: Failed,
// OnRecover must go through cleanup since
// PollPaymentAcceptedAction has already locked
// the reservations by the time the FSM can
// crash here.
OnRecover: UnlockReservationsOnRecover,
},
Action: f.PollPaymentAcceptedAction,
},
@ -254,10 +266,18 @@ func (f *FSM) GetV1ReservationStates() fsm.States {
Transitions: fsm.Transitions{
OnHtlcSigReceived: PushPreimage,
fsm.OnError: Failed,
OnRecover: Failed,
// Same as SendPaymentAndPollAccepted -- the
// reservations are still locked at this point.
OnRecover: UnlockReservationsOnRecover,
},
Action: f.BuildHTLCAction,
},
UnlockReservationsOnRecover: fsm.State{
Transitions: fsm.Transitions{
fsm.OnError: Failed,
},
Action: f.unlockReservationsOnRecoverAction,
},
PushPreimage: fsm.State{
Transitions: fsm.Transitions{
OnSweeplessSweepPublished: WaitForSweeplessSweepConfirmed,