diff --git a/instantout/actions.go b/instantout/actions.go index 8353ee1e..6d3f403c 100644 --- a/instantout/actions.go +++ b/instantout/actions.go @@ -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, diff --git a/instantout/fsm.go b/instantout/fsm.go index c188b158..518bd34a 100644 --- a/instantout/fsm.go +++ b/instantout/fsm.go @@ -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,