fix(nwc): debounce pending pay_invoice refresh and harden in-transit handling

This commit is contained in:
ajaysehwal
2026-06-07 12:02:23 +05:30
parent 005ae5c40c
commit 6e803fb0f5
3 changed files with 619 additions and 38 deletions
+184 -38
View File
@@ -91,6 +91,10 @@ const PAYMENT_TIMEOUT_SECONDS = 120;
const PAYMENT_FEE_LIMIT_SATS = 1000;
const PAYMENT_PROCESSING_DELAY_MS = 100;
const SAVE_CONNECTIONS_DEBOUNCE_MS = 500;
/** Min time a pay_invoice must stay pending before getActivities polls the node */
const PENDING_PAYMENT_STATUS_MIN_AGE_MS = 5_000;
/** Per-connection debounce for pending pay_invoice status refresh in getActivities */
const PENDING_PAYMENT_STATUS_REFRESH_MS = 30_000;
export const DEFAULT_NOSTR_RELAYS = [
'wss://relay.getalby.com/v1',
@@ -161,6 +165,14 @@ export default class NostrWalletConnectStore {
private androidLogListener: EmitterSubscription | null = null;
private _scheduledSave: ReturnType<typeof setTimeout> | null = null;
private resetInFlight: Promise<void> | null = null;
private lastPendingPaymentStatusFetchByConnection = new Map<
string,
number
>();
private lastPendingInvoiceStatusFetchByConnection = new Map<
string,
number
>();
settingsStore: SettingsStore;
balanceStore: BalanceStore;
@@ -923,6 +935,7 @@ export default class NostrWalletConnectStore {
}
}
const pendingLightningInvoiceActivities: ConnectionActivity[] = [];
const pendingCashuInvoiceActivities: ConnectionActivity[] = [];
for (const activity of connection.activity) {
if (activity?.invoice) {
@@ -948,13 +961,22 @@ export default class NostrWalletConnectStore {
!paymentRequestFirstMatchNotCashu.has(pr) &&
firstCashuPaidByPaymentRequest.get(pr) === true;
if (invoiceAlreadyPaid) {
const paidInvoice = cashuInvoices.find(
(inv) =>
inv.getPaymentRequest === pr && inv.isPaid
);
runInAction(() => {
activity.status = 'success';
if (paidInvoice) {
activity.invoice = paidInvoice;
}
});
} else if (activity.invoice.isExpired) {
runInAction(() => {
activity.status = 'expired';
});
} else {
pendingCashuInvoiceActivities.push(activity);
}
} else {
pendingLightningInvoiceActivities.push(activity);
@@ -998,6 +1020,68 @@ export default class NostrWalletConnectStore {
);
}
if (
pendingCashuInvoiceActivities.length > 0 &&
this.isCashuConfigured &&
this.shouldRefreshPendingCashuInvoices(
connectionId,
pendingCashuInvoiceActivities
)
) {
await Promise.all(
pendingCashuInvoiceActivities.map(async (activity) => {
const cashuInv = activity.invoice as
| CashuInvoice
| undefined;
const quote = cashuInv?.quote;
if (!quote || !cashuInv) return;
try {
const result = await this.cashuStore.checkInvoicePaid(
quote,
cashuInv.mintUrl,
undefined,
true
);
runInAction(() => {
if (result.isPaid) {
activity.status = 'success';
const updated =
result.updatedInvoice ||
this.cashuStore.invoices?.find(
(inv) => inv.quote === quote
);
if (updated) {
activity.invoice = updated;
}
const paidSats = Math.floor(
Number(result.amtSat) || 0
);
if (
paidSats > 0 &&
(!activity.satAmount ||
activity.satAmount <= 0)
) {
activity.satAmount = paidSats;
}
} else if (cashuInv.isExpired) {
activity.status = 'expired';
}
});
} catch (err) {
console.error(
'NWC: failed to refresh cashu make_invoice status:',
err
);
}
})
);
this.lastPendingInvoiceStatusFetchByConnection.set(
connectionId,
Date.now()
);
}
const pendingPayInvoiceActivities = connection.activity.filter(
(activity) =>
activity.type === 'pay_invoice' && activity.status === 'pending'
@@ -1007,8 +1091,11 @@ export default class NostrWalletConnectStore {
(activity) => activity.payment_source !== 'cashu'
);
if (lightningPending.length > 0) {
await this.paymentsStore.getPayments();
const payments = this.paymentsStore.payments || [];
const payments =
await this.getPaymentsForPendingPayInvoiceRefresh(
connectionId,
lightningPending
);
for (const activity of lightningPending) {
const payment = payments.find(
(p) =>
@@ -1021,17 +1108,12 @@ export default class NostrWalletConnectStore {
runInAction(() => {
activity.payment = new Payment(payment);
if (!payment.isIncomplete) {
const wasPending = activity.status === 'pending';
activity.status = 'success';
if (wasPending) {
const amountSats =
Math.floor(Number(activity.satAmount)) ||
Math.floor(
Number(activity.payment?.getAmount) || 0
);
if (amountSats > 0) {
connection.trackSpending(amountSats);
}
const amountSats =
Math.floor(Number(activity.satAmount)) ||
Math.floor(Number(payment.getAmount) || 0);
if (amountSats > 0) {
connection.trackSpending(amountSats);
}
} else if (payment.isFailed) {
activity.status = 'failed';
@@ -1845,6 +1927,7 @@ export default class NostrWalletConnectStore {
inTransitResult.payment?.getFee ||
0;
// NIP-47 requires preimage; empty string means HTLC is out but not settled (hodl/in-flight).
return {
result: {
preimage: '',
@@ -2013,11 +2096,10 @@ export default class NostrWalletConnectStore {
amount: amountSats.toString()
});
const cashuInTransit = cashuInvoice?.isInTransit;
if (
!cashuInvoice ||
cashuInvoice.isFailed ||
(this.cashuStore.paymentError && !cashuInTransit)
this.cashuStore.paymentError
) {
const paymentErrorMsg =
this.cashuStore.paymentErrorMsg ||
@@ -2041,16 +2123,9 @@ export default class NostrWalletConnectStore {
(p) => p.getPaymentRequest === request.invoice
);
if (cashuInTransit) {
await this.recordPendingPayment({
rawInvoice: request.invoice,
connection,
amountSats,
payment_source: 'cashu',
payment,
paymentHash: payment?.paymentHash
});
} else if (payment) {
// CashuStore does not surface IN_FLIGHT melts yet; add recordPendingPayment
// plus getActivities cashu reconciliation when it does.
if (payment) {
await this.finalizePayment({
id: request.invoice,
decoded: payment,
@@ -2145,6 +2220,65 @@ export default class NostrWalletConnectStore {
return { success: true };
}
/**
* Gates Cashu make_invoice status polling in getActivities (mint round-trip)
* using the same min-age and per-connection debounce as pay_invoice refresh.
*/
private shouldRefreshPendingCashuInvoices(
connectionId: string,
pendingCashuInvoices: ConnectionActivity[]
): boolean {
const now = Date.now();
const oldestPendingMs = Math.max(
0,
...pendingCashuInvoices.map(
(activity) => now - (activity.createdAt?.getTime() ?? now)
)
);
if (oldestPendingMs < PENDING_PAYMENT_STATUS_MIN_AGE_MS) {
return false;
}
const lastFetch =
this.lastPendingInvoiceStatusFetchByConnection.get(connectionId) ??
0;
if (now - lastFetch < PENDING_PAYMENT_STATUS_REFRESH_MS) {
return false;
}
return true;
}
private async getPaymentsForPendingPayInvoiceRefresh(
connectionId: string,
lightningPending: ConnectionActivity[]
): Promise<Payment[]> {
const cached = this.paymentsStore.payments || [];
const now = Date.now();
const oldestPendingMs = Math.max(
0,
...lightningPending.map(
(activity) => now - (activity.createdAt?.getTime() ?? now)
)
);
if (oldestPendingMs < PENDING_PAYMENT_STATUS_MIN_AGE_MS) {
return cached;
}
const lastFetch =
this.lastPendingPaymentStatusFetchByConnection.get(connectionId) ??
0;
if (now - lastFetch < PENDING_PAYMENT_STATUS_REFRESH_MS) {
return cached;
}
await this.paymentsStore.getPayments();
this.lastPendingPaymentStatusFetchByConnection.set(connectionId, now);
return this.paymentsStore.payments || [];
}
private getTransactionsStorePaymentState() {
return {
status: this.transactionsStore.status,
@@ -2291,7 +2425,7 @@ export default class NostrWalletConnectStore {
amountSats,
payment_source,
payment,
paymentHash
paymentHash: passedPaymentHash
}: {
rawInvoice: string;
connection: NWCConnection;
@@ -2300,14 +2434,21 @@ export default class NostrWalletConnectStore {
payment?: Payment | CashuPayment | null;
paymentHash?: string;
}): Promise<void> {
const { paymentRequest } = await NostrConnectUtils.decodeInvoiceTags(
rawInvoice
);
const { paymentRequest, paymentHash: decodedPaymentHash } =
await NostrConnectUtils.decodeInvoiceTags(rawInvoice);
const id = paymentRequest || rawInvoice;
const index = connection.activity.findIndex((conn) => conn.id === id);
const index = connection.activity.findIndex(
(activity) => activity.id === id
);
const activity = index !== -1 ? connection.activity[index] : undefined;
if (activity?.status === 'success') return;
const paymentHash =
passedPaymentHash ||
decodedPaymentHash ||
payment?.paymentHash ||
undefined;
runInAction(() => {
const record: ConnectionActivity = {
id,
@@ -2315,8 +2456,8 @@ export default class NostrWalletConnectStore {
satAmount: amountSats,
status: 'pending',
payment_source,
paymentHash,
createdAt: new Date()
createdAt: new Date(),
...(paymentHash ? { paymentHash } : {})
};
if (payment) {
@@ -2345,7 +2486,7 @@ export default class NostrWalletConnectStore {
amountSats,
payment_source,
errorMessage = localeString('error.paymentFailed'),
paymentHash
paymentHash: passedPaymentHash
}: {
rawInvoice: string;
connection: NWCConnection;
@@ -2355,13 +2496,18 @@ export default class NostrWalletConnectStore {
paymentHash?: string;
}): Promise<void> {
if (NostrConnectUtils.isIgnorableError(errorMessage || '')) return;
const { paymentRequest } = await NostrConnectUtils.decodeInvoiceTags(
rawInvoice
);
const { paymentRequest, paymentHash: decodedPaymentHash } =
await NostrConnectUtils.decodeInvoiceTags(rawInvoice);
const id = paymentRequest || rawInvoice;
const index = connection.activity.findIndex((conn) => conn.id === id);
const index = connection.activity.findIndex(
(activity) => activity.id === id
);
const activity = index !== -1 ? connection.activity[index] : undefined;
if (activity?.status === 'success') return;
const paymentHash =
passedPaymentHash || decodedPaymentHash || undefined;
runInAction(() => {
const record: ConnectionActivity = {
id,
@@ -2370,8 +2516,8 @@ export default class NostrWalletConnectStore {
status: 'failed',
payment_source,
error: errorMessage,
paymentHash,
createdAt: new Date()
createdAt: new Date(),
...(paymentHash ? { paymentHash } : {})
};
if (index !== -1) {
connection.activity[index] = {
+433
View File
@@ -36,10 +36,136 @@ jest.mock('react-native-notifications', () => ({
import * as nostrTools from 'nostr-tools';
import NostrConnectUtils from './NostrConnectUtils';
import Payment from '../models/Payment';
// Stable hex values: repeat a hex digit 64 times to fill 32 bytes
const hex64 = (c: string) => c.repeat(64);
const ZERO_PREIMAGE =
'0000000000000000000000000000000000000000000000000000000000000000';
/** Minimal snapshots used by NWC pay_invoice in-transit resolution */
type PaymentStateSnapshot = {
status: string | number | null;
isIncomplete: boolean | null;
error: boolean;
payment_error: string | null;
loading: boolean;
};
const INVOICE_A = 'lnbc1nwc-test-invoice-a';
const INVOICE_B = 'lnbc1nwc-test-invoice-b';
const HASH_A = hex64('c');
const HASH_B = hex64('d');
const paymentFixtures = {
/** LND-style: HTLC still in flight, no real preimage */
inFlightHtlc: () =>
new Payment({
payment_request: INVOICE_A,
payment_hash: HASH_A,
payment_preimage: ZERO_PREIMAGE,
htlcs: [{ status: 'IN_FLIGHT' }]
}),
/** LDK / list API: top-level IN_FLIGHT status without htlcs array */
inFlightStatus: () =>
new Payment({
payment_request: INVOICE_A,
payment_hash: HASH_A,
status: 'IN_FLIGHT',
payment_preimage: ZERO_PREIMAGE
}),
/** CLN-style: pending sendpay, incomplete */
pendingCln: () =>
new Payment({
payment_request: INVOICE_A,
payment_hash: HASH_A,
status: 'pending',
payment_preimage: ZERO_PREIMAGE
}),
settled: () =>
new Payment({
payment_request: INVOICE_A,
payment_hash: HASH_A,
status: 'SUCCEEDED',
payment_preimage: 'abc123preimage'
}),
failed: () =>
new Payment({
payment_request: INVOICE_A,
payment_hash: HASH_A,
status: 'FAILED',
payment_preimage: ZERO_PREIMAGE,
failure_reason: 'FAILURE_REASON_INCORRECT_PAYMENT_DETAILS',
htlcs: [{ status: 'FAILED' }]
}),
/** Another invoice — used to verify lookup does not cross-match */
otherInvoiceInFlight: () =>
new Payment({
payment_request: INVOICE_B,
payment_hash: HASH_B,
status: 'IN_FLIGHT',
payment_preimage: ZERO_PREIMAGE
})
};
const storeState = {
inFlight: (): PaymentStateSnapshot => ({
status: 'IN_FLIGHT',
isIncomplete: true,
error: false,
payment_error: null,
loading: false
}),
embeddedLndInFlight: (): PaymentStateSnapshot => ({
status: 1,
isIncomplete: true,
error: false,
payment_error: null,
loading: false
}),
incompleteNoError: (): PaymentStateSnapshot => ({
status: 'complete',
isIncomplete: true,
error: false,
payment_error: null,
loading: false
}),
timedOut: (): PaymentStateSnapshot => ({
status: 'complete',
isIncomplete: true,
error: true,
payment_error: 'views.SendingLightning.paymentTimedOut',
loading: false
}),
stillLoading: (): PaymentStateSnapshot => ({
status: null,
isIncomplete: null,
error: false,
payment_error: null,
loading: true
}),
settled: (): PaymentStateSnapshot => ({
status: 'SUCCEEDED',
isIncomplete: false,
error: false,
payment_error: null,
loading: false
}),
failed: (): PaymentStateSnapshot => ({
status: 'FAILED',
isIncomplete: true,
error: true,
payment_error: 'route not found',
loading: false
})
};
const PUBKEY = hex64('a');
const RELAY = 'wss://relay.example.com';
const SECRET = hex64('b');
@@ -310,4 +436,311 @@ describe('NostrConnectUtils', () => {
);
});
});
describe('lightning payment in-transit detection', () => {
describe('isInFlightPaymentStatus', () => {
it.each([
['IN_FLIGHT string', 'IN_FLIGHT', true],
['embedded-lnd enum', 1, true],
['stringified enum', '1', true],
['SUCCEEDED', 'SUCCEEDED', false],
['complete', 'complete', false],
['null', null, false],
['undefined', undefined, false]
])('%s → %s', (_label, status, expected) => {
expect(NostrConnectUtils.isInFlightPaymentStatus(status)).toBe(
expected
);
});
});
describe('isPaymentTimedOutMessage', () => {
it.each([
[
'locale key (SendingLightning)',
'views.SendingLightning.paymentTimedOut',
true
],
['locale key (error)', 'error.paymentTimedOut', true],
[
'lowercase phrase',
'payment timed out waiting for preimage',
true
],
['unrelated route error', 'route not found', false],
['null', null, false],
['empty string', '', false]
])('%s', (_label, message, expected) => {
expect(
NostrConnectUtils.isPaymentTimedOutMessage(message)
).toBe(expected);
});
});
describe('isSettledPayment', () => {
it('returns true when preimage is present and payment is not failed', () => {
expect(
NostrConnectUtils.isSettledPayment(
paymentFixtures.settled()
)
).toBe(true);
});
it('returns false for in-flight HTLC payment', () => {
expect(
NostrConnectUtils.isSettledPayment(
paymentFixtures.inFlightHtlc()
)
).toBe(false);
});
it('returns false for explicitly failed payment', () => {
expect(
NostrConnectUtils.isSettledPayment(paymentFixtures.failed())
).toBe(false);
});
});
describe('isListedPaymentInTransit', () => {
it.each([
['HTLC IN_FLIGHT', () => paymentFixtures.inFlightHtlc(), true],
[
'status IN_FLIGHT (LDK)',
() => paymentFixtures.inFlightStatus(),
true
],
[
'status pending (CLN)',
() => paymentFixtures.pendingCln(),
true
],
[
'settled with preimage',
() => paymentFixtures.settled(),
false
],
['failed HTLC', () => paymentFixtures.failed(), false]
])('%s', (_label, buildPayment, expected) => {
expect(
NostrConnectUtils.isListedPaymentInTransit(buildPayment())
).toBe(expected);
});
});
describe('findPaymentForInvoice', () => {
const payments = [
paymentFixtures.inFlightHtlc(),
paymentFixtures.otherInvoiceInFlight()
];
it('matches by bolt11 payment request', () => {
const found = NostrConnectUtils.findPaymentForInvoice(
INVOICE_A,
payments
);
expect(found?.getPaymentRequest).toBe(INVOICE_A);
});
it('matches by payment hash when invoice string is omitted', () => {
const found = NostrConnectUtils.findPaymentForInvoice(
'lnbc1unknown',
payments,
HASH_A
);
expect(found?.paymentHash).toBe(HASH_A);
});
it('returns undefined when neither invoice nor hash match', () => {
expect(
NostrConnectUtils.findPaymentForInvoice(
'lnbc1no-match',
payments,
hex64('f')
)
).toBeUndefined();
});
});
describe('findInTransitPaymentForInvoice', () => {
it('returns only in-transit payment for the requested invoice', () => {
const payments = [
paymentFixtures.settled(),
paymentFixtures.inFlightHtlc(),
paymentFixtures.otherInvoiceInFlight()
];
const found = NostrConnectUtils.findInTransitPaymentForInvoice(
INVOICE_A,
payments
);
expect(found?.getPaymentRequest).toBe(INVOICE_A);
expect(NostrConnectUtils.isListedPaymentInTransit(found!)).toBe(
true
);
});
it('ignores settled payment for the same invoice', () => {
const payments = [paymentFixtures.settled()];
expect(
NostrConnectUtils.findInTransitPaymentForInvoice(
INVOICE_A,
payments
)
).toBeUndefined();
});
it('matches in-transit payment by hash alone', () => {
const inFlight = paymentFixtures.inFlightStatus();
const found = NostrConnectUtils.findInTransitPaymentForInvoice(
'lnbc1different-encoding-same-hash',
[inFlight],
HASH_A
);
expect(found).toBe(inFlight);
});
});
describe('isTransactionsStorePaymentInTransit', () => {
it.each([
['IN_FLIGHT status', storeState.inFlight(), true],
[
'embedded-lnd numeric status',
storeState.embeddedLndInFlight(),
true
],
[
'incomplete without error (hodl: no preimage yet)',
storeState.incompleteNoError(),
true
],
['settled', storeState.settled(), false],
['hard failure with error', storeState.failed(), false]
])('%s', (_label, state, expected) => {
expect(
NostrConnectUtils.isTransactionsStorePaymentInTransit(state)
).toBe(expected);
});
it('returns false when incomplete but payment_error is set', () => {
expect(
NostrConnectUtils.isTransactionsStorePaymentInTransit({
...storeState.incompleteNoError(),
payment_error: 'views.SendingLightning.paymentTimedOut'
})
).toBe(false);
});
it('returns false when incomplete but still loading', () => {
expect(
NostrConnectUtils.isTransactionsStorePaymentInTransit({
...storeState.incompleteNoError(),
loading: true
})
).toBe(false);
});
});
describe('resolveLightningPaymentInTransit', () => {
const resolve = (params: {
invoice?: string;
payments?: Payment[];
paymentHash?: string | null;
paymentState: PaymentStateSnapshot;
}) =>
NostrConnectUtils.resolveLightningPaymentInTransit({
invoice: params.invoice ?? INVOICE_A,
payments: params.payments ?? [],
paymentHash: params.paymentHash,
paymentState: params.paymentState
});
describe('when transactions store reports in-flight', () => {
it('returns inTransit with matching listed payment', () => {
const inFlight = paymentFixtures.inFlightHtlc();
const result = resolve({
payments: [inFlight],
paymentState: storeState.inFlight()
});
expect(result).toEqual({
inTransit: true,
payment: inFlight
});
});
it('returns inTransit even when payment not yet in list', () => {
const result = resolve({
payments: [],
paymentState: storeState.inFlight()
});
expect(result.inTransit).toBe(true);
expect(result.payment).toBeUndefined();
});
it('returns not inTransit when list already shows settlement (race)', () => {
const settled = paymentFixtures.settled();
const result = resolve({
payments: [settled],
paymentState: storeState.inFlight()
});
expect(result).toEqual({
inTransit: false,
payment: settled
});
});
});
describe('when store timed out or is still loading', () => {
it('finds in-transit payment after router timeout message', () => {
const inFlight = paymentFixtures.inFlightStatus();
const result = resolve({
payments: [inFlight],
paymentState: storeState.timedOut()
});
expect(result).toEqual({
inTransit: true,
payment: inFlight
});
});
it('finds in-transit payment while send is still loading', () => {
const inFlight = paymentFixtures.pendingCln();
const result = resolve({
payments: [inFlight],
paymentState: storeState.stillLoading()
});
expect(result).toEqual({
inTransit: true,
payment: inFlight
});
});
it('returns not inTransit when timeout fired but nothing is in flight', () => {
const result = resolve({
payments: [paymentFixtures.settled()],
paymentState: storeState.timedOut()
});
expect(result).toEqual({ inTransit: false });
});
});
describe('when store shows settled failure (no false positive)', () => {
it('does not treat a genuine failure as in-transit', () => {
const result = resolve({
payments: [paymentFixtures.failed()],
paymentState: storeState.failed()
});
expect(result).toEqual({ inTransit: false });
});
});
});
});
});
+2
View File
@@ -870,12 +870,14 @@ export default class NostrConnectUtils {
static isInFlightPaymentStatus(
status: string | number | null | undefined
): boolean {
// 1 / '1' = lnrpc.Payment.PaymentStatus.IN_FLIGHT (embedded-lnd enum + stringified)
return status === 'IN_FLIGHT' || status === 1 || status === '1';
}
static isPaymentTimedOutMessage(message?: string | null): boolean {
if (!message) return false;
const normalized = message.toLowerCase();
// English substring catches raw backend/LND strings; localized paths use localeString equality below.
return (
normalized.includes('timed out') ||
message ===