From 3ffd791124e23f6531c286387ac764bf4180fbbe Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Sat, 26 Sep 2026 09:38:51 +0000 Subject: [PATCH 1/2] loopin: reveal the internal HTLC key only for a settled invoice A MuSig2 Loop In reveals the client's internal HTLC key to the server once the swap is done, so that the server can sweep the HTLC through the cheaper key path. With that key the server can spend the HTLC on its own. The client revealed the key as soon as the swap invoice was finalized, which includes a canceled invoice. In the normal flow the client cancels the invoice only after its timeout refund confirmed. The HTLC is spent by then, so revealing the key could not be used to take it. But an invoice canceled while the HTLC is still unspent, for example by hand, gave the server the key to an HTLC that it never paid for. Only reveal the key once the invoice is known to be settled, and check that inside the key reveal itself. The next commit relies on this to cancel the invoice before the refund is published. --- docs/release-notes/release-notes-next.md | 4 ++ loopin.go | 20 +++++- loopin_test.go | 79 ++++++++++++++++++++++++ server_mock_test.go | 6 ++ 4 files changed, 108 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/release-notes-next.md b/docs/release-notes/release-notes-next.md index ef705ccf7..562d6a70f 100644 --- a/docs/release-notes/release-notes-next.md +++ b/docs/release-notes/release-notes-next.md @@ -48,6 +48,10 @@ `loopd` failed with `exec format error` on ARM hosts. [Issue #1211](https://github.com/lightninglabs/loop/issues/1211) +* A MuSig2 Loop In no longer reveals the internal key of its HTLC to the + server when the swap invoice is canceled. The key is only shared once the + invoice is paid. + #### Maintenance * Align the standalone `looprpc` module's OpenTelemetry SDK and OTLP trace diff --git a/loopin.go b/loopin.go index c047aefc1..8546ca304 100644 --- a/loopin.go +++ b/loopin.go @@ -99,6 +99,10 @@ type loopInSwap struct { timeoutAddr btcutil.Address + // invoiceSettled is set once the swap invoice is known to be settled. + // Settlement is final, so the flag is never cleared. + invoiceSettled bool + abandonChan chan struct{} wg sync.WaitGroup @@ -904,6 +908,12 @@ func (s *loopInSwap) waitForSwapComplete(ctx context.Context, return fmt.Errorf("subscribe to swap invoice: %v", err) } + if s.state == loopdb.StateInvoiceSettled || + s.state == loopdb.StateSuccess { + + s.invoiceSettled = true + } + // publishTxOnTimeout publishes the timeout tx if the contract has // expired and invoice has not been settled. publishTxOnTimeout := func() (btcutil.Amount, error) { @@ -967,7 +977,7 @@ func (s *loopInSwap) waitForSwapComplete(ctx context.Context, return err } - if invoiceFinalized && !htlcKeyRevealed { + if s.invoiceSettled && !htlcKeyRevealed { htlcKeyRevealed = s.tryPushHtlcKey(ctx) } @@ -1030,6 +1040,7 @@ func (s *loopInSwap) waitForSwapComplete(ctx context.Context, } invoiceFinalized = true + s.invoiceSettled = true htlcKeyRevealed = s.tryPushHtlcKey(ctx) s.cost.Server = s.AmountRequested - update.AmtPaid @@ -1052,10 +1063,17 @@ func (s *loopInSwap) waitForSwapComplete(ctx context.Context, // returns an error of any kind we'll log it as a warning but won't act as the // swap execution can just go on without the server gaining knowledge of our // internal key. +// +// The internal key lets the server spend the htlc through its key path, so it +// is only revealed after the swap invoice was settled. For a canceled invoice +// the key must stay secret while the htlc can still be spent. func (s *loopInSwap) tryPushHtlcKey(ctx context.Context) bool { if s.ProtocolVersion < loopdb.ProtocolVersionMuSig2 { return false } + if !s.invoiceSettled { + return false + } log.Infof("Attempting to reveal internal HTLC key to the server") diff --git a/loopin_test.go b/loopin_test.go index 93d019da8..fb0ff8b9c 100644 --- a/loopin_test.go +++ b/loopin_test.go @@ -1086,3 +1086,82 @@ func startNewLoopIn(t *testing.T, ctx *loopInTestContext, height int32) ( return cfg, inSwap, err } + +// expiringLoopIn is a loop in whose confirmed htlc is about to expire. +type expiringLoopIn struct { + ctx *loopInTestContext + swap *loopInSwap + htlcTx wire.MsgTx + errChan chan error +} + +// startExpiringLoopIn runs a loop in until its htlc confirmed and the client +// watches the htlc and the swap invoice. +func startExpiringLoopIn(t *testing.T) *expiringLoopIn { + t.Helper() + + ctx := newLoopInTestContext(t) + cfg := newSwapConfig( + &ctx.lnd.LndServices, ctx.store, ctx.server, nil, + clock.NewTestClock(time.Unix(123, 0)), + ) + req := testLoopInRequest + initResult, err := newLoopInSwap( + context.Background(), cfg, 600, &req, + ) + require.NoError(t, err) + ctx.store.AssertLoopInStored() + + errChan := make(chan error, 1) + go func() { + errChan <- initResult.swap.execute( + context.Background(), ctx.cfg, 600, + ) + }() + + ctx.assertState(loopdb.StateInitiated) + ctx.assertState(loopdb.StateHtlcPublished) + ctx.store.AssertLoopInState(loopdb.StateHtlcPublished) + htlcTx := <-ctx.lnd.SendOutputsChannel + ctx.store.AssertLoopInState(loopdb.StateHtlcPublished) + + <-ctx.lnd.RegisterConfChannel + ctx.lnd.ConfChannel <- &chainntnfs.TxConfirmation{Tx: &htlcTx} + <-ctx.lnd.RegisterSpendChannel + ctx.assertSubscribeInvoice(ctx.server.swapHash) + + return &expiringLoopIn{ + ctx: ctx, + swap: initResult.swap, + htlcTx: htlcTx, + errChan: errChan, + } +} + +// TestLoopInCanceledInvoiceKeepsHtlcKey asserts that the htlc key is not +// revealed after the swap invoice was canceled, not even on later blocks. +func TestLoopInCanceledInvoiceKeepsHtlcKey(t *testing.T) { + defer test.Guard(t)() + + e := startExpiringLoopIn(t) + e.ctx.updateInvoiceState(0, invpkg.ContractCanceled) + + expiry := e.swap.LoopInContract.CltvExpiry + e.ctx.blockEpochChan <- expiry - 2 + e.ctx.blockEpochChan <- expiry - 1 + time.Sleep(100 * time.Millisecond) + require.Zero(t, e.ctx.server.pushKeyCalls.Load()) + + e.ctx.blockEpochChan <- expiry + <-e.ctx.lnd.SignOutputRawChannel + timeoutTx := <-e.ctx.lnd.TxPublishChannel + e.ctx.lnd.SpendChannel <- &chainntnfs.SpendDetail{ + SpendingTx: timeoutTx, + SpenderInputIndex: 0, + } + <-e.ctx.lnd.FailInvoiceChannel + e.ctx.assertState(loopdb.StateFailTimeout) + e.ctx.store.AssertLoopInState(loopdb.StateFailTimeout) + require.NoError(t, <-e.errChan) + require.Zero(t, e.ctx.server.pushKeyCalls.Load()) +} diff --git a/server_mock_test.go b/server_mock_test.go index e8bc47277..bf4e72f11 100644 --- a/server_mock_test.go +++ b/server_mock_test.go @@ -3,6 +3,7 @@ package loop import ( "context" "errors" + "sync/atomic" "testing" "time" @@ -60,6 +61,9 @@ type serverMock struct { // cancelSwap is a channel that swap cancellations are sent into. cancelSwap chan *outCancelDetails + // pushKeyCalls counts the htlc key reveals received. + pushKeyCalls atomic.Int32 + lnd *test.LndMockServices } @@ -300,6 +304,8 @@ func (s *serverMock) MultiMuSig2SignSweep(ctx context.Context, func (s *serverMock) PushKey(_ context.Context, _ loopdb.ProtocolVersion, _ lntypes.Hash, _ [32]byte) error { + s.pushKeyCalls.Add(1) + return nil } From e5eee1283a5a950fa243aef9882fc74731501f2a Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Sat, 26 Sep 2026 09:38:51 +0000 Subject: [PATCH 2/2] loopin: cancel the invoice before publishing a timeout refund At the HTLC expiry the client published its timeout refund while the swap invoice was still open, and canceled the invoice only after the refund confirmed. A payment that arrived in between settled the invoice and gave the server the preimage, while the refund could still win the race for the HTLC. After a restart, the first refund attempt also ran before the client had looked at the invoice at all. Before any refund, cancel the swap invoice with lnd's CancelInvoice, which resolves the race with a settlement atomically: - If the cancellation succeeds, the invoice can no longer reveal the preimage and the refund is published. The same holds if lnd no longer knows the invoice: lnd only deletes canceled invoices, for example when it garbage collects them, so a deleted invoice can't be paid either. - If the invoice is already settled, the refund is blocked and the swap moves to InvoiceSettled. The paid amount is read from the invoice, because the swap can complete before the invoice update that reports it arrives. - Any other error leaves the outcome open, and the refund waits for the next block. After a successful cancellation the refund is not in a hurry: the server has neither the preimage nor, after the previous commit, the internal key, so only the client's timeout path can spend the HTLC. After a restart the invoice is simply canceled again. The cancellation after the refund confirmed also accepts a deleted invoice, and the swap then completes without waiting for an invoice update that a deleted invoice never sends. --- docs/release-notes/release-notes-next.md | 5 + loopin.go | 113 +++++++++++++-- loopin_test.go | 176 +++++++++++++++++++++-- 3 files changed, 274 insertions(+), 20 deletions(-) diff --git a/docs/release-notes/release-notes-next.md b/docs/release-notes/release-notes-next.md index 562d6a70f..92d78645c 100644 --- a/docs/release-notes/release-notes-next.md +++ b/docs/release-notes/release-notes-next.md @@ -52,6 +52,11 @@ server when the swap invoice is canceled. The key is only shared once the invoice is paid. +* A Loop In now cancels its swap invoice before it refunds an expired HTLC, + and refunds only once lnd confirmed that the invoice can no longer be + paid. Previously the invoice stayed payable until the refund confirmed, so + a late payment could settle while the HTLC was being refunded. + #### Maintenance * Align the standalone `looprpc` module's OpenTelemetry SDK and OTLP trace diff --git a/loopin.go b/loopin.go index 8546ca304..cb5ddf127 100644 --- a/loopin.go +++ b/loopin.go @@ -79,6 +79,23 @@ func isInvoiceAlreadySettledError(err error) bool { rpcStatus.Message() == invpkg.ErrInvoiceAlreadySettled.Error() } +// isInvoiceNotFoundError returns true if the error reports that lnd does not +// know the invoice, either as the sentinel itself or as its gRPC form. +func isInvoiceNotFoundError(err error) bool { + if err == nil { + return false + } + + if errors.Is(err, invpkg.ErrInvoiceNotFound) { + return true + } + + rpcStatus, ok := status.FromError(err) + return ok && + rpcStatus.Code() == codes.Unknown && + rpcStatus.Message() == invpkg.ErrInvoiceNotFound.Error() +} + // loopInSwap contains all the in-memory state related to a pending loop in // swap. type loopInSwap struct { @@ -103,6 +120,11 @@ type loopInSwap struct { // Settlement is final, so the flag is never cleared. invoiceSettled bool + // invoiceCanceled is set once lnd acknowledged the cancellation of the + // swap invoice, or no longer knows the invoice. Either way, the server + // can no longer pay it. + invoiceCanceled bool + abandonChan chan struct{} wg sync.WaitGroup @@ -915,23 +937,26 @@ func (s *loopInSwap) waitForSwapComplete(ctx context.Context, } // publishTxOnTimeout publishes the timeout tx if the contract has - // expired and invoice has not been settled. + // expired and the invoice can no longer be settled. publishTxOnTimeout := func() (btcutil.Amount, error) { - // Don't publish the timeout tx if the invoice was settled. - if s.state == loopdb.StateInvoiceSettled { + // Don't publish the timeout tx if the invoice was settled or + // the swap succeeded. + if s.invoiceSettled || s.state == loopdb.StateInvoiceSettled || + s.state == loopdb.StateSuccess { + return 0, nil } - // Don't publish the timeout tx if the swap succeeded. - if s.state == loopdb.StateSuccess { + if s.height < s.LoopInContract.CltvExpiry { return 0, nil } - if s.height >= s.LoopInContract.CltvExpiry { - return s.publishTimeoutTx(ctx, htlcOutpoint, htlcValue) + refund, err := s.authorizeRefund(ctx) + if err != nil || !refund { + return 0, err } - return 0, nil + return s.publishTimeoutTx(ctx, htlcOutpoint, htlcValue) } // Check timeout at current height. After a restart we may want to @@ -946,8 +971,9 @@ func (s *loopInSwap) waitForSwapComplete(ctx context.Context, invoiceFinalized := false htlcKeyRevealed := false for { - // Check stop conditions. - if htlcSpend && invoiceFinalized { + // Check stop conditions. A canceled invoice is final even if + // lnd deleted it and never reports the cancellation. + if htlcSpend && (invoiceFinalized || s.invoiceCanceled) { break } if s.state == loopdb.StateInvoiceSettled { @@ -1059,6 +1085,64 @@ func (s *loopInSwap) waitForSwapComplete(ctx context.Context, return nil } +// authorizeRefund makes sure that the swap invoice can no longer be settled +// before the expired htlc is refunded, and reports whether the refund may +// proceed. +// +// Once the htlc expired, the server must not be able to pay the invoice +// anymore: a late payment would give the server the preimage while the client +// takes back the htlc. lnd's CancelInvoice resolves the race with settlement +// atomically. It succeeds for an open or already canceled invoice and fails +// for a settled one, which then blocks the refund. lnd only deletes canceled +// invoices, so an invoice that it no longer knows can't be settled either, +// for example one that its garbage collection removed after an earlier +// cancellation. Any other error leaves the outcome unresolved, so the refund +// waits for a later attempt. +func (s *loopInSwap) authorizeRefund(ctx context.Context) (bool, error) { + if s.invoiceCanceled { + return true, nil + } + + err := s.lnd.Invoices.CancelInvoice(ctx, s.hash) + switch { + case err == nil, isInvoiceNotFoundError(err): + + case isInvoiceAlreadySettledError(err): + s.log.Infof("Swap invoice settled before the refund, not " + + "refunding the htlc") + + // The swap can complete before the invoice update that + // reports the paid amount arrives, so take the amount from the + // invoice itself. + invoice, lookupErr := s.lnd.Client.LookupInvoice(ctx, s.hash) + if lookupErr != nil { + s.log.Warnf("Unable to look up the paid amount of the "+ + "settled swap invoice: %v", lookupErr) + } else { + s.cost.Server = s.AmountRequested - + invoice.AmountPaid.ToSatoshis() + } + + s.invoiceSettled = true + if s.state == loopdb.StateHtlcPublished { + s.setState(loopdb.StateInvoiceSettled) + return false, s.persistAndAnnounceState(ctx) + } + + return false, nil + + default: + s.log.Warnf("Unable to cancel the swap invoice before the "+ + "refund, retrying at the next block: %v", err) + + return false, nil + } + + s.invoiceCanceled = true + + return true, nil +} + // tryPushHtlcKey attempts to push the htlc key to the server. If the server // returns an error of any kind we'll log it as a warning but won't act as the // swap execution can just go on without the server gaining knowledge of our @@ -1124,9 +1208,14 @@ func (s *loopInSwap) processHtlcSpend(ctx context.Context, // swap invoice. We still need to query the final invoice state. // This is not a hodl invoice, so it may be that the invoice was // already settled. This means that the server didn't succeed in - // sweeping the htlc after paying the invoice. + // sweeping the htlc after paying the invoice. An invoice that + // lnd no longer knows was canceled before the refund. err := s.lnd.Invoices.CancelInvoice(ctx, s.hash) - if err != nil && !isInvoiceAlreadySettledError(err) { + switch { + case err == nil, isInvoiceNotFoundError(err): + s.invoiceCanceled = true + + case !isInvoiceAlreadySettledError(err): return err } } diff --git a/loopin_test.go b/loopin_test.go index fb0ff8b9c..107046e66 100644 --- a/loopin_test.go +++ b/loopin_test.go @@ -597,6 +597,10 @@ func handleHtlcExpiry(t *testing.T, ctx *loopInTestContext, inSwap *loopInSwap, // Let htlc expire. ctx.blockEpochChan <- inSwap.LoopInContract.CltvExpiry + // Before refunding, the client cancels the swap invoice so that the + // server can no longer pay it. + require.Equal(t, ctx.server.swapHash, <-ctx.lnd.FailInvoiceChannel) + // Expect a signing request for the htlc tx output value. signReq := <-ctx.lnd.SignOutputRawChannel require.Equal( @@ -1087,12 +1091,37 @@ func startNewLoopIn(t *testing.T, ctx *loopInTestContext, height int32) ( return cfg, inSwap, err } +// refundGateInvoices returns scripted errors for invoice cancellations and +// forwards every other call to the mock invoices client. +type refundGateInvoices struct { + lndclient.InvoicesClient + + cancelErrs chan error +} + +// CancelInvoice returns the next scripted error, or forwards the call. +func (r *refundGateInvoices) CancelInvoice(ctx context.Context, + hash lntypes.Hash) error { + + select { + case err := <-r.cancelErrs: + if err != nil { + return err + } + + default: + } + + return r.InvoicesClient.CancelInvoice(ctx, hash) +} + // expiringLoopIn is a loop in whose confirmed htlc is about to expire. type expiringLoopIn struct { - ctx *loopInTestContext - swap *loopInSwap - htlcTx wire.MsgTx - errChan chan error + ctx *loopInTestContext + swap *loopInSwap + htlcTx wire.MsgTx + invoices *refundGateInvoices + errChan chan error } // startExpiringLoopIn runs a loop in until its htlc confirmed and the client @@ -1112,6 +1141,12 @@ func startExpiringLoopIn(t *testing.T) *expiringLoopIn { require.NoError(t, err) ctx.store.AssertLoopInStored() + invoices := &refundGateInvoices{ + InvoicesClient: ctx.lnd.LndServices.Invoices, + cancelErrs: make(chan error, 4), + } + ctx.lnd.LndServices.Invoices = invoices + errChan := make(chan error, 1) go func() { errChan <- initResult.swap.execute( @@ -1131,13 +1166,99 @@ func startExpiringLoopIn(t *testing.T) *expiringLoopIn { ctx.assertSubscribeInvoice(ctx.server.swapHash) return &expiringLoopIn{ - ctx: ctx, - swap: initResult.swap, - htlcTx: htlcTx, - errChan: errChan, + ctx: ctx, + swap: initResult.swap, + htlcTx: htlcTx, + invoices: invoices, + errChan: errChan, + } +} + +// requireNoRefund asserts that no refund is signed. +func (e *expiringLoopIn) requireNoRefund(t *testing.T) { + t.Helper() + + select { + case <-e.ctx.lnd.SignOutputRawChannel: + t.Fatal("htlc refund was signed") + + case <-time.After(100 * time.Millisecond): } } +// TestLoopInRefundGateSettledInvoice asserts that a refund is never published +// when the swap invoice turns out to be settled at the htlc expiry, even if +// the client has not seen the settlement yet. +func TestLoopInRefundGateSettledInvoice(t *testing.T) { + defer test.Guard(t)() + + e := startExpiringLoopIn(t) + e.invoices.cancelErrs <- status.Error( + codes.Unknown, invpkg.ErrInvoiceAlreadySettled.Error(), + ) + + // The server paid the invoice, but its update has not arrived. + invoice, err := e.ctx.lnd.Client.LookupInvoice( + context.Background(), e.swap.hash, + ) + require.NoError(t, err) + invoice.State = invpkg.ContractSettled + invoice.AmountPaid = invoice.Amount + e.ctx.lnd.SetInvoice(invoice) + + e.ctx.blockEpochChan <- e.swap.LoopInContract.CltvExpiry + e.ctx.store.AssertLoopInState(loopdb.StateInvoiceSettled) + e.ctx.assertState(loopdb.StateInvoiceSettled) + e.requireNoRefund(t) + + // The server cost is recorded without the invoice update. + state := e.ctx.store.AssertLoopInState(loopdb.StateSuccess) + e.ctx.assertState(loopdb.StateSuccess) + require.Equal(t, + e.swap.AmountRequested-invoice.Amount.ToSatoshis(), + state.Cost.Server) + require.Positive(t, state.Cost.Server) + require.NoError(t, <-e.errChan) + require.Positive(t, e.ctx.server.pushKeyCalls.Load()) +} + +// TestLoopInRefundGateUnresolvedCancellation asserts that an invoice +// cancellation with an uncertain outcome defers the refund to a later block. +func TestLoopInRefundGateUnresolvedCancellation(t *testing.T) { + defer test.Guard(t)() + + e := startExpiringLoopIn(t) + e.invoices.cancelErrs <- status.Error( + codes.Unavailable, "connection lost", + ) + + expiry := e.swap.LoopInContract.CltvExpiry + e.ctx.blockEpochChan <- expiry + e.requireNoRefund(t) + + // The next block cancels the invoice and refunds the htlc. + e.ctx.blockEpochChan <- expiry + 1 + require.Equal(t, e.ctx.server.swapHash, <-e.ctx.lnd.FailInvoiceChannel) + + signReq := <-e.ctx.lnd.SignOutputRawChannel + require.Equal(t, e.htlcTx.TxOut[0].Value, + signReq.SignDescriptors[0].Output.Value) + timeoutTx := <-e.ctx.lnd.TxPublishChannel + + e.ctx.lnd.SpendChannel <- &chainntnfs.SpendDetail{ + SpendingTx: timeoutTx, + SpenderInputIndex: 0, + } + <-e.ctx.lnd.FailInvoiceChannel + e.ctx.updateInvoiceState(0, invpkg.ContractCanceled) + e.ctx.assertState(loopdb.StateFailTimeout) + e.ctx.store.AssertLoopInState(loopdb.StateFailTimeout) + require.NoError(t, <-e.errChan) + + // A canceled invoice never reveals the htlc key. + require.Zero(t, e.ctx.server.pushKeyCalls.Load()) +} + // TestLoopInCanceledInvoiceKeepsHtlcKey asserts that the htlc key is not // revealed after the swap invoice was canceled, not even on later blocks. func TestLoopInCanceledInvoiceKeepsHtlcKey(t *testing.T) { @@ -1153,6 +1274,7 @@ func TestLoopInCanceledInvoiceKeepsHtlcKey(t *testing.T) { require.Zero(t, e.ctx.server.pushKeyCalls.Load()) e.ctx.blockEpochChan <- expiry + <-e.ctx.lnd.FailInvoiceChannel <-e.ctx.lnd.SignOutputRawChannel timeoutTx := <-e.ctx.lnd.TxPublishChannel e.ctx.lnd.SpendChannel <- &chainntnfs.SpendDetail{ @@ -1165,3 +1287,41 @@ func TestLoopInCanceledInvoiceKeepsHtlcKey(t *testing.T) { require.NoError(t, <-e.errChan) require.Zero(t, e.ctx.server.pushKeyCalls.Load()) } + +// TestLoopInRefundGateDeletedInvoice asserts that a swap invoice that lnd no +// longer knows, for example one that its garbage collection deleted after an +// earlier cancellation, counts as canceled: the expired htlc is refunded, and +// the swap completes without an update of the deleted invoice. +func TestLoopInRefundGateDeletedInvoice(t *testing.T) { + defer test.Guard(t)() + + e := startExpiringLoopIn(t) + notFound := status.Error( + codes.Unknown, invpkg.ErrInvoiceNotFound.Error(), + ) + + // Both the cancellation before the refund and the one after it find + // no invoice. + e.invoices.cancelErrs <- notFound + e.invoices.cancelErrs <- notFound + + e.ctx.blockEpochChan <- e.swap.LoopInContract.CltvExpiry + select { + case signReq := <-e.ctx.lnd.SignOutputRawChannel: + require.Equal(t, e.htlcTx.TxOut[0].Value, + signReq.SignDescriptors[0].Output.Value) + + case <-time.After(test.Timeout): + t.Fatal("htlc refund was not signed") + } + timeoutTx := <-e.ctx.lnd.TxPublishChannel + + e.ctx.lnd.SpendChannel <- &chainntnfs.SpendDetail{ + SpendingTx: timeoutTx, + SpenderInputIndex: 0, + } + e.ctx.assertState(loopdb.StateFailTimeout) + e.ctx.store.AssertLoopInState(loopdb.StateFailTimeout) + require.NoError(t, <-e.errChan) + require.Zero(t, e.ctx.server.pushKeyCalls.Load()) +}