From eb96db04e3f781f8ced18b0188830c882fb6e072 Mon Sep 17 00:00:00 2001 From: Andras Banki-Horvath Date: Fri, 21 Aug 2026 12:01:11 +0200 Subject: [PATCH 1/3] swaps: cover settled funding replay Extend the lost-response regression through authoritative claim observation and terminal restart. Assert the recovered payment completes once without duplicate funding or recovery side effects. Also replay an authoritative funding miss after restart to prove the negative control remains terminal. (cherry picked from commit 739de3f4e7792291947539c0dc80f7e03c3d2581) --- sdk/swaps/in_swap_test.go | 91 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 89 insertions(+), 2 deletions(-) diff --git a/sdk/swaps/in_swap_test.go b/sdk/swaps/in_swap_test.go index d19d28cbf..b2e08dd89 100644 --- a/sdk/swaps/in_swap_test.go +++ b/sdk/swaps/in_swap_test.go @@ -2479,8 +2479,10 @@ func TestPaySessionResumeFundingGraceEventuallyRetries(t *testing.T) { // TestPaySessionFundingReplayAfterLostResponse verifies an accepted funding // OOR whose RPC response is lost is recovered under the same payment-scoped -// idempotency key. The resumed SDK must obtain the original session/outpoint; -// it must not create a second daemon-side funding intent. +// idempotency key. After restart, the SDK must obtain the original +// session/outpoint, observe the authoritative Lightning preimage, and persist +// completion. A second restart must return that result without repeating any +// funding or recovery side effect. func TestPaySessionFundingReplayAfterLostResponse(t *testing.T) { t.Parallel() @@ -2624,6 +2626,65 @@ func TestPaySessionFundingReplayAfterLostResponse(t *testing.T) { t, start.Add(55*time.Second), opts.AdmissionDeadline, ) } + + // The server settles the Lightning invoice and claims the funded vHTLC. + // Model the authoritative indexer exposing both the spend and its + // checkpoint preimage after the SDK has recovered from the lost + // response. + daemonConn.spentVTXO = &VTXOInfo{ + Outpoint: "funding-session:0", + AmountSat: testInSwapAmountSat, + SpentByTxID: "claim-session", + } + daemonConn.indexedPackage = &OORPackageInfo{ + CheckpointPSBTs: [][]byte{ + testCheckpointPSBTWithPreimage(t, preimage[:]), + }, + } + + result, err := resumed.Wait(t.Context()) + require.NoError(t, err) + require.Equal(t, preimage.Hash(), result.PaymentHash) + require.Equal(t, preimage, result.Preimage) + require.Equal(t, "funding-session", result.FundingSessionID) + require.Equal(t, PayStateCompleted, resumed.State()) + require.Len(t, accepted, 1) + require.Equal(t, 3, daemonConn.sendPolicyCalls) + require.Equal(t, 1, daemonConn.armRecoveryCalls) + require.Equal(t, 1, daemonConn.cancelCalls) + + summary, err := resumedClient.GetSwapSummary( + t.Context(), preimage.Hash(), + ) + require.NoError(t, err) + require.Equal(t, PayStateCompleted.String(), summary.State) + require.False(t, summary.Pending) + require.NotNil(t, summary.Preimage) + require.Equal(t, preimage, *summary.Preimage) + require.Equal(t, "funding-session", summary.FundingSessionID) + require.Equal(t, "funding-session:0", summary.VHTLCOutpoint) + + // A daemon restart after terminal persistence must only reload the + // completed result. It must not re-submit funding or re-arm/cancel the + // recovery actor. + terminalClient := configureTestPayClient( + NewSwapClientWithStore( + serverConn, daemonConn, nil, nil, store, + ), + ) + terminal, err := terminalClient.ResumePayViaLightning( + t.Context(), preimage.Hash(), + ) + require.NoError(t, err) + + terminalResult, err := terminal.Wait(t.Context()) + require.NoError(t, err) + require.Equal(t, result, terminalResult) + require.Equal(t, PayStateCompleted, terminal.State()) + require.Len(t, accepted, 1) + require.Equal(t, 3, daemonConn.sendPolicyCalls) + require.Equal(t, 1, daemonConn.armRecoveryCalls) + require.Equal(t, 1, daemonConn.cancelCalls) } // TestPaySessionExpiresAfterAuthoritativeFundingMiss verifies an ambiguous @@ -2716,6 +2777,32 @@ func TestPaySessionExpiresAfterAuthoritativeFundingMiss(t *testing.T) { daemonConn.sendPolicyOpts[0].AdmissionDeadline, ) require.True(t, daemonConn.sendPolicyOpts[0].ExistingOnly) + + // A later restart must preserve the authoritative failure and must not + // create a funding intent while reloading the terminal row. + terminalClient := configureTestPayClient( + NewSwapClientWithStore( + serverConn, daemonConn, nil, nil, store, + ), + ) + terminal, err := terminalClient.ResumePayViaLightning( + t.Context(), preimage.Hash(), + ) + require.NoError(t, err) + + _, err = terminal.Wait(t.Context()) + require.ErrorIs(t, err, errSwapExpired) + require.Equal(t, PayStateExpired, terminal.State()) + require.Equal(t, 0, daemonConn.armRecoveryCalls) + require.Equal(t, 1, daemonConn.sendPolicyCalls) + + summary, err := terminalClient.GetSwapSummary( + t.Context(), preimage.Hash(), + ) + require.NoError(t, err) + require.Equal(t, PayStateExpired.String(), summary.State) + require.False(t, summary.Pending) + require.Nil(t, summary.Preimage) } // TestPaySessionRefundsAmountMismatch asserts the client preserves mismatch From 5cd78b49af1072bb72fc0ee38fdb32aa63926317 Mon Sep 17 00:00:00 2001 From: Andras Banki-Horvath Date: Fri, 21 Aug 2026 12:01:18 +0200 Subject: [PATCH 2/3] swapwallet: cover recovered pay projection Project the reported incident shape through the real activity store and daemon balance adapter. Assert a completed pay produces one terminal event, the activity amount matches the VTXO debit, and a failed negative control does not alter balance. (cherry picked from commit 7687a182dd7351ed6990f8148cafa5287071c996) --- swapwallet/reconciler_test.go | 113 ++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/swapwallet/reconciler_test.go b/swapwallet/reconciler_test.go index 4312d7962..22f2d99a4 100644 --- a/swapwallet/reconciler_test.go +++ b/swapwallet/reconciler_test.go @@ -44,6 +44,119 @@ func newReconcileFixture(t *testing.T) (*Runtime, *db.ActivityPersistenceStore, return runtime, store, rpc } +// TestRecoveredSettledPayProjectsOnceAndMatchesBalance verifies the wallet +// surfaces a restart-reconciled pay as COMPLETE exactly once and preserves the +// daemon's post-OOR VTXO balance. The negative control proves an authoritative +// failed pay does not imply a balance debit at the wallet projection layer. +func TestRecoveredSettledPayProjectsOnceAndMatchesBalance(t *testing.T) { + t.Parallel() + + const ( + paymentHash = "b960600a4e03672eefd18d12604a1e5b" + + "932c57dc2670f62ffb6ac17a4c6b31f2" + failedHash = "failed-before-funding" + startingVTXOs = int64(3000) + paymentAmount = int64(1001) + remainingVTXOs = startingVTXOs - paymentAmount + ) + + ctx := t.Context() + runtime, store, rpc := newReconcileFixture(t) + service := newService(runtime.deps, runtime) + + rpc.getBalanceResp = &waverpc.GetBalanceResponse{ + VtxoBalanceSat: startingVTXOs, + } + before, err := service.Balance( + ctx, &wavewalletrpc.BalanceRequest{}, + ) + require.NoError(t, err) + + settled := &swapclientrpc.SwapSummary{ + PaymentHash: paymentHash, + Direction: swapclientrpc. + SwapDirection_SWAP_DIRECTION_PAY, + State: swapclientrpc.SwapState_SWAP_STATE_COMPLETED, + AmountSat: paymentAmount, + FeeSat: 1, + Preimage: "authoritative-settlement-preimage", + } + require.NoError( + t, + runtime.fanOutSwapUpdate( + &swapclientrpc.SubscribeSwapsResponse{ + Swap: settled, + }, + ), + ) + + // Startup replay can deliver the same terminal summary again. The + // canonical store must suppress the duplicate transition event. + require.NoError( + t, + runtime.fanOutSwapUpdate( + &swapclientrpc.SubscribeSwapsResponse{ + Swap: settled, + }, + ), + ) + + rpc.getBalanceResp = &waverpc.GetBalanceResponse{ + VtxoBalanceSat: remainingVTXOs, + } + after, err := service.Balance( + ctx, &wavewalletrpc.BalanceRequest{}, + ) + require.NoError(t, err) + + entry, err := store.GetEntry(ctx, paymentHash) + require.NoError(t, err) + require.Equal( + t, int64(wavewalletrpc.EntryStatus_ENTRY_STATUS_COMPLETE), + entry.Status, + ) + require.Equal(t, -paymentAmount, entry.AmountSat) + require.Equal(t, int64(1), entry.FeeSat) + require.Equal( + t, entry.AmountSat, + after.GetConfirmedSat()-before.GetConfirmedSat(), + ) + + events, err := store.PullEvents(ctx, 0, 100) + require.NoError(t, err) + require.Len(t, events, 1) + require.Equal(t, paymentHash, events[0].CanonicalID) + + failed := &swapclientrpc.SwapSummary{ + PaymentHash: failedHash, + Direction: swapclientrpc. + SwapDirection_SWAP_DIRECTION_PAY, + State: swapclientrpc.SwapState_SWAP_STATE_FAILED, + AmountSat: paymentAmount, + } + require.NoError( + t, + runtime.fanOutSwapUpdate( + &swapclientrpc.SubscribeSwapsResponse{ + Swap: failed, + }, + ), + ) + + failedEntry, err := store.GetEntry(ctx, failedHash) + require.NoError(t, err) + require.Equal( + t, int64(wavewalletrpc.EntryStatus_ENTRY_STATUS_FAILED), + failedEntry.Status, + ) + + afterFailure, err := service.Balance( + ctx, &wavewalletrpc.BalanceRequest{}, + ) + require.NoError(t, err) + require.Equal(t, after, afterFailure) +} + // TestReconcileActivityFlipsDepositLive verifies the reconciler lands a // confirmed boarding deposit's PENDING -> COMPLETE transition into the store // live (no restart), and that a second pass is a no-op (ProjectEntry From 9c4ffb0277590ae2514cdddc91f19ea93e3cff32 Mon Sep 17 00:00:00 2001 From: Andras Banki-Horvath Date: Fri, 21 Aug 2026 12:12:56 +0200 Subject: [PATCH 3/3] test: tighten issue 881 replay proof Exercise activity replay through the startup backfill and remove a fake balance comparison that only restated its fixture. Bound terminal reload waits and use txid-shaped identities. (cherry picked from commit 76e0c147ab6c139be056645a5d5cf1c8ab0576d4) --- sdk/swaps/in_swap_test.go | 21 ++++++--- swapwallet/reconciler_test.go | 80 ++++++++++++----------------------- 2 files changed, 44 insertions(+), 57 deletions(-) diff --git a/sdk/swaps/in_swap_test.go b/sdk/swaps/in_swap_test.go index b2e08dd89..4c1485803 100644 --- a/sdk/swaps/in_swap_test.go +++ b/sdk/swaps/in_swap_test.go @@ -2632,9 +2632,10 @@ func TestPaySessionFundingReplayAfterLostResponse(t *testing.T) { // checkpoint preimage after the SDK has recovered from the lost // response. daemonConn.spentVTXO = &VTXOInfo{ - Outpoint: "funding-session:0", - AmountSat: testInSwapAmountSat, - SpentByTxID: "claim-session", + Outpoint: "funding-session:0", + AmountSat: testInSwapAmountSat, + SpentByTxID: "0123456789abcdef0123456789abcdef" + + "0123456789abcdef0123456789abcdef", } daemonConn.indexedPackage = &OORPackageInfo{ CheckpointPSBTs: [][]byte{ @@ -2677,7 +2678,12 @@ func TestPaySessionFundingReplayAfterLostResponse(t *testing.T) { ) require.NoError(t, err) - terminalResult, err := terminal.Wait(t.Context()) + terminalCtx, cancelTerminal := context.WithTimeout( + t.Context(), time.Second, + ) + defer cancelTerminal() + + terminalResult, err := terminal.Wait(terminalCtx) require.NoError(t, err) require.Equal(t, result, terminalResult) require.Equal(t, PayStateCompleted, terminal.State()) @@ -2790,7 +2796,12 @@ func TestPaySessionExpiresAfterAuthoritativeFundingMiss(t *testing.T) { ) require.NoError(t, err) - _, err = terminal.Wait(t.Context()) + terminalCtx, cancelTerminal := context.WithTimeout( + t.Context(), time.Second, + ) + defer cancelTerminal() + + _, err = terminal.Wait(terminalCtx) require.ErrorIs(t, err, errSwapExpired) require.Equal(t, PayStateExpired, terminal.State()) require.Equal(t, 0, daemonConn.armRecoveryCalls) diff --git a/swapwallet/reconciler_test.go b/swapwallet/reconciler_test.go index 22f2d99a4..18187803b 100644 --- a/swapwallet/reconciler_test.go +++ b/swapwallet/reconciler_test.go @@ -44,33 +44,24 @@ func newReconcileFixture(t *testing.T) (*Runtime, *db.ActivityPersistenceStore, return runtime, store, rpc } -// TestRecoveredSettledPayProjectsOnceAndMatchesBalance verifies the wallet -// surfaces a restart-reconciled pay as COMPLETE exactly once and preserves the -// daemon's post-OOR VTXO balance. The negative control proves an authoritative -// failed pay does not imply a balance debit at the wallet projection layer. -func TestRecoveredSettledPayProjectsOnceAndMatchesBalance(t *testing.T) { +// TestRecoveredSettledPayProjectsOnceOnStartup verifies the wallet surfaces a +// restart-reconciled pay as COMPLETE exactly once. The negative control proves +// an authoritative failed pay remains FAILED through the same startup backfill. +func TestRecoveredSettledPayProjectsOnceOnStartup(t *testing.T) { t.Parallel() const ( paymentHash = "b960600a4e03672eefd18d12604a1e5b" + "932c57dc2670f62ffb6ac17a4c6b31f2" - failedHash = "failed-before-funding" - startingVTXOs = int64(3000) - paymentAmount = int64(1001) - remainingVTXOs = startingVTXOs - paymentAmount + failedHash = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + paymentAmount = int64(1001) ) ctx := t.Context() - runtime, store, rpc := newReconcileFixture(t) - service := newService(runtime.deps, runtime) - - rpc.getBalanceResp = &waverpc.GetBalanceResponse{ - VtxoBalanceSat: startingVTXOs, - } - before, err := service.Balance( - ctx, &wavewalletrpc.BalanceRequest{}, - ) - require.NoError(t, err) + runtime, store, _ := newReconcileFixture(t) + swap, ok := runtime.deps.SwapService.(*fakeSwapService) + require.True(t, ok) settled := &swapclientrpc.SwapSummary{ PaymentHash: paymentHash, @@ -90,24 +81,15 @@ func TestRecoveredSettledPayProjectsOnceAndMatchesBalance(t *testing.T) { ), ) - // Startup replay can deliver the same terminal summary again. The - // canonical store must suppress the duplicate transition event. - require.NoError( - t, - runtime.fanOutSwapUpdate( - &swapclientrpc.SubscribeSwapsResponse{ - Swap: settled, - }, - ), - ) - - rpc.getBalanceResp = &waverpc.GetBalanceResponse{ - VtxoBalanceSat: remainingVTXOs, + // A daemon restart replays persisted swaps through ListSwaps and the + // startup backfill. The canonical store must suppress the duplicate + // terminal transition event. + swap.listSwapsResp = &swapclientrpc.ListSwapsResponse{ + Swaps: []*swapclientrpc.SwapSummary{ + settled, + }, } - after, err := service.Balance( - ctx, &wavewalletrpc.BalanceRequest{}, - ) - require.NoError(t, err) + runtime.backfillActivity(ctx) entry, err := store.GetEntry(ctx, paymentHash) require.NoError(t, err) @@ -117,10 +99,6 @@ func TestRecoveredSettledPayProjectsOnceAndMatchesBalance(t *testing.T) { ) require.Equal(t, -paymentAmount, entry.AmountSat) require.Equal(t, int64(1), entry.FeeSat) - require.Equal( - t, entry.AmountSat, - after.GetConfirmedSat()-before.GetConfirmedSat(), - ) events, err := store.PullEvents(ctx, 0, 100) require.NoError(t, err) @@ -134,14 +112,8 @@ func TestRecoveredSettledPayProjectsOnceAndMatchesBalance(t *testing.T) { State: swapclientrpc.SwapState_SWAP_STATE_FAILED, AmountSat: paymentAmount, } - require.NoError( - t, - runtime.fanOutSwapUpdate( - &swapclientrpc.SubscribeSwapsResponse{ - Swap: failed, - }, - ), - ) + swap.listSwapsResp.Swaps = append(swap.listSwapsResp.Swaps, failed) + runtime.backfillActivity(ctx) failedEntry, err := store.GetEntry(ctx, failedHash) require.NoError(t, err) @@ -150,11 +122,15 @@ func TestRecoveredSettledPayProjectsOnceAndMatchesBalance(t *testing.T) { failedEntry.Status, ) - afterFailure, err := service.Balance( - ctx, &wavewalletrpc.BalanceRequest{}, - ) + events, err = store.PullEvents(ctx, 0, 100) + require.NoError(t, err) + require.Len(t, events, 2) + + // A repeated startup pass must suppress both terminal rows. + runtime.backfillActivity(ctx) + events, err = store.PullEvents(ctx, 0, 100) require.NoError(t, err) - require.Equal(t, after, afterFailure) + require.Len(t, events, 2) } // TestReconcileActivityFlipsDepositLive verifies the reconciler lands a