From 7cab89d45b4b86165cf02eee578a028ec621953d Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Tue, 11 Aug 2026 11:30:10 +0200 Subject: [PATCH 01/12] looprpc: align instant out permissions with loop out Apply the loop:out permission to Instant Out and reservation RPCs so their authorization requirements match the rest of the Loop Out API. --- looprpc/perms.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/looprpc/perms.go b/looprpc/perms.go index d646f667..9187920a 100644 --- a/looprpc/perms.go +++ b/looprpc/perms.go @@ -177,18 +177,30 @@ var RequiredPermissions = map[string][]bakery.Op{ "/looprpc.SwapClient/ListReservations": {{ Entity: "swap", Action: "read", + }, { + Entity: "loop", + Action: "out", }}, "/looprpc.SwapClient/InstantOut": {{ Entity: "swap", Action: "execute", + }, { + Entity: "loop", + Action: "out", }}, "/looprpc.SwapClient/InstantOutQuote": {{ Entity: "swap", Action: "read", + }, { + Entity: "loop", + Action: "out", }}, "/looprpc.SwapClient/ListInstantOuts": {{ Entity: "swap", Action: "read", + }, { + Entity: "loop", + Action: "out", }}, "/looprpc.SwapClient/StopDaemon": {{ Entity: "loop", From 806c1f9356594243d67f8e8cad69ca50ce23ab5b Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Tue, 11 Aug 2026 11:31:08 +0200 Subject: [PATCH 02/12] reservation: keep processing after notification errors Log individual reservation initialization failures and continue consuming later notifications instead of stopping the manager. --- instantout/reservation/manager.go | 3 +- instantout/reservation/manager_test.go | 45 ++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/instantout/reservation/manager.go b/instantout/reservation/manager.go index 600febfe..b5ff0443 100644 --- a/instantout/reservation/manager.go +++ b/instantout/reservation/manager.go @@ -80,7 +80,8 @@ func (m *Manager) Run(ctx context.Context, height int32, runCtx, uint32(currentHeight), reservationRes, ) if err != nil { - return err + log.Errorf("Unable to create reservation %x: %v", + reservationRes.ReservationId, err) } case err := <-newBlockErrChan: diff --git a/instantout/reservation/manager_test.go b/instantout/reservation/manager_test.go index 79455750..4b01fcfa 100644 --- a/instantout/reservation/manager_test.go +++ b/instantout/reservation/manager_test.go @@ -99,6 +99,51 @@ func TestManager(t *testing.T) { require.NoError(t, err) } +// TestManagerContinuesAfterInvalidNotification verifies that a malformed +// server notification doesn't stop the reservation manager from processing +// later notifications. +func TestManagerContinuesAfterInvalidNotification(t *testing.T) { + testContext := newManagerTestContext(t) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + + initChan := make(chan struct{}) + errChan := make(chan error, 1) + go func() { + errChan <- testContext.manager.Run( + ctx, testContext.mockLnd.Height, initChan, + ) + }() + + <-initChan + + // A malformed ID is rejected by newReservation. The manager should log + // the error and continue processing the stream. + testContext.reservationNotificationChan <- &swapserverrpc.ServerReservationNotification{ + ReservationId: []byte{1}, + } + + testContext.reservationNotificationChan <- &swapserverrpc.ServerReservationNotification{ + ReservationId: defaultReservationId[:], + Value: uint64(defaultValue), + ServerKey: defaultPubkeyBytes, + Expiry: uint32(testContext.mockLnd.Height) + + defaultExpiry, + } + + select { + case <-testContext.mockLnd.RegisterConfChannel: + case err := <-errChan: + require.NoError(t, err) + t.Fatal("reservation manager stopped after malformed notification") + case <-time.After(5 * time.Second): + t.Fatal("valid reservation notification was not processed") + } + + cancel() + require.NoError(t, <-errChan) +} + // ManagerTestContext is a helper struct that contains all the necessary // components to test the reservation manager. type ManagerTestContext struct { From 2989ceee428dd216c5434b98ab82dbd909787e11 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Tue, 11 Aug 2026 11:31:55 +0200 Subject: [PATCH 03/12] reservation: isolate asynchronous initialization errors Use a goroutine-local result for event dispatch so observer errors remain independent and initialization outcomes stay deterministic. --- instantout/reservation/manager.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/instantout/reservation/manager.go b/instantout/reservation/manager.go index b5ff0443..0a902c05 100644 --- a/instantout/reservation/manager.go +++ b/instantout/reservation/manager.go @@ -131,9 +131,11 @@ func (m *Manager) newReservation(ctx context.Context, currentHeight uint32, // Send the init event to the state machine. go func() { - err = reservationFSM.SendEvent(ctx, OnServerRequest, initContext) - if err != nil { - log.Errorf("Error sending init event: %v", err) + sendErr := reservationFSM.SendEvent( + ctx, OnServerRequest, initContext, + ) + if sendErr != nil { + log.Errorf("Error sending init event: %v", sendErr) } }() From 41b884f610fce2a298f7e5f9660f1bd8b8fa2582 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Tue, 11 Aug 2026 11:32:52 +0200 Subject: [PATCH 04/12] reservation: reject duplicate reservation entries Check active and persisted reservations before creating a new state machine, preserving the existing reservation when a duplicate arrives. --- instantout/reservation/manager.go | 24 ++++++++++++++++++++- instantout/reservation/manager_test.go | 29 ++++++++++++++++++++++++++ instantout/reservation/store.go | 4 ++++ 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/instantout/reservation/manager.go b/instantout/reservation/manager.go index 0a902c05..930dff7b 100644 --- a/instantout/reservation/manager.go +++ b/instantout/reservation/manager.go @@ -2,6 +2,7 @@ package reservation import ( "context" + "errors" "fmt" "strings" "sync" @@ -111,13 +112,28 @@ func (m *Manager) newReservation(ctx context.Context, currentHeight uint32, return nil, err } + _, err = m.cfg.Store.GetReservation(ctx, reservationID) + switch { + case err == nil: + return nil, ErrReservationAlreadyExists + + case !errors.Is(err, ErrReservationNotFound): + return nil, err + } + // Create the reservation state machine. We need to pass in the runCtx // of the reservation manager so that the state machine will keep on // running even if the grpc conte reservationFSM := NewFSM(m.cfg) - // Add the reservation to the active reservations map. + // Add the reservation to the active reservations map. Check the map while + // holding the lock as concurrent callers may both have completed the store + // lookup above. m.Lock() + if _, ok := m.activeReservations[reservationID]; ok { + m.Unlock() + return nil, ErrReservationAlreadyExists + } m.activeReservations[reservationID] = reservationFSM m.Unlock() @@ -146,6 +162,12 @@ func (m *Manager) newReservation(ctx context.Context, currentHeight uint32, fsm.WithWaitForStateOption(time.Second), ) if err != nil { + m.Lock() + if m.activeReservations[reservationID] == reservationFSM { + delete(m.activeReservations, reservationID) + } + m.Unlock() + if reservationFSM.LastActionError != nil { return nil, fmt.Errorf("error waiting for "+ "state: %v, last action error: %v", diff --git a/instantout/reservation/manager_test.go b/instantout/reservation/manager_test.go index 4b01fcfa..0437955e 100644 --- a/instantout/reservation/manager_test.go +++ b/instantout/reservation/manager_test.go @@ -144,6 +144,35 @@ func TestManagerContinuesAfterInvalidNotification(t *testing.T) { require.NoError(t, <-errChan) } +// TestManagerRejectsDuplicateReservation verifies that a duplicate server +// notification cannot replace the active FSM for an existing reservation. +func TestManagerRejectsDuplicateReservation(t *testing.T) { + testContext := newManagerTestContext(t) + ctx := t.Context() + req := &swapserverrpc.ServerReservationNotification{ + ReservationId: defaultReservationId[:], + Value: uint64(defaultValue), + ServerKey: defaultPubkeyBytes, + Expiry: uint32(testContext.mockLnd.Height) + + defaultExpiry, + } + + firstFSM, err := testContext.manager.newReservation( + ctx, uint32(testContext.mockLnd.Height), req, + ) + require.NoError(t, err) + + secondFSM, err := testContext.manager.newReservation( + ctx, uint32(testContext.mockLnd.Height), req, + ) + require.ErrorIs(t, err, ErrReservationAlreadyExists) + require.Nil(t, secondFSM) + require.Same( + t, firstFSM, + testContext.manager.activeReservations[defaultReservationId], + ) +} + // ManagerTestContext is a helper struct that contains all the necessary // components to test the reservation manager. type ManagerTestContext struct { diff --git a/instantout/reservation/store.go b/instantout/reservation/store.go index 117d02c6..72613f9c 100644 --- a/instantout/reservation/store.go +++ b/instantout/reservation/store.go @@ -180,6 +180,10 @@ func (r *SQLStore) GetReservation(ctx context.Context, return nil }) if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrReservationNotFound + } + return nil, err } From c75d7e3e81149e0c2dfd435b1e41a80fa36d3d3a Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Tue, 11 Aug 2026 11:34:36 +0200 Subject: [PATCH 05/12] reservation: bound and prune active state machines Limit newly accepted reservation state machines, resume all persisted reservations, remove terminal entries from memory, and make cleanup resilient to observer-driven eviction. --- instantout/actions.go | 25 ++-- instantout/cleanup_test.go | 77 ++++++++++++ instantout/reservation/interfaces.go | 14 ++- instantout/reservation/manager.go | 71 +++++++++-- instantout/reservation/manager_test.go | 166 +++++++++++++++++++++++++ 5 files changed, 331 insertions(+), 22 deletions(-) create mode 100644 instantout/cleanup_test.go diff --git a/instantout/actions.go b/instantout/actions.go index d1c405fd..25842652 100644 --- a/instantout/actions.go +++ b/instantout/actions.go @@ -617,20 +617,23 @@ func (f *FSM) WaitForHtlcSweepConfirmedAction(ctx context.Context, // handleErrorAndUnlockReservations handles an error and unlocks the // reservations. func (f *FSM) handleErrorAndUnlockReservations(ctx context.Context, - err error) fsm.EventType { + actionErr 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) + cleanupCtx, cancel := context.WithTimeout( + context.WithoutCancel(ctx), time.Second*30, + ) defer cancel() // Unlock the reservations. + var unlockErr error for _, reservation := range f.InstantOut.Reservations { err := f.cfg.ReservationManager.UnlockReservation( - ctx, reservation.ID, + cleanupCtx, reservation.ID, ) if err != nil { f.Errorf("error unlocking reservation: %v", err) - return f.HandleError(err) + unlockErr = errors.Join(unlockErr, err) } } @@ -638,10 +641,12 @@ 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) + cancelCtx, cancel := context.WithTimeout( + context.WithoutCancel(ctx), time.Second*30, + ) defer cancel() _, cancelErr := f.cfg.InstantOutClient.CancelInstantSwap( - ctx, &swapserverrpc.CancelInstantSwapRequest{ + cancelCtx, &swapserverrpc.CancelInstantSwapRequest{ SwapHash: f.InstantOut.SwapHash[:], }, ) @@ -652,7 +657,13 @@ func (f *FSM) handleErrorAndUnlockReservations(ctx context.Context, } }() - return f.HandleError(err) + // Preserve the action failure when cleanup also fails. If cleanup was + // the only failure, report it to the state machine. + if actionErr != nil { + return f.HandleError(actionErr) + } + + return f.HandleError(unlockErr) } func getMaxRoutingFee(amt btcutil.Amount) btcutil.Amount { diff --git a/instantout/cleanup_test.go b/instantout/cleanup_test.go new file mode 100644 index 00000000..45640079 --- /dev/null +++ b/instantout/cleanup_test.go @@ -0,0 +1,77 @@ +package instantout + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/lightninglabs/loop/fsm" + "github.com/lightninglabs/loop/instantout/reservation" + "github.com/lightninglabs/loop/swapserverrpc" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" +) + +type cleanupTestReservationManager struct { + ReservationManager + + unlockErr error +} + +func (m *cleanupTestReservationManager) UnlockReservation(context.Context, + reservation.ID) error { + + return m.unlockErr +} + +type cleanupTestInstantOutClient struct { + swapserverrpc.InstantSwapServerClient + + canceled chan struct{} +} + +func (c *cleanupTestInstantOutClient) CancelInstantSwap(context.Context, + *swapserverrpc.CancelInstantSwapRequest, ...grpc.CallOption) ( + *swapserverrpc.CancelInstantSwapResponse, error) { + + close(c.canceled) + return &swapserverrpc.CancelInstantSwapResponse{}, nil +} + +// TestCleanupPreservesActionError verifies that an unlock failure doesn't +// replace the action failure or prevent the cancellation notification. +func TestCleanupPreservesActionError(t *testing.T) { + actionErr := errors.New("action failed") + cancelClient := &cleanupTestInstantOutClient{ + canceled: make(chan struct{}), + } + instantOutFSM := &FSM{ + StateMachine: &fsm.StateMachine{}, + cfg: &Config{ + ReservationManager: &cleanupTestReservationManager{ + unlockErr: errors.New("unlock failed"), + }, + InstantOutClient: cancelClient, + }, + InstantOut: &InstantOut{ + Reservations: []*reservation.Reservation{ + {ID: reservation.ID{1}}, + }, + }, + } + + event := instantOutFSM.handleErrorAndUnlockReservations( + t.Context(), actionErr, + ) + require.Equal(t, fsm.OnError, event) + require.ErrorIs(t, instantOutFSM.LastActionError, actionErr) + require.Eventually(t, func() bool { + select { + case <-cancelClient.canceled: + return true + default: + return false + } + }, time.Second, time.Millisecond) +} diff --git a/instantout/reservation/interfaces.go b/instantout/reservation/interfaces.go index 04bf830d..23658d5a 100644 --- a/instantout/reservation/interfaces.go +++ b/instantout/reservation/interfaces.go @@ -8,14 +8,18 @@ import ( ) var ( - ErrReservationAlreadyExists = fmt.Errorf("reservation already exists") - ErrReservationNotFound = fmt.Errorf("reservation not found") + ErrReservationAlreadyExists = fmt.Errorf("reservation already exists") + ErrReservationNotFound = fmt.Errorf("reservation not found") + ErrTooManyActiveReservations = fmt.Errorf( + "too many active reservations", + ) ) const ( - KeyFamily = int32(42068) - DefaultConfTarget = int32(3) - IdLength = 32 + KeyFamily = int32(42068) + DefaultConfTarget = int32(3) + IdLength = 32 + maxActiveReservations = 1000 ) // Store is the interface that stores the reservations. diff --git a/instantout/reservation/manager.go b/instantout/reservation/manager.go index 930dff7b..bccdfa1e 100644 --- a/instantout/reservation/manager.go +++ b/instantout/reservation/manager.go @@ -14,6 +14,11 @@ import ( reservationrpc "github.com/lightninglabs/loop/swapserverrpc" ) +var ( + reservationStateWaitTimeout = 5 * time.Second + reservationStatePollDelay = time.Second +) + // Manager manages the reservation state machines. type Manager struct { sync.Mutex @@ -26,6 +31,28 @@ type Manager struct { activeReservations map[ID]*FSM } +// finalStateObserver removes a reservation FSM from the active set once it +// reaches a terminal state. +type finalStateObserver struct { + manager *Manager + id ID + fsm *FSM +} + +// Notify implements the fsm.Observer interface. +func (o *finalStateObserver) Notify(notification fsm.Notification) { + if !isFinalState(notification.NextState) { + return + } + + o.manager.Lock() + defer o.manager.Unlock() + + if o.manager.activeReservations[o.id] == o.fsm { + delete(o.manager.activeReservations, o.id) + } +} + // NewManager creates a new reservation manager. func NewManager(cfg *Config) *Manager { return &Manager{ @@ -134,9 +161,19 @@ func (m *Manager) newReservation(ctx context.Context, currentHeight uint32, m.Unlock() return nil, ErrReservationAlreadyExists } + if len(m.activeReservations) >= maxActiveReservations { + m.Unlock() + return nil, ErrTooManyActiveReservations + } m.activeReservations[reservationID] = reservationFSM m.Unlock() + reservationFSM.RegisterObserver(&finalStateObserver{ + manager: m, + id: reservationID, + fsm: reservationFSM, + }) + initContext := &InitReservationContext{ reservationID: reservationID, serverPubkey: serverKey, @@ -158,16 +195,10 @@ func (m *Manager) newReservation(ctx context.Context, currentHeight uint32, // We'll now wait for the reservation to be in the state where it is // waiting to be confirmed. err = reservationFSM.DefaultObserver.WaitForState( - ctx, 5*time.Second, WaitForConfirmation, - fsm.WithWaitForStateOption(time.Second), + ctx, reservationStateWaitTimeout, WaitForConfirmation, + fsm.WithWaitForStateOption(reservationStatePollDelay), ) if err != nil { - m.Lock() - if m.activeReservations[reservationID] == reservationFSM { - delete(m.activeReservations, reservationID) - } - m.Unlock() - if reservationFSM.LastActionError != nil { return nil, fmt.Errorf("error waiting for "+ "state: %v, last action error: %v", @@ -198,7 +229,14 @@ func (m *Manager) RecoverReservations(ctx context.Context) error { reservationFSM := NewFSMFromReservation(m.cfg, reservation) + m.Lock() m.activeReservations[reservation.ID] = reservationFSM + m.Unlock() + reservationFSM.RegisterObserver(&finalStateObserver{ + manager: m, + id: reservation.ID, + fsm: reservationFSM, + }) // As SendEvent can block, we'll start a goroutine to process // the event. @@ -236,7 +274,7 @@ func (m *Manager) LockReservation(ctx context.Context, id ID) error { m.Unlock() if !ok { - return fmt.Errorf("reservation not found") + return ErrReservationNotFound } // Try to send the lock event to the reservation. @@ -256,7 +294,20 @@ func (m *Manager) UnlockReservation(ctx context.Context, id ID) error { m.Unlock() if !ok { - return fmt.Errorf("reservation not found") + storedReservation, err := m.cfg.Store.GetReservation(ctx, id) + if err != nil { + return err + } + + // Terminal reservations are removed from the active set. Treat an + // unlock after that removal as idempotent, while still surfacing a + // missing active FSM for reservations that should be running. + if isFinalState(storedReservation.State) { + return nil + } + + return fmt.Errorf("%w: reservation %x is in state %v", + ErrReservationNotFound, id, storedReservation.State) } // Try to send the unlock event to the reservation. diff --git a/instantout/reservation/manager_test.go b/instantout/reservation/manager_test.go index 0437955e..ca3dec45 100644 --- a/instantout/reservation/manager_test.go +++ b/instantout/reservation/manager_test.go @@ -12,6 +12,7 @@ import ( "github.com/lightninglabs/loop/swapserverrpc" "github.com/lightninglabs/loop/test" "github.com/lightningnetwork/lnd/chainntnfs" + "github.com/lightningnetwork/lnd/keychain" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) @@ -97,6 +98,11 @@ func TestManager(t *testing.T) { // We'll now expect the reservation to be expired. err = reservationFSM.DefaultObserver.WaitForState(ctxb, 5*time.Second, Spent) require.NoError(t, err) + + testContext.manager.Lock() + _, ok := testContext.manager.activeReservations[defaultReservationId] + testContext.manager.Unlock() + require.False(t, ok) } // TestManagerContinuesAfterInvalidNotification verifies that a malformed @@ -173,6 +179,166 @@ func TestManagerRejectsDuplicateReservation(t *testing.T) { ) } +// TestManagerLimitsActiveReservations verifies that server notifications +// cannot grow the active FSM set without bound. +func TestManagerLimitsActiveReservations(t *testing.T) { + testContext := newManagerTestContext(t) + + for i := range maxActiveReservations { + var id ID + id[0] = byte(i) + id[1] = byte(i >> 8) + testContext.manager.activeReservations[id] = NewFSM( + testContext.manager.cfg, + ) + } + + reservationFSM, err := testContext.manager.newReservation( + t.Context(), uint32(testContext.mockLnd.Height), + &swapserverrpc.ServerReservationNotification{ + ReservationId: defaultReservationId[:], + Value: uint64(defaultValue), + ServerKey: defaultPubkeyBytes, + Expiry: uint32(testContext.mockLnd.Height) + + defaultExpiry, + }, + ) + require.ErrorIs(t, err, ErrTooManyActiveReservations) + require.Nil(t, reservationFSM) + require.Len( + t, testContext.manager.activeReservations, + maxActiveReservations, + ) +} + +// TestManagerKeepsReservationAfterWaitTimeout verifies that a caller-side +// wait timeout doesn't evict a reservation FSM that is still initializing. +func TestManagerKeepsReservationAfterWaitTimeout(t *testing.T) { + testContext := newManagerTestContext(t) + + originalWaitTimeout := reservationStateWaitTimeout + originalPollDelay := reservationStatePollDelay + reservationStateWaitTimeout = 20 * time.Millisecond + reservationStatePollDelay = time.Millisecond + t.Cleanup(func() { + reservationStateWaitTimeout = originalWaitTimeout + reservationStatePollDelay = originalPollDelay + }) + + releaseOpen := make(chan struct{}) + testContext.mockReservationClient.ExpectedCalls = nil + testContext.mockReservationClient.On( + "OpenReservation", mock.Anything, mock.Anything, mock.Anything, + ).Run(func(mock.Arguments) { + <-releaseOpen + }).Return( + &swapserverrpc.ServerOpenReservationResponse{}, nil, + ) + + reservationFSM, err := testContext.manager.newReservation( + t.Context(), uint32(testContext.mockLnd.Height), + &swapserverrpc.ServerReservationNotification{ + ReservationId: defaultReservationId[:], + Value: uint64(defaultValue), + ServerKey: defaultPubkeyBytes, + Expiry: uint32(testContext.mockLnd.Height) + + defaultExpiry, + }, + ) + require.Error(t, err) + require.Nil(t, reservationFSM) + + testContext.manager.Lock() + activeFSM := testContext.manager.activeReservations[defaultReservationId] + testContext.manager.Unlock() + require.NotNil(t, activeFSM) + + close(releaseOpen) + require.NoError(t, activeFSM.DefaultObserver.WaitForState( + t.Context(), 5*time.Second, WaitForConfirmation, + )) +} + +// TestManagerRecoversAllPersistedReservations verifies that the cap applied to +// new notifications doesn't prevent the manager from resuming obligations +// already recorded in the database. The terminal transitions also exercise +// concurrent observer-driven removal from the active map. +func TestManagerRecoversAllPersistedReservations(t *testing.T) { + reservations := make([]*Reservation, maxActiveReservations+1) + for i := range reservations { + reservations[i] = &Reservation{ + ID: ID{ + byte(i), byte(i >> 8), byte(i >> 16), + }, + State: Init, + ProtocolVersion: ProtocolVersionServerInitiated, + } + } + + manager := NewManager(&Config{ + Store: &recoveryStore{reservations: reservations}, + }) + require.NoError(t, manager.RecoverReservations(t.Context())) + require.Eventually(t, func() bool { + manager.Lock() + defer manager.Unlock() + + return len(manager.activeReservations) == 0 + }, 5*time.Second, time.Millisecond) +} + +// TestUnlockTerminalReservationIsIdempotent verifies that cleanup can safely +// race with terminal-state eviction without masking the original swap result. +func TestUnlockTerminalReservationIsIdempotent(t *testing.T) { + testContext := newManagerTestContext(t) + storedReservation := &Reservation{ + ID: defaultReservationId, + State: Init, + ClientPubkey: defaultPubkey, + ServerPubkey: defaultPubkey, + Value: defaultValue, + Expiry: defaultExpiry, + ProtocolVersion: ProtocolVersionServerInitiated, + KeyLocator: keychain.KeyLocator{ + Family: keychain.KeyFamily(KeyFamily), + Index: 1, + }, + } + + require.NoError(t, testContext.manager.cfg.Store.CreateReservation( + t.Context(), storedReservation, + )) + storedReservation.State = TimedOut + require.NoError(t, testContext.manager.cfg.Store.UpdateReservation( + t.Context(), storedReservation, + )) + + require.NoError(t, testContext.manager.UnlockReservation( + t.Context(), defaultReservationId, + )) + require.ErrorIs(t, testContext.manager.UnlockReservation( + t.Context(), ID{1}, + ), ErrReservationNotFound) +} + +type recoveryStore struct { + Store + + reservations []*Reservation +} + +func (s *recoveryStore) ListReservations(context.Context) ([]*Reservation, + error) { + + return s.reservations, nil +} + +func (s *recoveryStore) UpdateReservation(context.Context, + *Reservation) error { + + return nil +} + // ManagerTestContext is a helper struct that contains all the necessary // components to test the reservation manager. type ManagerTestContext struct { From 51eb8f410aa79b585bf8aa290b025de055c94e1f Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Tue, 11 Aug 2026 11:35:33 +0200 Subject: [PATCH 06/12] reservation: validate confirmed output amounts Compare each confirmed transaction output with the expected reservation amount before advancing the state machine. --- instantout/reservation/actions_test.go | 10 +++++++++- instantout/reservation/manager_test.go | 1 + instantout/reservation/reservation.go | 11 +++++++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/instantout/reservation/actions_test.go b/instantout/reservation/actions_test.go index 40e6509b..643b8ce3 100644 --- a/instantout/reservation/actions_test.go +++ b/instantout/reservation/actions_test.go @@ -203,6 +203,7 @@ func TestSubscribeToConfirmationAction(t *testing.T) { blockHeight int32 blockErr error sendTxConf bool + outputValue btcutil.Amount confErr error expectedEvent fsm.EventType }{ @@ -210,8 +211,15 @@ func TestSubscribeToConfirmationAction(t *testing.T) { name: "success", blockHeight: 0, sendTxConf: true, + outputValue: defaultValue, expectedEvent: OnConfirmed, }, + { + name: "reservation value mismatch", + sendTxConf: true, + outputValue: defaultValue - 1, + expectedEvent: fsm.OnError, + }, { name: "expired", blockHeight: 100, @@ -273,7 +281,7 @@ func TestSubscribeToConfirmationAction(t *testing.T) { TxIn: []*wire.TxIn{}, TxOut: []*wire.TxOut{ { - Value: int64(defaultValue), + Value: int64(tc.outputValue), PkScript: pkScript, }, }, diff --git a/instantout/reservation/manager_test.go b/instantout/reservation/manager_test.go index ca3dec45..af014926 100644 --- a/instantout/reservation/manager_test.go +++ b/instantout/reservation/manager_test.go @@ -58,6 +58,7 @@ func TestManager(t *testing.T) { confTx := &wire.MsgTx{ TxOut: []*wire.TxOut{ { + Value: int64(defaultValue), PkScript: pkScript, }, }, diff --git a/instantout/reservation/reservation.go b/instantout/reservation/reservation.go index 5a167d2e..8b83ae33 100644 --- a/instantout/reservation/reservation.go +++ b/instantout/reservation/reservation.go @@ -142,8 +142,14 @@ func (r *Reservation) findReservationOutput(tx *wire.MsgTx) (*wire.OutPoint, return nil, err } + var foundScript bool for i, txOut := range tx.TxOut { if bytes.Equal(txOut.PkScript, pkScript) { + foundScript = true + if txOut.Value != int64(r.Value) { + continue + } + return &wire.OutPoint{ Hash: tx.TxHash(), Index: uint32(i), @@ -151,6 +157,11 @@ func (r *Reservation) findReservationOutput(tx *wire.MsgTx) (*wire.OutPoint, } } + if foundScript { + return nil, fmt.Errorf("reservation output value mismatch: "+ + "expected %d", r.Value) + } + return nil, errors.New("reservation output not found") } From b3776e92f45ff1c9cb61b7f2a3261e47eb3c7066 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Tue, 11 Aug 2026 11:36:27 +0200 Subject: [PATCH 07/12] instantout: validate MuSig2 response dimensions Check nonce, signature, session, and transaction input counts before indexing signing vectors, returning clear errors for incomplete data. --- instantout/instantout.go | 38 ++++++++++++++++++++++++++++ instantout/instantout_test.go | 47 +++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 instantout/instantout_test.go diff --git a/instantout/instantout.go b/instantout/instantout.go index f8c89eb0..40cbf12e 100644 --- a/instantout/instantout.go +++ b/instantout/instantout.go @@ -263,12 +263,31 @@ func (i *InstantOut) signMusig2Tx(ctx context.Context, if err != nil { return nil, err } + if tx == nil { + return nil, errors.New("transaction is nil") + } + if len(tx.TxIn) != len(inputs) { + return nil, fmt.Errorf("invalid number of transaction inputs: "+ + "expected %d, got %d", len(inputs), len(tx.TxIn)) + } + if len(musig2sessions) != len(inputs) { + return nil, fmt.Errorf("invalid number of MuSig2 sessions: "+ + "expected %d, got %d", len(inputs), len(musig2sessions)) + } + if len(counterPartyNonces) != len(inputs) { + return nil, fmt.Errorf("invalid number of server nonces: "+ + "expected %d, got %d", len(inputs), len(counterPartyNonces)) + } prevOutFetcher := inputs.GetPrevoutFetcher() sigHashes := txscript.NewTxSigHashes(tx, prevOutFetcher) sigs := make([][]byte, len(inputs)) for idx, reservation := range inputs { + if musig2sessions[idx] == nil { + return nil, fmt.Errorf("MuSig2 session %d is nil", idx) + } + if !reflect.DeepEqual(tx.TxIn[idx].PreviousOutPoint, reservation.Outpoint) { @@ -329,8 +348,27 @@ func (i *InstantOut) finalizeMusig2Transaction(ctx context.Context, if err != nil { return nil, err } + if tx == nil { + return nil, errors.New("transaction is nil") + } + if len(tx.TxIn) != len(inputs) { + return nil, fmt.Errorf("invalid number of transaction inputs: "+ + "expected %d, got %d", len(inputs), len(tx.TxIn)) + } + if len(musig2Sessions) != len(inputs) { + return nil, fmt.Errorf("invalid number of MuSig2 sessions: "+ + "expected %d, got %d", len(inputs), len(musig2Sessions)) + } + if len(serverSigs) != len(inputs) { + return nil, fmt.Errorf("invalid number of server signatures: "+ + "expected %d, got %d", len(inputs), len(serverSigs)) + } for idx := range inputs { + if musig2Sessions[idx] == nil { + return nil, fmt.Errorf("MuSig2 session %d is nil", idx) + } + haveAllSigs, finalSig, err := signer.MuSig2CombineSig( ctx, musig2Sessions[idx].SessionID, [][]byte{serverSigs[idx]}, diff --git a/instantout/instantout_test.go b/instantout/instantout_test.go new file mode 100644 index 00000000..6b0e7cb5 --- /dev/null +++ b/instantout/instantout_test.go @@ -0,0 +1,47 @@ +package instantout + +import ( + "context" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/loop/instantout/reservation" + "github.com/lightningnetwork/lnd/input" + "github.com/stretchr/testify/require" +) + +// TestMuSig2VectorLengthValidation verifies that malformed server-controlled +// vectors are rejected before they can be indexed. +func TestMuSig2VectorLengthValidation(t *testing.T) { + _, pubKey := btcec.PrivKeyFromBytes([]byte{1}) + instantOut := &InstantOut{ + Reservations: []*reservation.Reservation{ + { + ClientPubkey: pubKey, + ServerPubkey: pubKey, + Value: btcutil.Amount(100_000), + Expiry: 200, + Outpoint: &wire.OutPoint{}, + }, + }, + } + tx := wire.NewMsgTx(2) + tx.AddTxIn(&wire.TxIn{}) + sessions := []*input.MuSig2SessionInfo{{}} + + require.NotPanics(t, func() { + _, err := instantOut.signMusig2Tx( + context.Background(), nil, tx, sessions, nil, + ) + require.ErrorContains(t, err, "server nonces") + }) + + require.NotPanics(t, func() { + _, err := instantOut.finalizeMusig2Transaction( + context.Background(), nil, sessions, tx, nil, + ) + require.ErrorContains(t, err, "server signatures") + }) +} From 6853e69a05c7c253d1324ac616ccbe5ba4985668 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Tue, 11 Aug 2026 11:36:57 +0200 Subject: [PATCH 08/12] instantout: verify finalized MuSig2 witnesses Run script validation for every combined signature before accepting a finalized transaction, surfacing invalid witnesses immediately. --- instantout/instantout.go | 17 ++++++++++++++++ instantout/instantout_test.go | 37 +++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/instantout/instantout.go b/instantout/instantout.go index 40cbf12e..16f91d45 100644 --- a/instantout/instantout.go +++ b/instantout/instantout.go @@ -364,6 +364,9 @@ func (i *InstantOut) finalizeMusig2Transaction(ctx context.Context, "expected %d, got %d", len(inputs), len(serverSigs)) } + prevOutFetcher := inputs.GetPrevoutFetcher() + sigHashes := txscript.NewTxSigHashes(tx, prevOutFetcher) + for idx := range inputs { if musig2Sessions[idx] == nil { return nil, fmt.Errorf("MuSig2 session %d is nil", idx) @@ -382,6 +385,20 @@ func (i *InstantOut) finalizeMusig2Transaction(ctx context.Context, } tx.TxIn[idx].Witness = wire.TxWitness{finalSig} + + vm, err := txscript.NewEngine( + inputs[idx].PkScript, tx, idx, + txscript.StandardVerifyFlags, nil, sigHashes, + int64(inputs[idx].Value), prevOutFetcher, + ) + if err != nil { + return nil, fmt.Errorf("unable to verify final MuSig2 "+ + "signature for input %d: %w", idx, err) + } + if err := vm.Execute(); err != nil { + return nil, fmt.Errorf("invalid final MuSig2 signature "+ + "for input %d: %w", idx, err) + } } return tx, nil diff --git a/instantout/instantout_test.go b/instantout/instantout_test.go index 6b0e7cb5..f2ba3241 100644 --- a/instantout/instantout_test.go +++ b/instantout/instantout_test.go @@ -7,11 +7,22 @@ import ( "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/lndclient" "github.com/lightninglabs/loop/instantout/reservation" "github.com/lightningnetwork/lnd/input" "github.com/stretchr/testify/require" ) +type invalidFinalSigSigner struct { + lndclient.SignerClient +} + +func (s *invalidFinalSigSigner) MuSig2CombineSig(context.Context, [32]byte, + [][]byte) (bool, []byte, error) { + + return true, make([]byte, 64), nil +} + // TestMuSig2VectorLengthValidation verifies that malformed server-controlled // vectors are rejected before they can be indexed. func TestMuSig2VectorLengthValidation(t *testing.T) { @@ -45,3 +56,29 @@ func TestMuSig2VectorLengthValidation(t *testing.T) { require.ErrorContains(t, err, "server signatures") }) } + +// TestFinalizeMuSig2TransactionVerifiesSignature verifies that a combined +// signature is validated locally before the transaction can be used as the +// instant-out safety net. +func TestFinalizeMuSig2TransactionVerifiesSignature(t *testing.T) { + _, pubKey := btcec.PrivKeyFromBytes([]byte{1}) + res := &reservation.Reservation{ + ClientPubkey: pubKey, + ServerPubkey: pubKey, + Value: btcutil.Amount(100_000), + Expiry: 200, + Outpoint: &wire.OutPoint{}, + } + instantOut := &InstantOut{ + Reservations: []*reservation.Reservation{res}, + } + tx := wire.NewMsgTx(2) + tx.AddTxIn(&wire.TxIn{PreviousOutPoint: *res.Outpoint}) + tx.AddTxOut(&wire.TxOut{Value: 90_000}) + + _, err := instantOut.finalizeMusig2Transaction( + context.Background(), &invalidFinalSigSigner{}, + []*input.MuSig2SessionInfo{{}}, tx, [][]byte{{1}}, + ) + require.ErrorContains(t, err, "invalid final MuSig2 signature") +} From b689e361c15acbe46f6836456df2e11288fb4b4c Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Tue, 11 Aug 2026 11:37:46 +0200 Subject: [PATCH 09/12] instantout: close unfinished MuSig2 sessions Clean up abandoned signing sessions on error paths while leaving completed sessions to lnd. --- instantout/actions.go | 18 ++++++++++++++++ instantout/instantout.go | 39 ++++++++++++++++++++++++++++++++++- instantout/instantout_test.go | 33 ++++++++++++++++++++++++++++- 3 files changed, 88 insertions(+), 2 deletions(-) diff --git a/instantout/actions.go b/instantout/actions.go index 25842652..fffe987c 100644 --- a/instantout/actions.go +++ b/instantout/actions.go @@ -293,6 +293,15 @@ func (f *FSM) BuildHTLCAction(ctx context.Context, } f.htlcMusig2Sessions = htlcSessions + defer func() { + err := cleanupMuSig2Sessions( + ctx, f.cfg.Signer, f.htlcMusig2Sessions, + ) + if err != nil { + f.Errorf("unable to clean up HTLC MuSig2 sessions: %v", err) + } + f.htlcMusig2Sessions = nil + }() // Send the server the client nonces. htlcInitRes, err := f.cfg.InstantOutClient.InitHtlcSig( @@ -382,6 +391,15 @@ func (f *FSM) PushPreimageAction(ctx context.Context, } f.sweeplessSweepSessions = coopSessions + defer func() { + err := cleanupMuSig2Sessions( + ctx, f.cfg.Signer, f.sweeplessSweepSessions, + ) + if err != nil { + f.Errorf("unable to clean up sweep MuSig2 sessions: %v", err) + } + f.sweeplessSweepSessions = nil + }() // Get the feerate for the coop sweep. feeRate, err := f.cfg.Wallet.EstimateFeeRate(ctx, normalConfTarget) diff --git a/instantout/instantout.go b/instantout/instantout.go index 16f91d45..eade26e0 100644 --- a/instantout/instantout.go +++ b/instantout/instantout.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "reflect" + "time" "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcec/v2/schnorr/musig2" @@ -25,6 +26,8 @@ import ( "github.com/lightningnetwork/lnd/lnwallet/chainfee" ) +const muSig2CleanupTimeout = 5 * time.Second + // InstantOut holds the necessary information to execute an instant out swap. type InstantOut struct { // SwapHash is the hash of the swap. @@ -112,7 +115,10 @@ func (i *InstantOut) createMusig2Session(ctx context.Context, for idx, reservation := range i.Reservations { session, err := reservation.Musig2CreateSession(ctx, signer) if err != nil { - return nil, nil, err + cleanupErr := cleanupMuSig2Sessions( + ctx, signer, musig2Sessions[:idx], + ) + return nil, nil, errors.Join(err, cleanupErr) } musig2Sessions[idx] = session @@ -122,6 +128,32 @@ func (i *InstantOut) createMusig2Session(ctx context.Context, return musig2Sessions, clientNonces, nil } +// cleanupMuSig2Sessions removes completed or abandoned MuSig2 sessions from +// lnd. Cleanup uses a bounded context that survives cancellation of the swap +// action that created the sessions. +func cleanupMuSig2Sessions(ctx context.Context, signer lndclient.SignerClient, + sessions []*input.MuSig2SessionInfo) error { + + cleanupCtx, cancel := context.WithTimeout( + context.WithoutCancel(ctx), muSig2CleanupTimeout, + ) + defer cancel() + + var cleanupErr error + for _, session := range sessions { + if session == nil { + continue + } + + err := signer.MuSig2Cleanup(cleanupCtx, session.SessionID) + if err != nil { + cleanupErr = errors.Join(cleanupErr, err) + } + } + + return cleanupErr +} + // getInputReservations returns the input reservations for the instant out. func (i *InstantOut) getInputReservations() (InputReservations, error) { if len(i.Reservations) == 0 { @@ -384,6 +416,11 @@ func (i *InstantOut) finalizeMusig2Transaction(ctx context.Context, return nil, fmt.Errorf("missing sigs") } + // lnd removes a MuSig2 session automatically once all signatures + // have been combined. Clear the local entry so the caller's deferred + // cleanup only targets sessions abandoned on an error path. + musig2Sessions[idx] = nil + tx.TxIn[idx].Witness = wire.TxWitness{finalSig} vm, err := txscript.NewEngine( diff --git a/instantout/instantout_test.go b/instantout/instantout_test.go index f2ba3241..fd45b788 100644 --- a/instantout/instantout_test.go +++ b/instantout/instantout_test.go @@ -23,6 +23,19 @@ func (s *invalidFinalSigSigner) MuSig2CombineSig(context.Context, [32]byte, return true, make([]byte, 64), nil } +type cleanupTrackingSigner struct { + lndclient.SignerClient + + cleaned [][32]byte +} + +func (s *cleanupTrackingSigner) MuSig2Cleanup(_ context.Context, + sessionID [32]byte) error { + + s.cleaned = append(s.cleaned, sessionID) + return nil +} + // TestMuSig2VectorLengthValidation verifies that malformed server-controlled // vectors are rejected before they can be indexed. func TestMuSig2VectorLengthValidation(t *testing.T) { @@ -76,9 +89,27 @@ func TestFinalizeMuSig2TransactionVerifiesSignature(t *testing.T) { tx.AddTxIn(&wire.TxIn{PreviousOutPoint: *res.Outpoint}) tx.AddTxOut(&wire.TxOut{Value: 90_000}) + sessions := []*input.MuSig2SessionInfo{{}} _, err := instantOut.finalizeMusig2Transaction( context.Background(), &invalidFinalSigSigner{}, - []*input.MuSig2SessionInfo{{}}, tx, [][]byte{{1}}, + sessions, tx, [][]byte{{1}}, ) require.ErrorContains(t, err, "invalid final MuSig2 signature") + require.Nil(t, sessions[0]) +} + +// TestCleanupMuSig2Sessions verifies that all allocated sessions are released +// while nil entries from partial session creation are skipped. +func TestCleanupMuSig2Sessions(t *testing.T) { + firstID := [32]byte{1} + secondID := [32]byte{2} + signer := &cleanupTrackingSigner{} + + err := cleanupMuSig2Sessions( + t.Context(), signer, []*input.MuSig2SessionInfo{ + {SessionID: firstID}, nil, {SessionID: secondID}, + }, + ) + require.NoError(t, err) + require.Equal(t, [][32]byte{firstID, secondID}, signer.cleaned) } From b596eabab3e27c927534ad100f2c359faccf1e4e Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Tue, 11 Aug 2026 11:38:27 +0200 Subject: [PATCH 10/12] 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", From e772f8ccfa204e8882278d447e8e638fb40565e0 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Tue, 11 Aug 2026 11:43:16 +0200 Subject: [PATCH 11/12] instantout: enforce the accepted swap fee Carry the accepted quote into each request, persist it, and reject invoices above that limit. Preserve compatibility for requests that omit the cap while distinguishing an explicit zero. --- cmd/loop/instantout.go | 5 ++ .../instantout/02_loop-instantout.json | 3 +- .../07_loop-instantout-channel.json | 3 +- .../08_loop-instantout-select-index.json | 3 +- instantout/actions.go | 50 ++++++++++++- instantout/instantout.go | 3 + instantout/instantout_test.go | 70 +++++++++++++++++++ instantout/manager.go | 31 +++++++- instantout/store.go | 3 +- loopd/swapclient_server.go | 9 ++- looprpc/client.pb.go | 45 +++++++++++- looprpc/client.proto | 10 +++ looprpc/client.swagger.json | 5 ++ looprpc/client_test.go | 32 +++++++++ 14 files changed, 260 insertions(+), 12 deletions(-) create mode 100644 looprpc/client_test.go diff --git a/cmd/loop/instantout.go b/cmd/loop/instantout.go index 9783f005..3cbcda08 100644 --- a/cmd/loop/instantout.go +++ b/cmd/loop/instantout.go @@ -183,6 +183,8 @@ func instantOut(ctx context.Context, cmd *cli.Command) error { fmt.Println("Starting instant swap out") + maxSwapFee := quote.ServiceFeeSat + // Now we can request the instant out swap. instantOutRes, err := client.InstantOut( ctx, @@ -190,6 +192,9 @@ func instantOut(ctx context.Context, cmd *cli.Command) error { ReservationIds: selectedReservations, OutgoingChanSet: outgoingChanSet, DestAddr: cmd.String("addr"), + MaxSwapFee: &looprpc.InstantOutRequest_MaxSwapFeeSat{ + MaxSwapFeeSat: maxSwapFee, + }, }, ) if err != nil { diff --git a/cmd/loop/testdata/sessions/instantout/02_loop-instantout.json b/cmd/loop/testdata/sessions/instantout/02_loop-instantout.json index 6dae20b1..257f27c7 100644 --- a/cmd/loop/testdata/sessions/instantout/02_loop-instantout.json +++ b/cmd/loop/testdata/sessions/instantout/02_loop-instantout.json @@ -139,7 +139,8 @@ "Mu65fbhayEtRzougKLBnoeRN8f+tEM1+O9QuNvUIfbI=" ], "outgoing_chan_set": [], - "dest_addr": "" + "dest_addr": "", + "max_swap_fee_sat": "4800" } } }, diff --git a/cmd/loop/testdata/sessions/instantout/07_loop-instantout-channel.json b/cmd/loop/testdata/sessions/instantout/07_loop-instantout-channel.json index 205ae43e..07038f8e 100644 --- a/cmd/loop/testdata/sessions/instantout/07_loop-instantout-channel.json +++ b/cmd/loop/testdata/sessions/instantout/07_loop-instantout-channel.json @@ -148,7 +148,8 @@ "outgoing_chan_set": [ "125344325763072" ], - "dest_addr": "" + "dest_addr": "", + "max_swap_fee_sat": "3200" } } }, diff --git a/cmd/loop/testdata/sessions/instantout/08_loop-instantout-select-index.json b/cmd/loop/testdata/sessions/instantout/08_loop-instantout-select-index.json index 9a6d103b..89f13c31 100644 --- a/cmd/loop/testdata/sessions/instantout/08_loop-instantout-select-index.json +++ b/cmd/loop/testdata/sessions/instantout/08_loop-instantout-select-index.json @@ -162,7 +162,8 @@ "cSfKVONNmsK9+p4Uc5nc3ZtE+37uOODHeq1vprhh/x4=" ], "outgoing_chan_set": [], - "dest_addr": "" + "dest_addr": "", + "max_swap_fee_sat": "1600" } } }, diff --git a/instantout/actions.go b/instantout/actions.go index 2d623811..329add5e 100644 --- a/instantout/actions.go +++ b/instantout/actions.go @@ -20,6 +20,7 @@ import ( "github.com/lightningnetwork/lnd/lnrpc/walletrpc" "github.com/lightningnetwork/lnd/lntypes" "github.com/lightningnetwork/lnd/lnwallet" + "github.com/lightningnetwork/lnd/lnwire" ) const ( @@ -65,6 +66,7 @@ type InitInstantOutCtx struct { outgoingChanSet loopdb.ChannelSet protocolVersion ProtocolVersion sweepAddress btcutil.Address + maxSwapFee *btcutil.Amount } // RecoverInstantOutCtx marks an action as being resumed after restart. @@ -85,7 +87,7 @@ func (f *FSM) InitInstantOutAction(ctx context.Context, } var ( - reservationAmt uint64 + reservationAmt btcutil.Amount reservationIds = make([][]byte, 0, len(initCtx.reservations)) reservations = make( []*reservation.Reservation, 0, len(initCtx.reservations), @@ -106,7 +108,7 @@ func (f *FSM) InitInstantOutAction(ctx context.Context, "locked", reservationId)) } - reservationAmt += uint64(res.Value) + reservationAmt += res.Value reservationIds = append(reservationIds, resId[:]) reservations = append(reservations, res) @@ -168,6 +170,11 @@ func (f *FSM) InitInstantOutAction(ctx context.Context, return f.HandleError(fmt.Errorf("invalid swap invoice hash: "+ "expected %x got %x", preimage.Hash(), payReq.Hash)) } + if err := validateInstantOutInvoiceAmount( + payReq.Value, reservationAmt, initCtx.maxSwapFee, + ); err != nil { + return f.HandleError(err) + } serverPubkey, err := btcec.ParsePubKey(instantOutResponse.SenderKey) if err != nil { return f.HandleError(err) @@ -186,6 +193,11 @@ func (f *FSM) InitInstantOutAction(ctx context.Context, } // Now we can create the instant out. + var maxSwapFee btcutil.Amount + if initCtx.maxSwapFee != nil { + maxSwapFee = *initCtx.maxSwapFee + } + instantOut := &InstantOut{ SwapHash: swapHash, swapPreimage: preimage, @@ -195,7 +207,8 @@ func (f *FSM) InitInstantOutAction(ctx context.Context, CltvExpiry: initCtx.cltvExpiry, clientPubkey: keyRes.PubKey, serverPubkey: serverPubkey, - Value: btcutil.Amount(reservationAmt), + Value: reservationAmt, + MaxSwapFee: maxSwapFee, htlcFeeRate: feeRate, swapInvoice: instantOutResponse.SwapInvoice, Reservations: reservations, @@ -213,6 +226,37 @@ func (f *FSM) InitInstantOutAction(ctx context.Context, return OnInit } +// validateInstantOutInvoiceAmount verifies that the server invoice doesn't +// charge more than the client-approved swap fee. Sub-satoshi fees are rounded +// up so the cap cannot be bypassed with millisatoshi precision. +func validateInstantOutInvoiceAmount(invoiceAmount lnwire.MilliSatoshi, + swapAmount btcutil.Amount, maxSwapFee *btcutil.Amount) error { + + // Omitting the cap preserves the behavior of clients that predate this + // field. In-tree callers set it explicitly after accepting a quote. + if maxSwapFee == nil { + return nil + } + + if *maxSwapFee < 0 { + return fmt.Errorf("maximum swap fee must not be negative") + } + + swapAmountMsat := lnwire.NewMSatFromSatoshis(swapAmount) + if invoiceAmount <= swapAmountMsat { + return nil + } + + swapFeeMsat := invoiceAmount - swapAmountMsat + swapFeeSat := btcutil.Amount((int64(swapFeeMsat)-1)/1000 + 1) + if swapFeeSat > *maxSwapFee { + return fmt.Errorf("instant out swap fee %d exceeds maximum %d", + swapFeeSat, *maxSwapFee) + } + + return nil +} + // PollPaymentAcceptedAction locks the reservations, sends the payment to the // server and polls the server for the payment status. func (f *FSM) PollPaymentAcceptedAction(ctx context.Context, diff --git a/instantout/instantout.go b/instantout/instantout.go index eade26e0..c700ee91 100644 --- a/instantout/instantout.go +++ b/instantout/instantout.go @@ -60,6 +60,9 @@ type InstantOut struct { // Value is the amount that is swapped. Value btcutil.Amount + // MaxSwapFee is the maximum off-chain swap fee accepted by the client. + MaxSwapFee btcutil.Amount + // keyLocator is the key locator that is used for the swap. keyLocator keychain.KeyLocator diff --git a/instantout/instantout_test.go b/instantout/instantout_test.go index 48195c32..66301a22 100644 --- a/instantout/instantout_test.go +++ b/instantout/instantout_test.go @@ -11,6 +11,7 @@ import ( "github.com/lightninglabs/loop/fsm" "github.com/lightninglabs/loop/instantout/reservation" "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/lnwire" "github.com/stretchr/testify/require" ) @@ -189,3 +190,72 @@ func TestPushPreimageRejectsExpiringHtlc(t *testing.T) { "instant out HTLC expires at height 105", ) } + +// TestValidateInstantOutInvoiceAmount verifies enforcement of the fee cap at +// millisatoshi precision. +func TestValidateInstantOutInvoiceAmount(t *testing.T) { + const swapAmount = btcutil.Amount(100_000) + + maxSwapFee := btcutil.Amount(200) + zeroSwapFee := btcutil.Amount(0) + negativeSwapFee := btcutil.Amount(-1) + + tests := []struct { + name string + invoiceAmount lnwire.MilliSatoshi + maxSwapFee *btcutil.Amount + expectErr bool + }{ + { + name: "exact fee cap", + invoiceAmount: lnwire.NewMSatFromSatoshis( + swapAmount + maxSwapFee, + ), + maxSwapFee: &maxSwapFee, + }, + { + name: "one millisatoshi over fee cap", + invoiceAmount: lnwire.NewMSatFromSatoshis( + swapAmount+maxSwapFee, + ) + 1, + maxSwapFee: &maxSwapFee, + expectErr: true, + }, + { + name: "discounted invoice", + invoiceAmount: lnwire.NewMSatFromSatoshis( + swapAmount - 1, + ), + maxSwapFee: &zeroSwapFee, + }, + { + name: "negative cap", + invoiceAmount: lnwire.NewMSatFromSatoshis( + swapAmount, + ), + maxSwapFee: &negativeSwapFee, + expectErr: true, + }, + { + name: "omitted cap", + invoiceAmount: lnwire.NewMSatFromSatoshis( + swapAmount + maxSwapFee + 1, + ), + maxSwapFee: nil, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := validateInstantOutInvoiceAmount( + tc.invoiceAmount, swapAmount, tc.maxSwapFee, + ) + if tc.expectErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + }) + } +} diff --git a/instantout/manager.go b/instantout/manager.go index 0d806251..e9ecb352 100644 --- a/instantout/manager.go +++ b/instantout/manager.go @@ -20,6 +20,20 @@ var ( ErrSwapDoesNotExist = errors.New("swap does not exist") ) +type newInstantOutOptions struct { + maxSwapFee *btcutil.Amount +} + +// NewInstantOutOption customizes an instant out request. +type NewInstantOutOption func(*newInstantOutOptions) + +// WithMaxSwapFee limits the off-chain fee accepted for an instant out. +func WithMaxSwapFee(maxSwapFee btcutil.Amount) NewInstantOutOption { + return func(options *newInstantOutOptions) { + options.maxSwapFee = &maxSwapFee + } +} + // Manager manages the instantout state machines. type Manager struct { sync.Mutex @@ -136,7 +150,21 @@ func (m *Manager) recoverInstantOuts(ctx context.Context) error { // NewInstantOut creates a new instantout. func (m *Manager) NewInstantOut(ctx context.Context, - reservations []reservation.ID, sweepAddress string) (*FSM, error) { + reservations []reservation.ID, sweepAddress string, + options ...NewInstantOutOption) (*FSM, error) { + + requestOptions := &newInstantOutOptions{} + for _, option := range options { + if option != nil { + option(requestOptions) + } + } + + if requestOptions.maxSwapFee != nil && + *requestOptions.maxSwapFee < 0 { + + return nil, fmt.Errorf("maximum swap fee must not be negative") + } var ( sweepAddr btcutil.Address @@ -159,6 +187,7 @@ func (m *Manager) NewInstantOut(ctx context.Context, initationHeight: m.currentHeight, protocolVersion: CurrentProtocolVersion(), sweepAddress: sweepAddr, + maxSwapFee: requestOptions.maxSwapFee, } instantOut, err := NewFSM(m.cfg, ProtocolVersionFullReservation) diff --git a/instantout/store.go b/instantout/store.go index 25d7fe70..0e2a5066 100644 --- a/instantout/store.go +++ b/instantout/store.go @@ -104,7 +104,7 @@ func (s *SQLStore) CreateInstantLoopOut(ctx context.Context, AmountRequested: int64(instantOut.Value), CltvExpiry: instantOut.CltvExpiry, MaxMinerFee: 0, - MaxSwapFee: 0, + MaxSwapFee: int64(instantOut.MaxSwapFee), InitiationHeight: instantOut.initiationHeight, ProtocolVersion: int32(instantOut.protocolVersion), Label: "", @@ -368,6 +368,7 @@ func (s *SQLStore) sqlInstantOutToInstantOut(ctx context.Context, protocolVersion: ProtocolVersion(row.ProtocolVersion), initiationHeight: row.InitiationHeight, Value: btcutil.Amount(row.AmountRequested), + MaxSwapFee: btcutil.Amount(row.MaxSwapFee), keyLocator: keychain.KeyLocator{ Family: keychain.KeyFamily(row.ClientKeyFamily), Index: uint32(row.ClientKeyIndex), diff --git a/loopd/swapclient_server.go b/loopd/swapclient_server.go index a0559d41..497fe3a8 100644 --- a/loopd/swapclient_server.go +++ b/loopd/swapclient_server.go @@ -1763,8 +1763,15 @@ func (s *swapClientServer) InstantOut(ctx context.Context, reservationIds[i] = resId } + var options []instantout.NewInstantOutOption + if req.GetMaxSwapFee() != nil { + options = append(options, instantout.WithMaxSwapFee( + btcutil.Amount(req.GetMaxSwapFeeSat()), + )) + } + instantOutFsm, err := s.instantOutManager.NewInstantOut( - ctx, reservationIds, req.DestAddr, + ctx, reservationIds, req.DestAddr, options..., ) if err != nil { return nil, err diff --git a/looprpc/client.pb.go b/looprpc/client.pb.go index ec324648..04b35f1e 100644 --- a/looprpc/client.pb.go +++ b/looprpc/client.pb.go @@ -4467,7 +4467,11 @@ type InstantOutRequest struct { OutgoingChanSet []uint64 `protobuf:"varint,2,rep,packed,name=outgoing_chan_set,json=outgoingChanSet,proto3" json:"outgoing_chan_set,omitempty"` // An optional address to sweep the onchain funds to. If not set, the funds // will be swept to the wallet's internal address. - DestAddr string `protobuf:"bytes,3,opt,name=dest_addr,json=destAddr,proto3" json:"dest_addr,omitempty"` + DestAddr string `protobuf:"bytes,3,opt,name=dest_addr,json=destAddr,proto3" json:"dest_addr,omitempty"` + // Types that are valid to be assigned to MaxSwapFee: + // + // *InstantOutRequest_MaxSwapFeeSat + MaxSwapFee isInstantOutRequest_MaxSwapFee `protobuf_oneof:"max_swap_fee"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -4523,6 +4527,36 @@ func (x *InstantOutRequest) GetDestAddr() string { return "" } +func (x *InstantOutRequest) GetMaxSwapFee() isInstantOutRequest_MaxSwapFee { + if x != nil { + return x.MaxSwapFee + } + return nil +} + +func (x *InstantOutRequest) GetMaxSwapFeeSat() int64 { + if x != nil { + if x, ok := x.MaxSwapFee.(*InstantOutRequest_MaxSwapFeeSat); ok { + return x.MaxSwapFeeSat + } + } + return 0 +} + +type isInstantOutRequest_MaxSwapFee interface { + isInstantOutRequest_MaxSwapFee() +} + +type InstantOutRequest_MaxSwapFeeSat struct { + // The maximum off-chain swap fee that may be charged for the swap. If + // this field is omitted, no fee cap is applied for compatibility with + // clients that predate this field. An explicitly set value of zero + // rejects any positive swap fee. + MaxSwapFeeSat int64 `protobuf:"varint,4,opt,name=max_swap_fee_sat,json=maxSwapFeeSat,proto3,oneof"` +} + +func (*InstantOutRequest_MaxSwapFeeSat) isInstantOutRequest_MaxSwapFee() {} + type InstantOutResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // The hash of the swap preimage. @@ -6964,11 +6998,13 @@ const file_client_proto_rawDesc = "" + "\x06amount\x18\x03 \x01(\x04R\x06amount\x12\x13\n" + "\x05tx_id\x18\x04 \x01(\tR\x04txId\x12\x12\n" + "\x04vout\x18\x05 \x01(\rR\x04vout\x12\x16\n" + - "\x06expiry\x18\x06 \x01(\rR\x06expiry\"\x85\x01\n" + + "\x06expiry\x18\x06 \x01(\rR\x06expiry\"\xc0\x01\n" + "\x11InstantOutRequest\x12'\n" + "\x0freservation_ids\x18\x01 \x03(\fR\x0ereservationIds\x12*\n" + "\x11outgoing_chan_set\x18\x02 \x03(\x04R\x0foutgoingChanSet\x12\x1b\n" + - "\tdest_addr\x18\x03 \x01(\tR\bdestAddr\"t\n" + + "\tdest_addr\x18\x03 \x01(\tR\bdestAddr\x12)\n" + + "\x10max_swap_fee_sat\x18\x04 \x01(\x03H\x00R\rmaxSwapFeeSatB\x0e\n" + + "\fmax_swap_fee\"t\n" + "\x12InstantOutResponse\x12(\n" + "\x10instant_out_hash\x18\x01 \x01(\fR\x0einstantOutHash\x12\x1e\n" + "\vsweep_tx_id\x18\x02 \x01(\tR\tsweepTxId\x12\x14\n" + @@ -7490,6 +7526,9 @@ func file_client_proto_init() { (*SweepHtlcResponse_Published)(nil), (*SweepHtlcResponse_Failed)(nil), } + file_client_proto_msgTypes[48].OneofWrappers = []any{ + (*InstantOutRequest_MaxSwapFeeSat)(nil), + } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ diff --git a/looprpc/client.proto b/looprpc/client.proto index 844dade7..f2a592f1 100644 --- a/looprpc/client.proto +++ b/looprpc/client.proto @@ -1685,6 +1685,16 @@ message InstantOutRequest { will be swept to the wallet's internal address. */ string dest_addr = 3; + + oneof max_swap_fee { + /* + The maximum off-chain swap fee that may be charged for the swap. If + this field is omitted, no fee cap is applied for compatibility with + clients that predate this field. An explicitly set value of zero + rejects any positive swap fee. + */ + int64 max_swap_fee_sat = 4; + } } message InstantOutResponse { diff --git a/looprpc/client.swagger.json b/looprpc/client.swagger.json index 1c94febb..57d6dfd6 100644 --- a/looprpc/client.swagger.json +++ b/looprpc/client.swagger.json @@ -1917,6 +1917,11 @@ "dest_addr": { "type": "string", "description": "An optional address to sweep the onchain funds to. If not set, the funds\nwill be swept to the wallet's internal address." + }, + "max_swap_fee_sat": { + "type": "string", + "format": "int64", + "description": "The maximum off-chain swap fee that may be charged for the swap. If\nthis field is omitted, no fee cap is applied for compatibility with\nclients that predate this field. An explicitly set value of zero\nrejects any positive swap fee." } } }, diff --git a/looprpc/client_test.go b/looprpc/client_test.go new file mode 100644 index 00000000..4a19c68f --- /dev/null +++ b/looprpc/client_test.go @@ -0,0 +1,32 @@ +package looprpc + +import ( + "testing" + + "google.golang.org/protobuf/proto" +) + +// TestInstantOutMaxSwapFeePresence verifies that an omitted fee cap remains +// distinguishable from an explicitly encoded zero while retaining the scalar +// field's original wire representation. +func TestInstantOutMaxSwapFeePresence(t *testing.T) { + request := &InstantOutRequest{} + if err := proto.Unmarshal(nil, request); err != nil { + t.Fatalf("unable to unmarshal omitted cap: %v", err) + } + if request.GetMaxSwapFee() != nil { + t.Fatal("omitted cap unexpectedly has presence") + } + + // Field four, encoded as a varint with value zero. This is the same wire + // representation used before the field gained presence semantics. + if err := proto.Unmarshal([]byte{0x20, 0x00}, request); err != nil { + t.Fatalf("unable to unmarshal explicit zero cap: %v", err) + } + if request.GetMaxSwapFee() == nil { + t.Fatal("explicit zero cap lost presence") + } + if request.GetMaxSwapFeeSat() != 0 { + t.Fatalf("expected zero cap, got %d", request.GetMaxSwapFeeSat()) + } +} From f826260266a6e064c26795551f17518a91864431 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Tue, 11 Aug 2026 11:43:54 +0200 Subject: [PATCH 12/12] docs: document instant out reliability improvements Record the Instant Out and reservation validation, recovery, fee-limit, lifecycle, and custom macaroon updates in the next release notes. --- docs/release-notes/release-notes-next.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/release-notes/release-notes-next.md b/docs/release-notes/release-notes-next.md index fb302d1e..1f889227 100644 --- a/docs/release-notes/release-notes-next.md +++ b/docs/release-notes/release-notes-next.md @@ -4,12 +4,21 @@ #### Breaking Changes +* Instant Out and reservation RPCs now require the `loop:out` permission. + Operators using custom scoped macaroons must rebake them before calling + `ListReservations`, `InstantOut`, `InstantOutQuote`, or `ListInstantOuts`. + [PR #1194](https://github.com/lightninglabs/loop/pull/1194) + #### Bug Fixes * Loop Out requests now account for channel reserves when checking outbound capacity, preventing swaps from starting when their off-chain payment cannot be funded. +* Improved Instant Out and reservation validation, lifecycle cleanup, recovery + timing, fee limits, and macaroon permissions. + [PR #1194](https://github.com/lightninglabs/loop/pull/1194) + * Taproot Asset Loop Out handling now validates RFQ timeouts and asset rates, keeps cached asset-name lookups responsive during slow `tapd` queries, and closes `tapd` connections cleanly during shutdown and startup failures.