aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app.js54
-rw-r--r--src/i18n.js9
-rw-r--r--src/swap.js28
3 files changed, 87 insertions, 4 deletions
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;
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;