From add29cf2d0a63ba047d67329e3a681033c907321 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 20 May 2026 21:48:08 +0200 Subject: [PATCH 1/4] instantout: guard MuSig peer slice lengths Peer MuSig data was indexed by reservation position before validating counts. Short server responses could panic loopd instead of returning an error. --- instantout/instantout.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/instantout/instantout.go b/instantout/instantout.go index f8c89eb0c..d9405ffaf 100644 --- a/instantout/instantout.go +++ b/instantout/instantout.go @@ -263,6 +263,12 @@ func (i *InstantOut) signMusig2Tx(ctx context.Context, if err != nil { return nil, err } + if len(musig2sessions) != len(inputs) { + return nil, fmt.Errorf("invalid number of MuSig2 sessions") + } + if len(counterPartyNonces) != len(inputs) { + return nil, fmt.Errorf("invalid number of peer nonces") + } prevOutFetcher := inputs.GetPrevoutFetcher() sigHashes := txscript.NewTxSigHashes(tx, prevOutFetcher) @@ -329,6 +335,12 @@ func (i *InstantOut) finalizeMusig2Transaction(ctx context.Context, if err != nil { return nil, err } + if len(musig2Sessions) != len(inputs) { + return nil, fmt.Errorf("invalid number of MuSig2 sessions") + } + if len(serverSigs) != len(inputs) { + return nil, fmt.Errorf("invalid number of peer signatures") + } for idx := range inputs { haveAllSigs, finalSig, err := signer.MuSig2CombineSig( From fc884a4ef03b17f3fbaed2d684481bd6c43c6d9a Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 20 May 2026 21:48:20 +0200 Subject: [PATCH 2/4] instantout: store active swaps under real hash New instant outs were added to the active map before the swap hash existed. The FSM was stored under the zero hash, making real-hash lookups fail. --- instantout/manager.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/instantout/manager.go b/instantout/manager.go index 37ccb681b..be895e18f 100644 --- a/instantout/manager.go +++ b/instantout/manager.go @@ -159,14 +159,12 @@ func (m *Manager) NewInstantOut(ctx context.Context, protocolVersion: CurrentProtocolVersion(), sweepAddress: sweepAddr, } + m.Unlock() instantOut, err := NewFSM(m.cfg, ProtocolVersionFullReservation) if err != nil { - m.Unlock() return nil, err } - m.activeInstantOuts[instantOut.InstantOut.SwapHash] = instantOut - m.Unlock() // Start the instantout FSM. go func() { @@ -186,6 +184,10 @@ func (m *Manager) NewInstantOut(ctx context.Context, return nil, err } + m.Lock() + m.activeInstantOuts[instantOut.InstantOut.SwapHash] = instantOut + m.Unlock() + return instantOut, nil } From 6b3f4d985674e04ffeff06088c21f2f50b27de69 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 20 May 2026 21:48:32 +0200 Subject: [PATCH 3/4] instantout: treat closed payment streams as errors Closed payment channels yielded zero values or nil errors in failure paths. Return explicit errors so the observer cannot report a failed swap as success. --- instantout/actions.go | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/instantout/actions.go b/instantout/actions.go index d1c405fd8..18c31d288 100644 --- a/instantout/actions.go +++ b/instantout/actions.go @@ -244,7 +244,13 @@ func (f *FSM) PollPaymentAcceptedAction(ctx context.Context, timer := time.NewTimer(time.Second) for { select { - case payRes := <-payChan: + case payRes, ok := <-payChan: + if !ok { + return f.handleErrorAndUnlockReservations( + ctx, errors.New("payment status channel closed"), + ) + } + f.Debugf("payment result: %v", payRes) if payRes.State == lnrpc.Payment_FAILED { return f.handleErrorAndUnlockReservations( @@ -252,12 +258,18 @@ func (f *FSM) PollPaymentAcceptedAction(ctx context.Context, payRes.FailureReason), ) } - case err := <-paymentErrChan: + case err, ok := <-paymentErrChan: + if !ok { + err = errors.New("payment error channel closed") + } else if err == nil { + err = errors.New("payment error channel returned nil") + } + f.Errorf("error sending payment: %v", err) return f.handleErrorAndUnlockReservations(ctx, err) case <-ctx.Done(): - return f.handleErrorAndUnlockReservations(ctx, nil) + return f.handleErrorAndUnlockReservations(ctx, ctx.Err()) case <-timer.C: res, err := f.cfg.InstantOutClient.PollPaymentAccepted( From db18e6e1d2cf763ee1b12ad755502c09a0dcbb64 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 20 May 2026 21:48:44 +0200 Subject: [PATCH 4/4] instantout: use fresh contexts for cleanup Cleanup contexts were derived from caller contexts that may be canceled. Use independent timeout contexts for reservation unlocks and server cancel. --- instantout/actions.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/instantout/actions.go b/instantout/actions.go index 18c31d288..bc297f843 100644 --- a/instantout/actions.go +++ b/instantout/actions.go @@ -632,13 +632,15 @@ func (f *FSM) handleErrorAndUnlockReservations(ctx context.Context, err error) fsm.EventType { // We might get here from a canceled context, we create a new context // with a timeout to unlock the reservations. - ctx, cancel := context.WithTimeout(ctx, time.Second*30) + unlockCtx, cancel := context.WithTimeout( + context.Background(), time.Second*30, + ) defer cancel() // Unlock the reservations. for _, reservation := range f.InstantOut.Reservations { err := f.cfg.ReservationManager.UnlockReservation( - ctx, reservation.ID, + unlockCtx, reservation.ID, ) if err != nil { f.Errorf("error unlocking reservation: %v", err) @@ -650,7 +652,9 @@ func (f *FSM) handleErrorAndUnlockReservations(ctx context.Context, // release the reservations. This can be done in a goroutine as we // wan't to fail the fsm early. go func() { - ctx, cancel := context.WithTimeout(ctx, time.Second*30) + ctx, cancel := context.WithTimeout( + context.Background(), time.Second*30, + ) defer cancel() _, cancelErr := f.cfg.InstantOutClient.CancelInstantSwap( ctx, &swapserverrpc.CancelInstantSwapRequest{