diff --git a/sdk/swaps/in_swap_test.go b/sdk/swaps/in_swap_test.go index d19d28cbf..4c1485803 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,71 @@ 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: "0123456789abcdef0123456789abcdef" + + "0123456789abcdef0123456789abcdef", + } + 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) + + 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()) + 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 +2783,37 @@ 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) + + 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) + 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 diff --git a/swapwallet/reconciler_test.go b/swapwallet/reconciler_test.go index 4312d7962..18187803b 100644 --- a/swapwallet/reconciler_test.go +++ b/swapwallet/reconciler_test.go @@ -44,6 +44,95 @@ func newReconcileFixture(t *testing.T) (*Runtime, *db.ActivityPersistenceStore, return runtime, store, rpc } +// 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 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + paymentAmount = int64(1001) + ) + + ctx := t.Context() + runtime, store, _ := newReconcileFixture(t) + swap, ok := runtime.deps.SwapService.(*fakeSwapService) + require.True(t, ok) + + 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, + }, + ), + ) + + // 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, + }, + } + runtime.backfillActivity(ctx) + + 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) + + 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, + } + swap.listSwapsResp.Swaps = append(swap.listSwapsResp.Swaps, failed) + runtime.backfillActivity(ctx) + + failedEntry, err := store.GetEntry(ctx, failedHash) + require.NoError(t, err) + require.Equal( + t, int64(wavewalletrpc.EntryStatus_ENTRY_STATUS_FAILED), + failedEntry.Status, + ) + + 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.Len(t, events, 2) +} + // 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