Skip to content

Commit 21c0b8c

Browse files
committed
fix(self-custodial): close inFlightInvoicesRef race between live and replay paths
1 parent e6dd3db commit 21c0b8c

2 files changed

Lines changed: 67 additions & 23 deletions

File tree

__tests__/self-custodial/hooks/use-auto-convert-listener.spec.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -685,4 +685,43 @@ describe("useAutoConvertListener — mount replay", () => {
685685
})
686686
expect(sdk.listPayments).not.toHaveBeenCalled()
687687
})
688+
689+
it("dedups live and replay racing the same paymentRequest via inFlightInvoicesRef (Important #1)", async () => {
690+
// Both effects fire on mount; both target the same invoice. Only one
691+
// convert must run — the second path should see the inFlight ref populated
692+
// by the first and skip cleanly.
693+
const sdk = makeSdk({
694+
getPayment: jest.fn().mockResolvedValue({ payment: makeLightningPayment("lnbc1") }),
695+
listPayments: jest.fn().mockResolvedValue({
696+
payments: [
697+
{
698+
id: "pid-lnbc1",
699+
amount: 5000n,
700+
details: { tag: "Lightning", inner: { invoice: "lnbc1" } },
701+
},
702+
],
703+
}),
704+
})
705+
setupDefaults(sdk)
706+
mockUseSelfCustodialWallet.mockReturnValue({
707+
sdk,
708+
lastReceivedPaymentId: "pid-lnbc1",
709+
isStableBalanceActive: false,
710+
})
711+
mockFindPendingAutoConvert.mockResolvedValue(makeRecord({ paymentRequest: "lnbc1" }))
712+
mockListPendingAutoConverts.mockResolvedValue([
713+
makeRecord({ paymentRequest: "lnbc1" }),
714+
])
715+
716+
renderHook(() => useAutoConvertListener())
717+
718+
await waitFor(() => {
719+
expect(mockExecuteAutoConvert).toHaveBeenCalled()
720+
})
721+
// Give any pending replay-path microtasks a chance to flush.
722+
await new Promise((resolve) => {
723+
setTimeout(resolve, 10)
724+
})
725+
expect(mockExecuteAutoConvert).toHaveBeenCalledTimes(1)
726+
})
688727
})

app/self-custodial/hooks/use-auto-convert-listener.ts

Lines changed: 28 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -274,14 +274,17 @@ export const useAutoConvertListener = (): void => {
274274
if (inFlightInvoicesRef.current.has(invoice)) return
275275
if (!isRetryableNow(record, Date.now())) return
276276

277-
const { claimedConversionIds, pairedReceiveIds } = await buildClaimedConversionIds()
278-
if (pairedReceiveIds.has(lastReceivedPaymentId)) {
279-
await removePendingAutoConvert(invoice)
280-
return
281-
}
282-
277+
// Reserve the invoice synchronously so the replay path can't race past
278+
// the `has` check during the awaits below.
283279
inFlightInvoicesRef.current.add(invoice)
284280
try {
281+
const { claimedConversionIds, pairedReceiveIds } =
282+
await buildClaimedConversionIds()
283+
if (pairedReceiveIds.has(lastReceivedPaymentId)) {
284+
await removePendingAutoConvert(invoice)
285+
return
286+
}
287+
285288
await runAutoConvert({
286289
sdk,
287290
record,
@@ -332,27 +335,29 @@ export const useAutoConvertListener = (): void => {
332335
if (inFlightInvoicesRef.current.has(record.paymentRequest)) return
333336
if (!isRetryableNow(record, nowMs)) return
334337

335-
const paid = await findPaidAmountForInvoice(sdk, record.paymentRequest)
336-
// Bound the replay loop on busy wallets where the matching payment has
337-
// aged off the recent listPayments page.
338-
if (!paid) {
339-
if (record.attempts + 1 >= autoConvertMaxAttempts) {
340-
await removePendingAutoConvert(record.paymentRequest)
338+
// Reserve the invoice synchronously so the live path can't race past
339+
// the `has` check during the awaits below.
340+
inFlightInvoicesRef.current.add(record.paymentRequest)
341+
try {
342+
const paid = await findPaidAmountForInvoice(sdk, record.paymentRequest)
343+
// Bound the replay loop on busy wallets where the matching payment has
344+
// aged off the recent listPayments page.
345+
if (!paid) {
346+
if (record.attempts + 1 >= autoConvertMaxAttempts) {
347+
await removePendingAutoConvert(record.paymentRequest)
348+
return
349+
}
350+
await recordAutoConvertAttempt(record.paymentRequest, nowMs)
341351
return
342352
}
343-
await recordAutoConvertAttempt(record.paymentRequest, nowMs)
344-
return
345-
}
346353

347-
const { claimedConversionIds, pairedReceiveIds } =
348-
await buildClaimedConversionIds()
349-
if (pairedReceiveIds.has(paid.paymentId)) {
350-
await removePendingAutoConvert(record.paymentRequest)
351-
return
352-
}
354+
const { claimedConversionIds, pairedReceiveIds } =
355+
await buildClaimedConversionIds()
356+
if (pairedReceiveIds.has(paid.paymentId)) {
357+
await removePendingAutoConvert(record.paymentRequest)
358+
return
359+
}
353360

354-
inFlightInvoicesRef.current.add(record.paymentRequest)
355-
try {
356361
await runAutoConvert({
357362
sdk,
358363
record,

0 commit comments

Comments
 (0)