aboutsummaryrefslogtreecommitdiffstats
path: root/src/features
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-29 00:50:09 -0700
committerAdam Soltys <[email protected]>2026-08-29 00:50:09 -0700
commit2023fe06d5cc4867f09d5916680b5fe92394dacb (patch)
treeaf8e230bd283776273537da60fe9c0d6782cba28 /src/features
parent8ea0416c08535750839088c060d3252415510cc5 (diff)
downloadcoinosv3-2023fe06d5cc4867f09d5916680b5fe92394dacb.tar.gz
coinosv3-2023fe06d5cc4867f09d5916680b5fe92394dacb.zip
The wallet learns to read BOLT 12 for itself: offers and invoices decode client-side, and every invoice the ASP fetches over onion messages is verified here — payment hash read from the raw TLVs, offer terms mirrored byte-for-byte, the issuer's schnorr signature checked against LDK's exact merkle construction (proven against a live CLN-signed fixture) — so no server can hand back an invoice whose preimage it already holds. Paste an lno1 or resolve a name whose record carries one and the existing HTLC pay flow carries it (captaind's Invoice enum takes lni strings natively); a name's owner may now publish their OWN node's offer in their DNS record, retiring the registrar's stand-in — senders fetch invoices straight from their node and no coinos service ever touches the money. The /lnquote fallback prices blinded paths by entry node + advertised payinfo
Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_019WboeoetMF3MTmgT7Ew8yi
Diffstat (limited to 'src/features')
-rw-r--r--src/features/ark.js14
-rw-r--r--src/features/names.js101
-rw-r--r--src/features/zaps.js55
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) {