Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 100 additions & 2 deletions sdk/swaps/in_swap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
89 changes: 89 additions & 0 deletions swapwallet/reconciler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading