diff options
Diffstat (limited to 'src/features')
| -rw-r--r-- | src/features/ark.js | 14 | ||||
| -rw-r--r-- | src/features/names.js | 101 | ||||
| -rw-r--r-- | src/features/zaps.js | 55 |
3 files changed, 126 insertions, 44 deletions
diff --git a/src/features/ark.js b/src/features/ark.js index cd4fa47..291ae92 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -9,7 +9,7 @@ import { sha256 } from '@noble/hashes/sha256'; import { ArkManager } from '../ark/manager.js'; import { loadBg, saveBg, buildBg, disarmSiblingRecords } from '../nwc-bg.js'; import { boardFee } from '../ark/board.js'; -import { maybeBolt11, lnSendFee } from '../ark/lightning.js'; +import { maybeBolt11, maybeLnInvoice, lnSendFee } from '../ark/lightning.js'; import { decodeVtxo, getVtxoStatus, VTXO_STATE_SPENT, concatBytes } from '../ark/proto.js'; import { signedExitTxs, buildBumpChild, buildExitClaim, submitPackage } from '../ark/exit.js'; import { utxoId } from '../wallet.js'; @@ -662,7 +662,7 @@ export function arkFeature(ctx) { // directly when swaps is absent): take over when the ark balance can // plausibly cover it, else decline so the Boltz path handles it. function startArkLnPay(invoice, meta) { - const dec = maybeBolt11(invoice); + const dec = maybeLnInvoice(invoice); if (!dec || !arkAvailable() || wallet.watchOnly) return false; const s = arkStateNow(); const covers = (state, sats) => @@ -2431,7 +2431,7 @@ export function arkFeature(ctx) { interceptReview(s) { if (s.recipients.length === 1) { const inv = (s.recipients[0].address || '').trim().replace(/^lightning:/i, ''); - if (maybeBolt11(inv) && startArkLnPay(inv)) return true; + if (maybeLnInvoice(inv) && startArkLnPay(inv)) return true; } if (s.recipients.length === 1 && isArkAddress(s.recipients[0].address)) { if (!arkAvailable()) throw new Error(t('arkNotConnected')); @@ -2457,6 +2457,12 @@ export function arkFeature(ctx) { // The zaps feature's generic Lightning seams (once served by the retired // swaps feature): the ASP pays invoices natively over ark. canLnPay() { return arkAvailable() && !wallet.watchOnly && !!(ark && ark.info); }, + // Fetch + verify a bolt12 invoice for an offer (the ASP's CLN speaks the + // onion messages) — resolves { invoice (lni), amountSat, paymentHash }. + async arkFetchBolt12Invoice(offerStr, amountSat) { + const mgr = await connectArk(); + return mgr.fetchBolt12(offerStr, amountSat); + }, lnSpendableSat() { const b = arkBalance(); return b ? b.spendableSat : 0; }, settingsCards() { return [autoWithdrawCard()]; }, // The onboarding wizard's top-up step borrows the board form wholesale. @@ -2546,7 +2552,7 @@ export function arkFeature(ctx) { return true; } } - if (maybeBolt11(inv)) return startArkLnPay(inv); + if (maybeLnInvoice(inv)) return startArkLnPay(inv); const pk = npubToHex(text); if (!pk || !arkAvailable() || !wallet.nostrFetch) return false; startNpubPay(pk, String(text).trim()); diff --git a/src/features/names.js b/src/features/names.js index 2c69a84..ff43089 100644 --- a/src/features/names.js +++ b/src/features/names.js @@ -89,12 +89,14 @@ export function namesFeature(ctx) { return addr ? `bitcoin:?ark=${encodeURIComponent(addr)}` : null; } - async function claim(name, { signer, manager, quietProfile, noffer } = {}) { + async function claim(name, { signer, manager, quietProfile, noffer, lno } = {}) { const uri = await currentUri(); if (!uri) throw new Error(t('namesNeedArk')); - // Delivery noffer: undefined keeps whatever the record carries (here or - // registrar-side), '' clears it, a string points delivery at that code. + // Own-node fields (delivery noffer / published BOLT 12 offer): undefined + // keeps whatever the record carries (here or registrar-side), '' clears, + // a string sets. const nof = noffer !== undefined ? noffer : (load().noffer || undefined); + const ownLno = lno !== undefined ? lno : (load().lno || undefined); const j = await post('/register', 'POST', { name, uri, domain: DOMAIN(), // lets the address take Lightning payments from LNURL-only wallets: @@ -104,9 +106,11 @@ export function namesFeature(ctx) { // wallet key to keep the record updated afterwards ...(manager ? { manager } : {}), ...(nof !== undefined ? { noffer: nof } : {}), + ...(ownLno !== undefined ? { lno: ownLno } : {}), }, signer); const prev = load().name; - save({ ...load(), name, domain: DOMAIN(), uri, offerPk: hook('nwcOfferPubkey') || null, noffer: nof || null, updated: Date.now() }); + save({ ...load(), name, domain: DOMAIN(), uri, offerPk: hook('nwcOfferPubkey') || null, + noffer: nof || null, lno: ownLno || null, updated: Date.now() }); // Releasing the previous name is for renames the user meant. A CUSTOM // name must never be released because a placeholder claim raced in: that // exact race once deleted a user's claimed name — adoptIdentity's npub @@ -143,7 +147,8 @@ export function namesFeature(ctx) { const r = await withTimeout( fetch(`${REGISTRAR}/pubkey/${pk}?domain=${encodeURIComponent(DOMAIN())}`).then((x) => x.json()), 12000, 'registrar'); if (r && r.name) save({ ...load(), name: r.name, domain: r.domain || DOMAIN(), uri: r.uri, - ...(r.noffer !== undefined ? { noffer: r.noffer } : {}) }); + ...(r.noffer !== undefined ? { noffer: r.noffer } : {}), + ...(r.lno !== undefined ? { lno: r.lno } : {}) }); } catch (e) { console.warn('names: lookup failed —', e.message); } } @@ -184,7 +189,8 @@ export function namesFeature(ctx) { 8000, 'registrar'); if (r && r.name && !/^npub1/.test(r.name)) { save({ ...st, name: r.name, domain: r.domain || DOMAIN(), uri: r.uri, - ...(r.noffer !== undefined ? { noffer: r.noffer } : {}) }); + ...(r.noffer !== undefined ? { noffer: r.noffer } : {}), + ...(r.lno !== undefined ? { lno: r.lno } : {}) }); st = load(); } } catch {} @@ -318,6 +324,12 @@ export function namesFeature(ctx) { render(); return; } + // no payable ark instruction: a bolt12 offer (a foreign node, or a + // name delivered to its owner's own node) beats falling to on-chain + if (dec?.params?.lno && hook('startBolt12Pay', dec.params.lno, `${parsed.name}@${parsed.domain}`)) { + render(); + return; + } if (dec?.onchain) { ui.send.recipients[0].address = dec.onchain; render(); @@ -589,50 +601,61 @@ export function namesFeature(ctx) { copyBtn(st.address, t('copy'))); } - // Point the address's Lightning delivery at the user's own node: any - // CLINK-capable service beside their node (Lightning.Pub etc.) hands out - // its own noffer; registered here, the registrar asks THAT service for - // invoices, so payments land on their node and never touch this wallet. - async function setDeliveryNoffer(raw) { + // Point the address at the user's own node, two complementary ways: + // - noffer: a CLINK service beside their node (Lightning.Pub etc.) mints + // the invoices the registrar hands out and pays — LN delivery lands on + // their node instead of in this wallet. + // - lno: their node's own BOLT 12 offer goes into the DNS record, so + // BIP-353 senders fetch invoices straight from their node — no coinos + // hop at all. + async function setOwnNode(field, raw) { const st = load(); - if (!st.name) { ui.namesNofferError = t('namesNeedArk'); render(); return; } - ui.namesNofferBusy = true; ui.namesNofferError = null; render(); + if (!st.name) { ui.namesOwnErr = t('namesNeedArk'); render(); return; } + ui.namesOwnBusy = field; ui.namesOwnErr = null; render(); try { - if (raw) { + if (raw && field === 'noffer') { const d = decodeNoffer(raw); if (!d.relay) throw new Error(t('namesOwnNodeNoRelay')); } - await claim(st.name, { noffer: raw || '' }); - ui.namesNofferInput = ''; + if (raw && field === 'lno' && !/^lno1[a-z0-9]+$/i.test(raw)) throw new Error(t('namesOwnLnoBad')); + await claim(st.name, { [field]: raw || '' }); + ui.namesOwnInput = { ...ui.namesOwnInput, [field]: '' }; toast(raw ? t('namesOwnNodeSet') : t('namesOwnNodeCleared')); - } catch (e) { ui.namesNofferError = e.message; } - ui.namesNofferBusy = false; render(); + } catch (e) { ui.namesOwnErr = e.message; } + ui.namesOwnBusy = null; render(); + } + + function ownNodeField(field, current, placeholder, how) { + const busy = ui.namesOwnBusy === field; + if (current) { + return h('div', { class: 'col', style: 'gap:8px' }, + h('div', { class: 'small muted' }, t(field === 'noffer' ? 'namesOwnNodeActive' : 'namesOwnLnoActive')), + h('div', { class: 'addr-box break', style: 'font-size:10px' }, current), + h('button', { class: 'btn-ghost btn-sm', disabled: !!ui.namesOwnBusy, + onClick: () => setOwnNode(field, '') }, + busy ? h('span', { class: 'spinner sm' }) : t('namesOwnNodeClear'))); + } + return h('div', { class: 'col', style: 'gap:8px' }, + h('div', { class: 'small muted' }, how), + h('div', { class: 'row gap6' }, + h('input', { + type: 'text', class: 'grow mono-input', placeholder, + value: (ui.namesOwnInput || {})[field] || '', + onInput: (e) => { ui.namesOwnInput = { ...ui.namesOwnInput, [field]: e.target.value }; }, + }), + h('button', { class: 'btn-sm', disabled: !!ui.namesOwnBusy, + onClick: () => setOwnNode(field, ((ui.namesOwnInput || {})[field] || '').trim()) }, + busy ? h('span', { class: 'spinner sm' }) : t('save')))); } function ownNodeSection() { const st = load(); if (!st.name) return null; - return h('div', { class: 'col', style: 'gap:8px;width:100%;margin-top:6px' }, + return h('div', { class: 'col', style: 'gap:10px;width:100%;margin-top:6px' }, h('strong', { class: 'small' }, t('namesOwnNode')), - st.noffer - ? h('div', { class: 'col', style: 'gap:8px' }, - h('div', { class: 'small muted' }, t('namesOwnNodeActive')), - h('div', { class: 'addr-box break', style: 'font-size:10px' }, st.noffer), - h('button', { class: 'btn-ghost btn-sm', disabled: !!ui.namesNofferBusy, - onClick: () => setDeliveryNoffer('') }, - ui.namesNofferBusy ? h('span', { class: 'spinner sm' }) : t('namesOwnNodeClear'))) - : h('div', { class: 'col', style: 'gap:8px' }, - h('div', { class: 'small muted' }, t('namesOwnNodeHow')), - h('div', { class: 'row gap6' }, - h('input', { - type: 'text', class: 'grow mono-input', placeholder: 'noffer1…', - value: ui.namesNofferInput || '', - onInput: (e) => { ui.namesNofferInput = e.target.value; }, - }), - h('button', { class: 'btn-sm', disabled: !!ui.namesNofferBusy, - onClick: () => setDeliveryNoffer((ui.namesNofferInput || '').trim()) }, - ui.namesNofferBusy ? h('span', { class: 'spinner sm' }) : t('save')))), - ui.namesNofferError ? h('div', { class: 'notice err small' }, ui.namesNofferError) : null); + ownNodeField('noffer', st.noffer, 'noffer1…', t('namesOwnNodeHow')), + ownNodeField('lno', st.lno, 'lno1…', t('namesOwnLnoHow')), + ui.namesOwnErr ? h('div', { class: 'notice err small' }, ui.namesOwnErr) : null); } function clinkPane() { @@ -731,7 +754,7 @@ export function namesFeature(ctx) { namesSeed(rec) { if (!rec || !rec.name) return null; const st = load(); - if (!st.name) save({ name: rec.name, domain: rec.domain || DOMAIN(), uri: rec.uri, noffer: rec.noffer ?? null }); + if (!st.name) save({ name: rec.name, domain: rec.domain || DOMAIN(), uri: rec.uri, noffer: rec.noffer ?? null, lno: rec.lno ?? null }); return true; }, // 'pending' while the restore/claim pass is still running — the wallet diff --git a/src/features/zaps.js b/src/features/zaps.js index 7dfc272..0938d3d 100644 --- a/src/features/zaps.js +++ b/src/features/zaps.js @@ -16,6 +16,7 @@ import { npubOf } from '../nostr.js'; import { parseZapTarget, zapTargetFromProfile, fetchPayParams, buildZapRequest, requestInvoice } from '../lnurl.js'; import { decodeNoffer } from '../noffer.js'; import { requestOfferInvoice } from '../clink.js'; +import { decodeOffer } from '../bolt12.js'; import { maybeBolt11 } from '../ark/lightning.js'; export function zapsFeature(ctx) { @@ -34,6 +35,22 @@ export function zapsFeature(ctx) { try { return { ...decodeNoffer(s), raw: s }; } catch { return null; } } + // A pasted BOLT 12 offer (`lno1…`) — decoded, or null. + function maybeOffer(text) { + const s = String(text || '').trim().replace(/^(lightning|bitcoin):/i, '').toLowerCase(); + if (!/^lno1/.test(s)) return null; + try { return { ...decodeOffer(s), raw: s }; } catch { return null; } + } + + const shortCode = (s) => s.slice(0, 12) + '…' + s.slice(-6); + + // Begin the amount screen for a decoded offer. Fetching the actual invoice + // (via the ASP's CLN) and verifying it happens on confirm. + function beginBolt12(off, display) { + begin({ kind: 'bolt12', offer: off, address: display }, off.description || display); + return true; + } + // Kick off resolution of a parsed target. `display` is what we show as the // recipient until a profile name arrives. function begin(target, display) { @@ -46,6 +63,18 @@ export function zapsFeature(ctx) { async function resolve(z) { let target = z.target; + // A BOLT 12 offer carries its own terms — no lookup until confirm, when + // the ASP's CLN fetches (and this wallet verifies) the actual invoice. + if (target.kind === 'bolt12') { + const off = target.offer; + if (off.currency) throw new Error(t('bolt12Currency', { currency: off.currency })); + const fixedSat = off.amountMsat ? Number((off.amountMsat + 999n) / 1000n) : null; + if (fixedSat) z.amount = getUnit() === 'sats' ? String(fixedSat) : (fixedSat / 1e8).toFixed(8); + z.params = { minSendable: 1000, maxSendable: 21e15, commentAllowed: 0, allowsNostr: false, fixedSat }; + z.status = 'ready'; + render(); + return; + } // A CLINK offer needs no HTTP at all: the code itself says who to ask. if (target.kind === 'noffer') { const n = target.noffer; @@ -130,6 +159,19 @@ export function zapsFeature(ctx) { return render(); } z.error = ''; z.status = 'invoicing'; render(); + if (z.target.kind === 'bolt12') { + try { + // fetch through the ASP's CLN; the manager verifies signature, offer + // mirroring and amount before handing back the lni + const r = await hook('arkFetchBolt12Invoice', z.target.offer.raw, sats); + ui.zap = null; + hook('startLnPay', r.invoice, { name: z.name, address: z.address, pk: null, comment: '', isZap: false }); + render(); + } catch (e) { + z.status = 'ready'; z.error = e.message; render(); + } + return; + } if (z.target.kind === 'noffer') { try { const invoice = await requestOfferInvoice(z.target.noffer, { amountSat: sats, description: z.comment }); @@ -249,10 +291,12 @@ export function zapsFeature(ctx) { if (!canPay() || !ui.send || ui.send.recipients.length !== 1) return false; const nof = maybeNoffer(text); if (nof) { - const short = nof.raw.slice(0, 12) + '…' + nof.raw.slice(-6); + const short = shortCode(nof.raw); begin({ kind: 'noffer', noffer: nof, address: short }, short); return true; } + const off = maybeOffer(text); + if (off) return beginBolt12(off, shortCode(off.raw)); const target = parseZapTarget(text); if (!target) return false; // npub with no nostr seam to look up a profile → can't resolve here. @@ -278,6 +322,15 @@ export function zapsFeature(ctx) { onClick: () => begin(target, display), }, '⚡ ' + t('lnZapOffer', { addr: display })); }, + // A BIP-353 name resolved to a record whose lno= is the only payable + // instruction (a foreign BOLT 12 node, or a coinos name whose owner + // pointed it at their own node) — pay the offer. + startBolt12Pay(lno, display) { + if (!canPay() || !ui.send || ui.send.recipients.length !== 1) return false; + const off = maybeOffer(lno); + if (!off) return false; + return beginBolt12(off, display || shortCode(off.raw)); + }, // The names feature resolves user@domain over DNS first (BIP-353) and // lands here when the name has no DNS record — the classic LNURL path. lnAddressFallback(text) { |
