diff --git a/src/index.html b/src/index.html index d4966bb..3d8caa4 100644 --- a/src/index.html +++ b/src/index.html @@ -6660,6 +6660,7 @@ // localStorage so the user only pastes the connection string once. const NWC_STORAGE_KEY = 'casewrap-nwc-connection'; const NWC_DEFAULT_TIMEOUT_MS = 25000; + const NWC_PAY_INVOICE_TIMEOUT_MS = 60000; const nwc = (() => { let _conn = null; // { servicePubkey, relays, secret, clientPubkey, lud16 } once connected let _ws = null; // active WebSocket @@ -6886,12 +6887,14 @@ _emit(); } - async function payInvoice(bolt11) { - return _request('pay_invoice', { invoice: bolt11 }); + async function payInvoice(bolt11, opts = {}) { + return _request('pay_invoice', { invoice: bolt11 }, { timeoutMs: opts.timeoutMs || NWC_PAY_INVOICE_TIMEOUT_MS }); } - async function lookupInvoice(paymentHash) { - return _request('lookup_invoice', { payment_hash: paymentHash }); + async function lookupInvoice(invoiceOrHash) { + const value = String(invoiceOrHash || '').trim(); + const params = /^ln/i.test(value) ? { invoice: value } : { payment_hash: value }; + return _request('lookup_invoice', params); } async function getInfoFresh() { @@ -7314,10 +7317,27 @@ } } onStatus(`Paying ${label}…`); - const result = await nwc.payInvoice(leg.bolt11); - if (!result?.preimage) throw new Error(`${label} payment returned no preimage`); - partial[side] = { ...leg, preimage: result.preimage }; - savePartialUnlock(eventId, partial); + try { + const result = await nwc.payInvoice(leg.bolt11); + if (!result?.preimage) throw new Error(`${label} payment returned no preimage`); + 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 { @@ -7699,11 +7719,6 @@ 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.` : ''; - const authorProfile = _feedCache.profiles?.[row.e?.pubkey] || null; - const creatorMeta = { - lud16: authorProfile?.lud16, - lud06: authorProfile?.lud06, - }; const ok = confirm( `${partialSummary.status === 'none' ? 'Unlock' : 'Resume unlock for'} "${row.title || '(untitled)'}" for ${min} sats?\n\n` + `• Creator (${shortPubkey(row.e.pubkey)}): ${creatorSats} sats\n` + @@ -7718,6 +7733,17 @@ statusToast = toast(msg, { duration: 60000 }); }; 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({ creatorPubkey: row.e.pubkey, creatorMetadata: creatorMeta, @@ -7843,7 +7869,10 @@ } } catch (err) { 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)` : ''; toastError(`${msg}${stage}`); } diff --git a/tests/premium/premium2-index-integration.test.mjs b/tests/premium/premium2-index-integration.test.mjs index c69f7b5..5b54abd 100644 --- a/tests/premium/premium2-index-integration.test.mjs +++ b/tests/premium/premium2-index-integration.test.mjs @@ -105,6 +105,29 @@ test('index page exposes partial-payment resume state without discarding paid le 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 () => { const html = await indexHtml();