Harden NWC split-zap timeout recovery

This commit is contained in:
thePR0M3TH3AN committed 2026-07-04 16:08:28 -04:00
1 parent 0729bce549
commit bc35dd2cff
2 files changed
+66 -14

No files matched your search

+43 -14
View File
@@ -6660,6 +6660,7 @@
// localStorage so the user only pastes the connection string once. // localStorage so the user only pastes the connection string once.
const NWC_STORAGE_KEY = 'casewrap-nwc-connection'; const NWC_STORAGE_KEY = 'casewrap-nwc-connection';
const NWC_DEFAULT_TIMEOUT_MS = 25000; const NWC_DEFAULT_TIMEOUT_MS = 25000;
const NWC_PAY_INVOICE_TIMEOUT_MS = 60000;
const nwc = (() => { const nwc = (() => {
let _conn = null; // { servicePubkey, relays, secret, clientPubkey, lud16 } once connected let _conn = null; // { servicePubkey, relays, secret, clientPubkey, lud16 } once connected
let _ws = null; // active WebSocket let _ws = null; // active WebSocket
@@ -6886,12 +6887,14 @@
_emit(); _emit();
} }
async function payInvoice(bolt11) { async function payInvoice(bolt11, opts = {}) {
return _request('pay_invoice', { invoice: bolt11 }); return _request('pay_invoice', { invoice: bolt11 }, { timeoutMs: opts.timeoutMs || NWC_PAY_INVOICE_TIMEOUT_MS });
} }
async function lookupInvoice(paymentHash) { async function lookupInvoice(invoiceOrHash) {
return _request('lookup_invoice', { payment_hash: paymentHash }); const value = String(invoiceOrHash || '').trim();
const params = /^ln/i.test(value) ? { invoice: value } : { payment_hash: value };
return _request('lookup_invoice', params);
} }
async function getInfoFresh() { async function getInfoFresh() {
@@ -7314,10 +7317,27 @@
} }
} }
onStatus(`Paying ${label}…`); onStatus(`Paying ${label}…`);
const result = await nwc.payInvoice(leg.bolt11); try {
if (!result?.preimage) throw new Error(`${label} payment returned no preimage`); const result = await nwc.payInvoice(leg.bolt11);
partial[side] = { ...leg, preimage: result.preimage }; if (!result?.preimage) throw new Error(`${label} payment returned no preimage`);
savePartialUnlock(eventId, partial); partial[side] = { ...leg, preimage: result.preimage };
savePartialUnlock(eventId, partial);
} catch (err) {
if (err?.code === 'TIMEOUT' && leg?.bolt11) {
onStatus(`Checking whether ${label} settled…`);
try {
const postTimeoutLookup = await nwc.lookupInvoice(leg.bolt11);
if (postTimeoutLookup?.preimage) {
partial[side] = { ...leg, preimage: postTimeoutLookup.preimage };
savePartialUnlock(eventId, partial);
return;
}
} catch (lookupErr) {
console.warn('[nwc] post-timeout lookup_invoice failed', lookupErr);
}
}
throw err;
}
} }
try { try {
@@ -7699,11 +7719,6 @@
const resumeNote = partialSummary.status !== 'none' const resumeNote = partialSummary.status !== 'none'
? `\n\nResume detected: ${partialSummary.confirmed} payment leg${partialSummary.confirmed === 1 ? '' : 's'} confirmed, ${partialSummary.missing} left. Artstr will reuse cached invoices/preimages and only pay missing legs.` ? `\n\nResume detected: ${partialSummary.confirmed} payment leg${partialSummary.confirmed === 1 ? '' : 's'} confirmed, ${partialSummary.missing} left. Artstr will reuse cached invoices/preimages and only pay missing legs.`
: ''; : '';
const authorProfile = _feedCache.profiles?.[row.e?.pubkey] || null;
const creatorMeta = {
lud16: authorProfile?.lud16,
lud06: authorProfile?.lud06,
};
const ok = confirm( const ok = confirm(
`${partialSummary.status === 'none' ? 'Unlock' : 'Resume unlock for'} "${row.title || '(untitled)'}" for ${min} sats?\n\n` + `${partialSummary.status === 'none' ? 'Unlock' : 'Resume unlock for'} "${row.title || '(untitled)'}" for ${min} sats?\n\n` +
`• Creator (${shortPubkey(row.e.pubkey)}): ${creatorSats} sats\n` + `• Creator (${shortPubkey(row.e.pubkey)}): ${creatorSats} sats\n` +
@@ -7718,6 +7733,17 @@
statusToast = toast(msg, { duration: 60000 }); statusToast = toast(msg, { duration: 60000 });
}; };
try { try {
let authorProfile = _feedCache.profiles?.[row.e?.pubkey]
|| state.community?.profiles?.[row.e?.pubkey]
|| null;
if (!resolveLnurlPayUrl(authorProfile)) {
onStatus('Checking creator Lightning address…');
authorProfile = await ensureProfilePageData(row.e.pubkey, row.e.tags?.filter(t => t[0] === 'r' && t[1]).map(t => t[1]) || []);
}
const creatorMeta = {
lud16: authorProfile?.lud16,
lud06: authorProfile?.lud06,
};
const result = await payZapSplit({ const result = await payZapSplit({
creatorPubkey: row.e.pubkey, creatorPubkey: row.e.pubkey,
creatorMetadata: creatorMeta, creatorMetadata: creatorMeta,
@@ -7843,7 +7869,10 @@
} }
} catch (err) { } catch (err) {
if (statusToast?.dismiss) statusToast.dismiss(); if (statusToast?.dismiss) statusToast.dismiss();
const msg = err?.message || 'Unlock failed.'; let msg = err?.message || 'Unlock failed.';
if (err?.code === 'TIMEOUT') {
msg += ' If one payment leg completed, click Resume payment; Artstr will check cached invoices before paying again.';
}
const stage = err?.stage ? ` (${err.stage} leg)` : ''; const stage = err?.stage ? ` (${err.stage} leg)` : '';
toastError(`${msg}${stage}`); toastError(`${msg}${stage}`);
} }
@@ -105,6 +105,29 @@ test('index page exposes partial-payment resume state without discarding paid le
assert.match(html, /const stalePartialCleared = clearStalePartialUnlocks\(\)/); assert.match(html, /const stalePartialCleared = clearStalePartialUnlocks\(\)/);
}); });
test('index page recovers NWC split-zap legs after wallet timeouts', async () => {
const html = await indexHtml();
assert.match(html, /const NWC_PAY_INVOICE_TIMEOUT_MS = 60000/);
assert.match(html, /return _request\('pay_invoice', \{ invoice: bolt11 \}, \{ timeoutMs: opts\.timeoutMs \|\| NWC_PAY_INVOICE_TIMEOUT_MS \}\)/);
assert.ok(html.includes('const params = /^ln/i.test(value) ? { invoice: value } : { payment_hash: value };'));
assert.match(html, /const postTimeoutLookup = await nwc\.lookupInvoice\(leg\.bolt11\)/);
assert.match(html, /if \(postTimeoutLookup\?\.preimage\)/);
assert.match(html, /preimage: postTimeoutLookup\.preimage/);
assert.match(html, /If one payment leg completed, click Resume payment/);
});
test('index page refreshes creator Lightning metadata before premium unlock payment', async () => {
const html = await indexHtml();
assert.ok(html.includes('let authorProfile = _feedCache.profiles?.[row.e?.pubkey]'));
assert.ok(html.includes('|| state.community?.profiles?.[row.e?.pubkey]'));
assert.match(html, /if \(!resolveLnurlPayUrl\(authorProfile\)\)/);
assert.match(html, /authorProfile = await ensureProfilePageData\(row\.e\.pubkey/);
assert.match(html, /Checking creator Lightning address/);
assert.match(html, /lud06: authorProfile\?\.lud06/);
});
test('index page migrates legacy inline vault purchases to private copies', async () => { test('index page migrates legacy inline vault purchases to private copies', async () => {
const html = await indexHtml(); const html = await indexHtml();