diff options
| author | Adam Soltys <[email protected]> | 2026-08-02 18:17:27 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-08-02 18:17:27 -0700 |
| commit | 8da44392be513929802a5f4329b29bfa125aaa04 (patch) | |
| tree | ad147686220f5a64b7c8bc9c3287f9ba0bb12020 /src | |
| parent | df01e3fb245c3e8d75a335d94e95df5f76a3b30a (diff) | |
| download | coinosv3-8da44392be513929802a5f4329b29bfa125aaa04.tar.gz coinosv3-8da44392be513929802a5f4329b29bfa125aaa04.zip | |
Pay an npub: ark > lightning > on-chain, and fix the orphaned LN seams
Picking a search result (or pasting an npub) now resolves every way the
person can be paid and takes the best: their kind-10037 ark advert; an
ark= instruction in BIP-353 via their coinos name (registrar /pubkey
returns the URI directly) or their nip05 name's own DNS record (a bare
domain is the BIP-353 "_" user); then Lightning from profile lud16/
lud06 through the zap flow; then a BIP-353 on-chain address as last
resort. Search results no longer put the nip05 identifier in the fill
value — that's how fiatjaf.com ended up as a dead recipient.
Along the way: the zaps feature's canLnPay/startLnPay seams had no
provider since the swaps feature retired, so the whole Lightning-zap
path was unreachable. Ark provides both now (the ASP pays invoices
natively), which also revives zapping any LN-only npub.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi
Diffstat (limited to 'src')
| -rw-r--r-- | src/features/ark.js | 73 | ||||
| -rw-r--r-- | src/i18n.js | 2 | ||||
| -rw-r--r-- | src/recipient-search.js | 6 |
3 files changed, 71 insertions, 10 deletions
diff --git a/src/features/ark.js b/src/features/ark.js index bd98fd4..3613b02 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -17,6 +17,7 @@ import { getNetwork, setNetwork, getArkProviderId, getArkConfig, } from '../api.js'; import { t } from '../i18n.js'; +import { resolveBip353, parsePaymentName, parseBip21 as parseBip21Uri } from '../bip353.js'; import { shortAddr, shortTxid, timeAgo, ARK_ICON, ARK_MARK } from '../format.js'; // t?ark1… bech32m — an Ark address for this or another ASP. @@ -889,6 +890,67 @@ export function arkFeature(ctx) { } // Resolve an npub to a same-network ark address via their announcement. + // Paying a PERSON (an npub): resolve every way they can be paid and take + // the best — ark over lightning over on-chain. + // 1. their ark address: the kind-10037 advert, or an ark= instruction in + // BIP-353 (their coinos name via the registrar, or their nip05 name's + // own DNS record — a bare domain nip05 is the BIP-353 "_" user) + // 2. lightning: profile lud16/lud06, via the zap flow + // 3. an on-chain address from the BIP-353 record + const NAMES_REGISTRAR = 'https://names.coinos.io'; + function startNpubPay(pk, npub) { + const z = (ui.arkZap = { npub, pk, amount: '', comment: '', status: 'lookup' }); + ui.sendError = ''; + render(); + const live = () => ui.arkZap === z; + (async () => { + connectArk().catch(() => {}); + // 1a. ark advert + const adv = await lookupArkZapTarget(pk).catch(() => ({ status: 'noark' })); + if (!live()) return; + if (adv.status === 'ready') { Object.assign(z, adv); render(); return; } + if (adv.status === 'wrongnet') { Object.assign(z, adv); render(); return; } + // 1b. BIP-353 with an ark instruction; remember on-chain as last resort + const uris = []; + try { + const j = await (await fetch(`${NAMES_REGISTRAR}/pubkey/${pk}`)).json(); + if (j && j.uri) uris.push(j.uri); + } catch {} + const profile = wallet.nostrProfile ? await wallet.nostrProfile(pk).catch(() => null) : null; + if (!live()) return; + if (profile && typeof profile.nip05 === 'string' && profile.nip05.trim()) { + const nip05 = profile.nip05.trim().toLowerCase(); + const parsed = parsePaymentName(nip05.includes('@') ? nip05 : '_@' + nip05); + if (parsed) { + try { const uri = await resolveBip353(parsed.name, parsed.domain); if (uri) uris.push(uri); } catch {} + } + } + if (!live()) return; + let onchain = null; + for (const uri of uris) { + const dec = parseBip21Uri(uri); + if (!dec) continue; + const arkAddr = dec.params && dec.params.ark; + if (arkAddr && isArkAddress(arkAddr)) { Object.assign(z, { status: 'ready', address: arkAddr }); render(); return; } + if (dec.onchain && !onchain) onchain = dec.onchain; + } + // 2. lightning via the zap flow (connect first — canLnPay needs it) + if (profile && (profile.lud16 || profile.lud06)) { + await connectArk().catch(() => {}); + if (!live()) return; + if (ctx.hook('canLnZap')) { ctx.hook('lnZapNpub', pk, npub); return; } + } + // 3. on-chain fallback + if (onchain) { + ui.arkZap = null; + ui.send.recipients[0].address = onchain; + render(); + return; + } + if (live()) { z.status = 'noark'; render(); } + })().catch((e) => { if (live()) { z.status = 'noark'; ui.sendError = e.message; render(); } }); + } + async function lookupArkZapTarget(pk) { const events = await wallet.nostrFetch({ kinds: [ARK_INFO_KIND], authors: [pk] }, 6000); const ev = (events || []).sort((a, b) => b.created_at - a.created_at)[0]; @@ -1560,6 +1622,10 @@ export function arkFeature(ctx) { // swaps feature's delegation (its matcher runs first) or directly in // builds without the swaps feature. startArkLnPay(invoice, meta) { return startArkLnPay(invoice, meta); }, + // 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); }, + startLnPay(invoice, meta) { return startArkLnPay(invoice, meta); }, // ---- headless seam for the NWC wallet service ---- arkPayInvoice(invoice, opts) { return payInvoiceHeadless(invoice, opts); }, arkSpendableSat() { const b = arkBalance(); return b ? b.spendableSat : 0; }, @@ -1605,12 +1671,7 @@ export function arkFeature(ctx) { if (maybeBolt11(inv)) return startArkLnPay(inv); const pk = npubToHex(text); if (!pk || !arkAvailable() || !wallet.nostrFetch) return false; - ui.arkZap = { npub: String(text).trim(), pk, amount: '', comment: '', status: 'lookup' }; - ui.sendError = ''; - render(); - Promise.all([connectArk(), lookupArkZapTarget(pk)]) - .then(([, res]) => { if (ui.arkZap && ui.arkZap.pk === pk) { Object.assign(ui.arkZap, res); render(); } }) - .catch((e) => { if (ui.arkZap && ui.arkZap.pk === pk) { ui.arkZap.status = 'noark'; ui.sendError = e.message; render(); } }); + startNpubPay(pk, String(text).trim()); return true; }, historyEntries() { diff --git a/src/i18n.js b/src/i18n.js index 8ca63cc..09c9b07 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -445,7 +445,7 @@ const STR = { arkExitNoBalance: 'Nothing in Spending to move.', arkZapTitle: 'Send a zap', arkZapLookup: 'Looking them up…', - arkZapNoArk: 'This nostr user can’t receive instant payments yet.', + arkZapNoArk: 'No way to pay this account was found — no Ark address, Lightning address, or payment record.', arkZapHint: 'Instant and free, with a zap receipt on nostr.', arkZapCommentPh: 'Comment (optional)', arkZapBtn: 'Zap', diff --git a/src/recipient-search.js b/src/recipient-search.js index 32319ab..d3e69eb 100644 --- a/src/recipient-search.js +++ b/src/recipient-search.js @@ -95,7 +95,7 @@ export async function searchRecipients(qRaw) { try { const m = JSON.parse(e.content); profileCache.set(e.pubkey, { name: m.display_name || m.name || null, picture: m.picture || null }); - add(e.pubkey, { name: m.display_name || m.name, picture: m.picture, address: typeof m.nip05 === 'string' ? m.nip05.replace(/^_@/, '') : undefined }, pri); + add(e.pubkey, { name: m.display_name || m.name, picture: m.picture, nip05: typeof m.nip05 === 'string' ? m.nip05.replace(/^_@/, '') : undefined }, pri); } catch {} }; const primal = await primalSearch(q); @@ -108,7 +108,7 @@ export async function searchRecipients(qRaw) { // Within a source, a name that actually contains the query outranks the // search relay's looser relevance matches. - const score = (r) => ((r.name || '').toLowerCase().includes(q) || (r.address || '').toLowerCase().includes(q)) ? 0 : 1; + const score = (r) => ((r.name || '').toLowerCase().includes(q) || (r.address || r.nip05 || '').toLowerCase().includes(q)) ? 0 : 1; const rows = [...out.values()].sort((a, b) => a.pri - b.pri || score(a) - score(b)).slice(0, MAX_RESULTS); await fillProfiles(rows).catch(() => {}); if (queryCache.size > 200) queryCache.clear(); @@ -147,5 +147,5 @@ export function resultRows(h, rows, onPick) { : h('div', { class: 'chat-avatar fallback' }, (r.name || npubOf(r.pk) || '??').slice(0, 2)), h('div', { class: 'col grow', style: 'min-width:0;gap:1px' }, h('div', { class: 'chat-name' }, r.name || (npubOf(r.pk) || '').slice(0, 14) + '…'), - h('div', { class: 'muted small chat-preview' }, r.address || (npubOf(r.pk) || '').slice(0, 24) + '…')))); + h('div', { class: 'muted small chat-preview' }, r.address || r.nip05 || (npubOf(r.pk) || '').slice(0, 24) + '…')))); } |
