From 880c848e5511071c84def83d0bb5a9f3b112f99e Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 20 May 2026 16:02:30 +0200 Subject: [PATCH 1/4] instantout: guard MuSig peer slice lengths The instant out client indexes peer nonces and signatures by reservation index while signing and finalizing MuSig transactions. A server response with too few peer values can panic loopd before an error is returned. Validate the peer-provided slice counts against the reservation inputs before indexing them. --- 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 d77c154ca4f4d065fc389a66626b9a421171f825 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 20 May 2026 16:02:59 +0200 Subject: [PATCH 2/4] instantout: store active swaps under real hash New instant outs were added to the active swap map before the start action generated the swap preimage and hash. That stores the FSM under the zero hash, so later lookups by the real hash fail and concurrent swaps can overwrite the same zero-hash entry. Insert the active FSM only after initialization reaches the expected state and the real hash is available. --- 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 09f5a3b3943ae3e22851a8f8c9444d09d9c4449c Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 20 May 2026 16:03:24 +0200 Subject: [PATCH 3/4] instantout: treat closed payment streams as errors The payment polling action read from the payment status and error channels without checking whether they were closed. A closed error channel yielded a nil error, and context cancellation also passed nil into HandleError, allowing the observer's abort-on-error path to return nil for a failed swap. Convert closed channels, nil channel errors, and cancellation into explicit errors before failing the FSM. --- 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 bac35f52d099a7bab62da1b6507bbb788fd11225 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 20 May 2026 16:03:46 +0200 Subject: [PATCH 4/4] instantout: use fresh contexts for cleanup The error cleanup path derived unlock and cancel contexts from the caller context. When the caller context was already canceled, cleanup could fail immediately; the async server cancel also captured a context that was canceled by the parent function's defer. Use independent timeout contexts for local reservation unlocks and the server cancel RPC. --- 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{