diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.js | 54 | ||||
| -rw-r--r-- | src/i18n.js | 9 | ||||
| -rw-r--r-- | src/swap.js | 28 |
3 files changed, 87 insertions, 4 deletions
@@ -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; diff --git a/src/i18n.js b/src/i18n.js index 041b0be..72d2013 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -356,6 +356,15 @@ const STR = { lnCreateInvoice: 'Create invoice', copyInvoice: 'Copy invoice', amountSatsLabel: 'Amount (sats)', + lnQuoting: 'Getting a quote…', + lnPayTitle: 'Pay over Lightning', + lnPayAmount: 'Amount', + lnPayBoltzFee: 'Boltz fee', + lnPayNetworkFee: 'Network fee', + lnPayTotal: 'Total', + lnPayConfirm: 'Pay', + lnPaySentTitle: 'Lightning payment sent', + lnPaySentBody: 'Boltz pays the invoice once the on-chain lockup confirms.', spScan: 'Scan for silent payments', spScanning: 'Scanning…', spWatching: 'Watching for payments', diff --git a/src/swap.js b/src/swap.js index 81336f4..1e56223 100644 --- a/src/swap.js +++ b/src/swap.js @@ -312,6 +312,14 @@ export class SwapManager { // SUBMARINE: hal spends over Lightning. Funds the lockup from the wallet; // Boltz pays the invoice + claims. On failure, refund after the timeout. async startSubmarine(invoice) { + return this.fundQuotedSubmarine(await this.quoteSubmarine(invoice)); + } + + // Quote a submarine swap WITHOUT committing funds: ask Boltz for the lockup + // amount + build (but don't broadcast) the funding tx, so the UI can show an + // itemized cost (amount + Boltz fee + on-chain network fee) before the user + // pays. Returns everything fundQuotedSubmarine() needs. + async quoteSubmarine(invoice) { const w = this.wallet, net = this.network; const dec = decodeBolt11(invoice); const idx = w.nextSwapIndex(); @@ -319,11 +327,23 @@ export class SwapManager { const res = await boltzReq(this.getApi(), '/v2/swap/submarine', { invoice, from: 'BTC', to: 'BTC', refundPublicKey: bytesToHex(node.publicKey) }); const d = swapP2TR({ kind: 'submarine', preimageHash160: ripemd160(dec.paymentHash), boltzPub33: hexToBytes(res.claimPublicKey), halPub33: node.publicKey, lockTime: res.timeoutBlockHeight, network: net }); if (d.address !== res.address) throw new Error('lockup address mismatch — not funding'); - const rec = { id: res.id, kind: 'submarine', swapIndex: idx, status: 'funding', network: net, paymentHash: bytesToHex(dec.paymentHash), lockupAddress: res.address, expectedAmount: res.expectedAmount, claimPublicKey: res.claimPublicKey, timeoutBlockHeight: res.timeoutBlockHeight, createdAt: Date.now() }; - this._save(rec); const draft = w.buildTx({ recipients: [{ address: res.address, amount: res.expectedAmount }], feeRate: this.feeRate }); - const fundTxid = await w.broadcast(w.sign(draft.tx)); - if (w.applySentTx) w.applySentTx(draft.tx); + const invoiceAmount = dec.amountMsat ? Number(dec.amountMsat / 1000n) : null; + return { + invoice, id: res.id, swapIndex: idx, lockupAddress: res.address, expectedAmount: res.expectedAmount, + claimPublicKey: res.claimPublicKey, timeoutBlockHeight: res.timeoutBlockHeight, paymentHash: bytesToHex(dec.paymentHash), + invoiceAmount, networkFee: draft.fee, boltzFee: invoiceAmount != null ? res.expectedAmount - invoiceAmount : null, + total: res.expectedAmount + draft.fee, _draft: draft, + }; + } + + // Broadcast the funding tx for a previously-quoted submarine swap + watch it. + async fundQuotedSubmarine(q) { + const w = this.wallet, net = this.network; + const rec = { id: q.id, kind: 'submarine', swapIndex: q.swapIndex, status: 'funding', network: net, paymentHash: q.paymentHash, lockupAddress: q.lockupAddress, expectedAmount: q.expectedAmount, claimPublicKey: q.claimPublicKey, timeoutBlockHeight: q.timeoutBlockHeight, invoiceAmount: q.invoiceAmount, createdAt: Date.now() }; + this._save(rec); + const fundTxid = await w.broadcast(w.sign(q._draft.tx)); + if (w.applySentTx) w.applySentTx(q._draft.tx); rec.fundTxid = fundTxid; rec.status = 'paying'; this._save(rec); this._watchSubmarine(rec.id); return rec; |
