From 0baf4cf55daa1ed33c2b276f40a6fd8aa922ee34 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 29 Jun 2026 07:09:15 -0700 Subject: Send: pay a pasted Lightning invoice via an auto submarine swap (itemized cost) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Paste a bolt11 into the normal Send recipient field and Review now detects it and routes through a Boltz submarine swap — no 'swap' concept surfaced. Split startSubmarine into quoteSubmarine (POST + build-but-don't-broadcast the funding tx) and fundQuotedSubmarine, so the review shows an itemized cost before committing: Amount + Boltz fee + on-chain network fee = Total. Pay funds the lockup; Boltz pays the invoice once it confirms. Verified on regtest: a 60,000-sat invoice quotes to Amount 60,000 / Boltz 362 / network 442 / Total 60,804, with a Pay button. Lightning-address (LNURL) input + removing the standalone Swap tab are the remaining pieces. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app.js | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) (limited to 'src/app.js') diff --git a/src/app.js b/src/app.js index 54953be..6433878 100644 --- a/src/app.js +++ b/src/app.js @@ -2834,6 +2834,8 @@ function sendTab() { } if (ui.sendResult) return sendResultView(); if (ui.broadcastTx) return broadcastConfirmView(); + if (ui.lnSent || ui.lnSend) return lnSendReview(); + if (ui.lnSendBusy) return h('div', { class: 'card col', style: 'align-items:center;gap:14px;padding:32px 14px' }, h('span', { class: 'spinner' }), h('p', { class: 'muted', style: 'margin:0' }, t('lnQuoting'))); if (ui.draft) return reviewView(); if (ui.giftMode) return giftView(); return sendForm(); @@ -3282,6 +3284,10 @@ function reviewSend() { ui.sendError = ''; try { const s = ui.send; + // A bolt11 invoice in the recipient field → pay it over Lightning via a Boltz + // submarine swap, shown with an itemized cost. To the user it's just a payment. + const lnTarget = s.recipients.length === 1 ? (s.recipients[0].address || '').trim().replace(/^lightning:/i, '') : ''; + if (isLnInvoice(lnTarget)) { startLnSend(lnTarget); return; } const feeRate = currentFeeRate(); let coinIds = null; if (s.manual) { @@ -3311,6 +3317,54 @@ function reviewSend() { render(); } +// A bolt11 Lightning invoice (any network prefix); long ln-prefixed bech32 string. +function isLnInvoice(t) { t = (t || '').trim(); return /^ln[a-z0-9]+$/i.test(t) && t.length > 50; } + +// Quote a submarine swap for a pasted invoice, then show the itemized cost. +async function startLnSend(invoice) { + ui.sendError = ''; ui.lnSend = null; ui.lnSent = null; ui.lnSendBusy = true; render(); + try { ui.lnSend = await swaps.quoteSubmarine(invoice); } + catch (e) { ui.sendError = e.message; } + ui.lnSendBusy = false; render(); +} + +async function doLnPay() { + if (!ui.lnSend) return; + ui.sendError = ''; ui.lnSendBusy = true; render(); + try { const rec = await swaps.fundQuotedSubmarine(ui.lnSend); ui.lnSent = { amount: ui.lnSend.invoiceAmount, id: rec.id }; } + catch (e) { ui.sendError = e.message; } + ui.lnSendBusy = false; render(); +} + +// Lightning send review: itemized cost (amount + Boltz fee + network fee → total), +// then a success card once funded. Boltz pays the invoice after the lockup confirms. +function lnSendReview() { + const u = ' ' + unitLabel(); + if (ui.lnSent) { + return h('div', { class: 'col', style: 'gap:16px' }, + h('div', { class: 'card col', style: 'align-items:center;text-align:center;gap:8px' }, + h('div', { class: 'check-badge' }, '⚡'), + h('h2', { style: 'margin:0' }, t('lnPaySentTitle')), + ui.lnSent.amount ? h('div', { class: 'amount-pos', style: 'font-size:18px' }, fmtAmount(ui.lnSent.amount) + u) : null, + h('p', { class: 'muted', style: 'margin:0' }, t('lnPaySentBody'))), + h('button', { class: 'btn-primary btn-block', onClick: () => { ui.lnSent = null; ui.lnSend = null; ui.send = blankSend(); ui.tab = 'history'; render(); } }, t('done'))); + } + const q = ui.lnSend; + const row = (label, sats, bold) => h('div', { class: 'row', style: 'justify-content:space-between' + (bold ? ';font-weight:600' : '') }, + h('span', { class: bold ? '' : 'small muted' }, label), h('span', {}, fmtAmount(sats) + u)); + return h('div', { class: 'col', style: 'gap:12px' }, + h('div', { class: 'card col', style: 'gap:10px' }, + h('h3', { style: 'margin:0' }, t('lnPayTitle')), + q.invoiceAmount != null ? row(t('lnPayAmount'), q.invoiceAmount) : null, + q.boltzFee != null ? row(t('lnPayBoltzFee'), q.boltzFee) : null, + row(t('lnPayNetworkFee'), q.networkFee), + h('div', { style: 'border-top:1px solid var(--line, #ddd);margin:2px 0' }), + row(t('lnPayTotal'), q.total, true)), + ui.sendError ? h('div', { class: 'notice err' }, ui.sendError) : null, + h('button', { class: 'btn-primary btn-block', disabled: !!ui.lnSendBusy, onClick: doLnPay }, ui.lnSendBusy ? h('span', { class: 'spinner' }) : t('lnPayConfirm')), + h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.lnSend = null; ui.sendError = ''; render(); } }, t('back'))); +} + function reviewView() { const d = ui.draft; const changeAddr = wallet.freshChange().address; -- cgit v1.2.3