diff options
| author | Adam Soltys <[email protected]> | 2026-07-30 08:43:47 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-07-30 08:43:47 -0700 |
| commit | 6713f8d4e55db8d5344684b08b062cc9129ef6b0 (patch) | |
| tree | 2e547aedcef6b4c7f8de8e82a561a62c6781ce54 /src/features/swaps.js | |
| parent | 962223b0628133ebcd27ff6c89d6aa616a936142 (diff) | |
| download | coinosv3-6713f8d4e55db8d5344684b08b062cc9129ef6b0.tar.gz coinosv3-6713f8d4e55db8d5344684b08b062cc9129ef6b0.zip | |
Add NIP-57 Lightning zaps (npub / lightning address / lnurl)
Send a Lightning zap to a nostr npub, a lightning address (name@domain),
or an lnurl. New src/lnurl.js does the resolution half — target → LNURL-pay
params → signed kind-9734 zap request → callback → bolt11 — and the new
zaps feature (src/features/zaps.js) drives the amount/comment UI, then hands
the invoice to the existing Boltz submarine-swap flow via a new
swaps.startLnPay hook, so there's one tested pay/review/confirm/success path.
Routing: a bolt11 is still caught by swaps; an npub still prefers an instant
free Ark zap when Ark can serve it, and only falls through to a Lightning
zap otherwise — ark's "no Ark address" screen also offers an explicit
"Zap over Lightning" fallback (gated on the zaps feature's canLnZap). A real
zap receipt is attributed only when the server advertises allowsNostr and we
know the recipient's pubkey; otherwise it degrades to a plain LNURL-pay.
Nostr seam gains nostrSign (finalize without publishing), nostrProfile
(kind-0 with lud16/lud06), and nostrRelays. Lightning addresses are
debounced in matchSendText since their regex matches mid-typing.
Verified: unit tests for parse/encode/zap-request; a live resolve→signed
9734→payable mainnet bolt11 against coinos; and an in-browser DOM smoke
(zap form + LNURL params over CORS + allowsNostr hint + handoff).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat (limited to 'src/features/swaps.js')
| -rw-r--r-- | src/features/swaps.js | 27 |
1 files changed, 20 insertions, 7 deletions
diff --git a/src/features/swaps.js b/src/features/swaps.js index 3e97412..091ca32 100644 --- a/src/features/swaps.js +++ b/src/features/swaps.js @@ -132,8 +132,8 @@ export function swapsFeature(ctx) { line(t('status'), swap.status === 'claimed' ? 'Settled ✓' : swap.status)); } - async function startLnSend(invoice) { - ui.sendError = ''; ui.lnSend = null; ui.lnSent = null; ui.lnSendBusy = true; render(); + async function startLnSend(invoice, meta = null) { + ui.sendError = ''; ui.lnSend = null; ui.lnSent = null; ui.lnPayMeta = meta; ui.lnSendBusy = true; render(); try { ui.lnSend = await swaps.quoteSubmarine(invoice); } catch (e) { ui.sendError = e.message; } ui.lnSendBusy = false; render(); @@ -149,21 +149,25 @@ export function swapsFeature(ctx) { function lnSendReview() { const u = ' ' + unitLabel(); + const zap = ui.lnPayMeta; // set when a NIP-57 zap routed through here 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')), + h('h2', { style: 'margin:0' }, zap ? t('arkZapSentTitle') : t('lnPaySentTitle')), + zap && zap.name ? h('div', { class: 'small muted' }, zap.name) : null, 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'))); + h('p', { class: 'muted', style: 'margin:0' }, zap ? t('lnZapSentBody') : t('lnPaySentBody'))), + h('button', { class: 'btn-primary btn-block', onClick: () => { ui.lnSent = null; ui.lnSend = null; ui.lnPayMeta = 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')), + h('h3', { style: 'margin:0' }, zap ? '⚡ ' + t('lnZapReviewTitle') : t('lnPayTitle')), + zap && (zap.name || zap.address) ? h('div', { class: 'small muted', style: 'word-break:break-all' }, zap.name || zap.address) : null, + zap && zap.comment ? h('div', { class: 'small faint', style: 'font-style:italic;word-break:break-word' }, '“' + zap.comment + '”') : null, q.invoiceAmount != null ? row(t('lnPayAmount'), q.invoiceAmount) : null, q.boltzFee != null ? row(t('lnPayBoltzFee'), q.boltzFee) : null, row(t('lnPayNetworkFee'), q.networkFee), @@ -171,7 +175,7 @@ export function swapsFeature(ctx) { 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'))); + h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.lnSend = null; ui.lnPayMeta = null; ui.sendError = ''; render(); } }, t('back'))); } return { @@ -215,5 +219,14 @@ export function swapsFeature(ctx) { }, findByTxid(txid) { return swaps.findByTxid(txid); }, settingsCards() { return [boltzProviderCard()]; }, + // ---- cross-feature seam (the zaps feature pays a resolved bolt11) ---- + // True when Lightning payment is possible from this wallet/build — the + // zaps feature checks this before offering a Lightning zap. + canLnPay() { return !wallet.watchOnly; }, + // Pay a bolt11 that another feature resolved (e.g. a NIP-57 zap), reusing + // this feature's quote → review → confirm → success flow. `meta` decorates + // the review as a zap (recipient name, comment). Returns true so the hook + // dispatcher stops here. + startLnPay(invoice, meta) { startLnSend(invoice, meta || null); return true; }, }; } |
