fix(nwc): debounce pending pay_invoice refresh and harden in-transit handling
This commit is contained in:
@@ -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] = {
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 ===
|
||||
|
||||
Reference in New Issue
Block a user