diff options
Diffstat (limited to 'src/features')
| -rw-r--r-- | src/features/gifts.js | 8 | ||||
| -rw-r--r-- | src/features/messages.js | 65 | ||||
| -rw-r--r-- | src/features/withdraw-links.js | 195 | ||||
| -rw-r--r-- | src/features/zaps.js | 10 |
4 files changed, 272 insertions, 6 deletions
diff --git a/src/features/gifts.js b/src/features/gifts.js index f23ecc8..c6ccbd9 100644 --- a/src/features/gifts.js +++ b/src/features/gifts.js @@ -8,6 +8,7 @@ import { decryptWithCode, npubOf } from '../nostr.js'; import { installGiftWallet, previewGift, giftOutpoints, giftClaimTxs, isRetiredGift, giftMinimum, lockGift, previewLockedGift } from './gifts-wallet.js'; import { t } from '../i18n.js'; import { qrSvg } from '../qr.js'; +import { withdrawLinks } from './withdraw-links.js'; import { fmtBtc, timeAgo, shortAddr } from '../format.js'; import { getEventHash, verifyEvent } from 'nostr-tools/pure'; import { sha256 } from '@noble/hashes/sha256'; @@ -26,6 +27,7 @@ export function giftsFeature(ctx) { // every call degrades to null in an on-chain-only build. const hook = ctx.hook || (() => null); const arkGiftOf = (code) => hook('arkGiftDecode', code); + const wl = withdrawLinks(ctx); // prepaid LNURL-withdraw links, the gift link's sibling on Send // ================================================================ GIFT / CLAIM // Read a gift link, returning the code (and scrubbing it from the URL so the @@ -1085,9 +1087,11 @@ export function giftsFeature(ctx) { return { blob, claimCode, url: `${location.origin}/lg/${blob}` }; }, receiveTakeover() { return claimCelebration(); }, - sendView() { return ui.giftMode ? giftView() : null; }, + sendView() { return ui.giftMode === 'lnurl' ? wl.view() : ui.giftMode ? giftView() : null; }, sendFormExtras() { - return h('button', { class: 'linklike small', style: 'align-self:center;margin-top:2px', onClick: () => { ui.giftMode = true; ui.sendError = ''; render(); } }, '๐ ' + t('giftLink')); + return h('div', { class: 'row gap6 wrap', style: 'justify-content:center;column-gap:18px' }, + h('button', { class: 'linklike small', style: 'margin-top:2px', onClick: () => { ui.giftMode = true; ui.sendError = ''; render(); } }, '๐ ' + t('giftLink')), + wl.entry()); }, balanceLines() { const locked = wallet.giftLockedValue(); diff --git a/src/features/messages.js b/src/features/messages.js index 6c4f5f8..4ba4bfb 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -4375,6 +4375,8 @@ export function messagesFeature(ctx) { if (persist) persistThread(c); } const tagOf = (ev, k) => (ev.tags.find((x) => x[0] === k) || [])[1]; + const APP_KIND = 32267; // a Zapstore app listing (NIP-82): zaps on one are zaps on your app + const APP_RELAYS = ['wss://relay.zapstore.dev']; function receiptSats(ev) { if (ev.kind === 9737) { const net = tagOf(ev, 'network'); @@ -4395,6 +4397,16 @@ export function messagesFeature(ctx) { if (ev.kind === 9737) return String(ev.content || '').slice(0, 200); try { return String((JSON.parse(tagOf(ev, 'description') || 'null') || {}).content || '').slice(0, 200); } catch { return ''; } } + // The address (kind:pubkey:d) a zap was for, when it wasn't a note: a + // stream (30311) or a Zapstore app listing (32267). LNURL servers often + // leave the a tag off the receipt (Zapstore's did, 2026-11-07), so the zap + // request inside it is asked too. + function zapAddrOf(ev) { + const a = (e) => ((e && e.tags) || []).find((x) => x[0] === 'a' && x[1]); + const own = a(ev); + if (own) return own[1]; + try { const r = a(JSON.parse(tagOf(ev, 'description') || 'null')); return r ? r[1] : null; } catch { return null; } + } function zapperOf(ev) { if (ev.kind === 9737) { // Signed by the wallet key; the sender tag names the person behind it. @@ -18455,9 +18467,11 @@ export function messagesFeature(ctx) { if (ZAP_KINDS.includes(ev.kind)) { const actor = zapperOf(ev), sats = receiptSats(ev); if (!actor || my.includes(actor) || !sats) return null; - // a zap on a stream names it by address (a 30311), not by an e tag - const stream = !lastE ? ((ev.tags || []).find((x) => x[0] === 'a' && String(x[1] || '').startsWith(LIVE_KIND + ':')) || [])[1] || null : null; - return { id: ev.id, what: 'zap', actor, target: lastE, stream, hint, sats, text: zapText(ev), ts: ev.created_at }; + // a zap on a stream or an app names it by address, not by an e tag + const addr = !lastE ? zapAddrOf(ev) : null; + const stream = addr && addr.startsWith(LIVE_KIND + ':') ? addr : null; + const app = addr && addr.startsWith(APP_KIND + ':') ? addr : null; + return { id: ev.id, what: 'zap', actor, target: lastE, stream, ...(app ? { app } : {}), hint, sats, text: zapText(ev), ts: ev.created_at }; } if (my.includes(ev.pubkey)) return null; if (ev.kind === 7) { @@ -18494,6 +18508,12 @@ export function messagesFeature(ctx) { const old = c.items.find((y) => y.id === x.id); if (old && !old.emojiUrl) { old.emojiUrl = x.emojiUrl; old.emoji = x.emoji; healed = true; } } + // ...and a zap on an app saved before rows knew one + for (const x of items) { + if (!x || !x.app || !known.has(x.id)) continue; + const old = c.items.find((y) => y.id === x.id); + if (old && !old.app) { old.app = x.app; healed = true; } + } const add = items.filter((x) => x && !hiddenPk(x.actor) && !known.has(x.id) && known.add(x.id)); if (!add.length) { if (healed) persistNotifs(); return healed; } warmNotifFaces(add); @@ -18611,7 +18631,32 @@ export function messagesFeature(ctx) { return ref && ref.type === 'pubkey' ? '@' + displayName(ref.pk) : m.slice(6, 18) + '\u2026'; }).replace(/\s+/g, ' ').trim(); } + // Zapstore app listings (NIP-82 kind 32267): a zap on one is a zap on your + // app. Its name is asked of Zapstore's relay once per session. + const appIdOf = (addr) => String(addr || '').split(':').slice(2).join(':'); + const appNames = new Map(); // app id -> name | null (asking / none) + function appName(addr) { + const id = appIdOf(addr); + if (!id) return null; + if (!appNames.has(id)) { + appNames.set(id, null); + queryOn(APP_RELAYS, { kinds: [APP_KIND], '#d': [id], limit: 5 }, 5000).then((evs) => { + const ev = (evs || []).sort((a, b) => b.created_at - a.created_at)[0]; + const name = ev && tagOf(ev, 'name'); + if (name) { appNames.set(id, String(name).slice(0, 60)); scheduleRepaint(); } + }).catch(() => {}); + } + return appNames.get(id); + } + const openApp = (addr) => { + const url = 'https://zapstore.dev/apps/' + encodeURIComponent(appIdOf(addr)); + if (ctx.openExternal) ctx.openExternal(url); else window.open(url, '_blank', 'noopener'); + }; function notifLabel(x) { + if (x.what === 'zap' && x.app) { + const name = appName(x.app); + return name ? t('alertZapApp', { name, sats: fmtSats(x.sats) + ' sats' }) : t('alertZapAppAnon', { sats: fmtSats(x.sats) + ' sats' }); + } if (x.what === 'zap') return t('alertZap', { sats: fmtSats(x.sats) + ' sats' }); if (x.what === 'react') { const code = shortcodeOf(x.emoji); @@ -18649,6 +18694,20 @@ export function messagesFeature(ctx) { // a zap on a stream opens the stream; one on your profile (no post // at all) opens the zapper's โ the tap used to do nothing else if (x.stream) { const [, pk, ...d] = x.stream.split(':'); stopNotifWatch(); openLiveRef({ pk, d: d.join(':') }).catch(() => {}); } + else if (x.app) openApp(x.app); + // a row saved before rows read the zap request's address: look at + // the receipt once, then go where it says + else if (x.what === 'zap' && !x.addrChecked) { + x.addrChecked = true; + queryOn(zapRelays(), { ids: [x.id] }, 4500).catch(() => []).then((evs) => { + const addr = evs && evs[0] ? zapAddrOf(evs[0]) : null; + if (addr && addr.startsWith(APP_KIND + ':')) x.app = addr; + else if (addr && addr.startsWith(LIVE_KIND + ':')) x.stream = addr; + persistNotifs(); + render(); + open(); + }); + } else { stopNotifWatch(); openProfile(x.actor); } } }; diff --git a/src/features/withdraw-links.js b/src/features/withdraw-links.js new file mode 100644 index 0000000..a63d180 --- /dev/null +++ b/src/features/withdraw-links.js @@ -0,0 +1,195 @@ +// Prepaid Lightning withdraw links (LNURL-withdraw): make a link or QR that +// whoever holds it can pull sats from โ a voucher, a faucet, a printed card โ +// even while this wallet is asleep. "Do we have lnurl withdraw links +// somewhere? can we add them?" โ "Prepaid" (Adam, 2026-11-07). +// +// The money is paid up front, from Spending over Lightning, to the coinos +// registrar (coinosv3-server names/withdraw.js), which pays each claim out of +// it; until claimed it sits there, and the creator can take back whatever is +// left at any time ("Take back"), which closes the link. Links belong to the +// wallet's own nostr key, so every device with the seed sees them. +// +// It lives on the Send page as a sibling of the gift link: ui.giftMode === +// 'lnurl' (giftMode is already a navigation field with its resets). +import { nip98Header } from '../nip98.js'; +import { encodeLnurl } from '../lnurl.js'; +import { qrSvg } from '../qr.js'; +import { getNetwork } from '../api.js'; +import { t } from '../i18n.js'; + +const REGISTRAR = 'https://names.coinos.io'; +const NOTE_MAX = 120; +const net = () => (getNetwork() === 'mutinynet' ? 'mutinynet' : 'mainnet'); +const available = () => ['mainnet', 'mutinynet'].includes(getNetwork()); + +export function withdrawLinks(ctx) { + const { h, ui, render, wallet, toast, copyBtn, goBack, fmtAmount, unitLabel, unitTag, parseAmount, getUnit } = ctx; + const hook = ctx.hook || (() => null); + const base = () => ctx.withdrawRegistrar || REGISTRAR; + // ui.wl: { list: [...]|null, listError, amount, uses, note, busy, error, open: id|null, made: id|null, taking: id|null } + const st = () => (ui.wl ||= { list: null, amount: '', uses: '1', note: '' }); + + const signer = { signEvent: (e) => wallet.nostrSign(e) }; + async function call(method, path, payload) { + const url = base() + path; + const body = payload ? JSON.stringify(payload) : ''; + const auth = await nip98Header(signer, url, method, body); + const r = await fetch(url, { method, headers: { authorization: auth, ...(body ? { 'content-type': 'application/json' } : {}) }, ...(body ? { body } : {}) }); + const j = await r.json().catch(() => ({})); + if (!r.ok || j.error) throw new Error(j.error || `registrar refused (${r.status})`); + return j; + } + + async function load() { + const s = st(); + try { + const j = await call('GET', '/withdraw/mine'); + s.list = (j.links || []).filter((l) => l.net === net()); + s.listError = ''; + } catch (e) { s.listError = e.message; s.list ||= []; } + render(); + } + + function open() { + ui.wl = null; + ui.giftMode = 'lnurl'; + ui.sendError = ''; + st(); + render(); + load(); + } + + const lnurlOf = (l) => encodeLnurl(l.url).toUpperCase(); + const amt = (sat) => fmtAmount(sat) + ' ' + unitLabel(); + + async function create() { + const s = st(); + const sat = parseAmount(s.amount, getUnit()) || 0; + const uses = Math.floor(Number(s.uses) || 1); + s.error = ''; + if (!(sat >= 1)) { s.error = t('wlAmountInvalid'); render(); return; } + if (!(uses >= 1 && uses <= 1000)) { s.error = t('wlUsesInvalid'); render(); return; } + if (wallet.watchOnly) { s.error = t('lnWithdrawWatchOnly'); render(); return; } + if (!hook('arkReady')) { s.error = t('hatNeedSpending'); render(); return; } + const total = sat * uses; + if ((hook('arkSpendableSat') || 0) < total + Math.max(10, Math.ceil(total * 0.002))) { s.error = t('wlNoFunds', { amount: amt(total) }); render(); return; } + s.busy = true; render(); + try { + const inv = await call('POST', '/withdraw/new', { sat, uses, note: String(s.note || '').trim().slice(0, NOTE_MAX), net: net() }); + await hook('arkPayInvoice', inv.invoice, { maxAmountSat: inv.totalSat }); + let link = null; + for (let i = 0; i < 10 && !link; i++) { + try { link = await call('POST', '/withdraw/funded', { id: inv.id }); } + catch (e) { if (!/not paid/i.test(e.message)) throw e; await new Promise((r) => setTimeout(r, 1500)); } + } + if (!link) throw new Error(t('wlNotConfirmed')); + s.list = [link, ...(s.list || []).filter((l) => l.id !== link.id)]; + s.made = link.id; s.open = link.id; + s.amount = ''; s.uses = '1'; s.note = ''; + } catch (e) { + s.error = e.message || String(e); + } finally { s.busy = false; render(); } + } + + async function takeBack(l) { + const s = st(); + s.taking = l.id; s.error = ''; render(); + let lnId = null; + try { + if (l.remainingSat > 0) { + const inv = await hook('arkLnInvoice', l.remainingSat, t('wlTakeBackMemo')); + if (!inv || !inv.invoice) throw new Error(t('lnWithdrawNoInvoice')); + lnId = inv.id; + // the wallet has to claim the held payment while the registrar pays it + hook('arkLnWatch', inv.id, () => {}); + const r = await call('POST', '/withdraw/reclaim', { id: l.id, invoice: inv.invoice }); + Object.assign(l, r); + toast(r.paid === false ? t('wlTakeBackFailed') : t('wlTakenBack', { amount: amt(inv.amountSat) })); + } else { + Object.assign(l, await call('POST', '/withdraw/reclaim', { id: l.id })); + } + } catch (e) { + if (lnId) hook('arkLnCancel', lnId); + s.error = e.message || String(e); + } finally { s.taking = null; render(); load(); } + } + + function linkCard(l) { + const s = st(); + const lnurl = lnurlOf(l); + let svg = null; + try { svg = qrSvg(lnurl, { ec: 'M', mode: 'Alphanumeric' }); } catch {} + const live = l.status === 'open' && l.remainingSat > 0 && l.used < l.uses; + return h('div', { class: 'card col', style: 'align-items:center;gap:10px' }, + h('h3', { style: 'align-self:stretch' }, s.made === l.id ? t('wlReady') : t('wlTitle')), + live && svg ? h('div', { html: svg }) : null, + l.note ? h('blockquote', { class: 'gift-note', style: 'width:100%' }, l.note) : null, + h('div', { class: 'small muted', style: 'text-align:center' }, + l.uses > 1 ? t('wlTermsMany', { amount: amt(l.sat), n: l.uses - l.used, total: l.uses }) : t('wlTermsOne', { amount: amt(l.sat) }), + ' ยท ', t('wlLeft', { amount: amt(l.remainingSat) })), + live ? h('div', { class: 'addr-box break mono', style: 'width:100%;font-size:11px' }, lnurl) : h('div', { class: 'notice small' }, l.status === 'closed' ? t('wlClosed') : t('wlUsedUp')), + live ? h('div', { class: 'row gap6 wrap', style: 'width:100%' }, copyBtn(lnurl, t('wlCopy'))) : null, + live ? h('div', { class: 'small faint', style: 'text-align:center' }, t('wlBearer')) : null, + s.error && h('div', { class: 'notice err', style: 'width:100%' }, s.error), + live || (l.status === 'open' && l.remainingSat > 0) + ? (s.taking === l.id + ? h('button', { class: 'btn-block', disabled: true }, h('span', { class: 'spinner sm' })) + : h('button', { class: 'btn-ghost btn-block', onClick: () => takeBack(l) }, t('wlTakeBack', { amount: amt(l.remainingSat) }))) + : null, + h('button', { class: 'btn-ghost btn-block', onClick: () => { s.open = null; s.made = null; s.error = ''; render(); } }, t('back'))); + } + + function formCard() { + const s = st(); + const sat = parseAmount(s.amount, getUnit()) || 0; + const uses = Math.max(1, Math.floor(Number(s.uses) || 1)); + return h('div', { class: 'card col gap6' }, + h('h3', {}, t('wlTitle')), + h('div', { class: 'small muted' }, t('wlDesc')), + h('div', { class: 'input-group' }, + h('input', { id: 'wl-amount', type: 'number', min: '0', inputmode: 'decimal', step: getUnit() === 'sats' ? '1' : '0.00000001', + placeholder: t('wlAmount'), value: s.amount, onInput: (e) => { s.amount = e.target.value; render(); } }), + h('div', { style: 'display:flex;align-items:center' }, unitTag())), + h('label', { class: 'row gap6', style: 'align-items:center' }, + h('span', { class: 'small grow' }, t('wlUses')), + h('input', { id: 'wl-uses', type: 'number', min: '1', max: '1000', step: '1', inputmode: 'numeric', style: 'width:90px', + value: s.uses, onInput: (e) => { s.uses = e.target.value; render(); } })), + h('textarea', { class: 'gift-note-input', rows: 2, maxlength: NOTE_MAX, placeholder: t('wlNote'), value: s.note, onInput: (e) => { s.note = e.target.value; } }), + h('div', { class: 'small faint' }, sat >= 1 && uses > 1 ? t('wlPayNowMany', { total: amt(sat * uses), n: uses, amount: amt(sat) }) : t('wlPayNow')), + s.error && !s.open ? h('div', { class: 'notice err' }, s.error) : null, + s.busy + ? h('button', { class: 'btn-primary btn-block', disabled: true }, h('span', { class: 'spinner sm' })) + : h('button', { id: 'wl-create', class: 'btn-primary btn-block', onClick: create }, sat >= 1 ? t('wlCreateFor', { amount: amt(sat * uses) }) : t('wlCreate'))); + } + + function listCard() { + const s = st(); + if (s.list == null) return h('div', { class: 'card', style: 'text-align:center' }, h('span', { class: 'spinner sm' })); + if (!s.list.length) return s.listError ? h('div', { class: 'notice err small' }, s.listError) : null; + return h('div', { class: 'card col gap6' }, + h('span', { class: 'small muted' }, t('wlYours')), + ...s.list.map((l) => h('button', { class: 'row between wl-row', style: 'text-align:left', onClick: () => { s.open = l.id; s.made = null; s.error = ''; render(); } }, + h('span', { class: 'small' }, amt(l.sat), l.uses > 1 ? ` ร ${l.uses}` : '', l.note ? h('span', { class: 'faint' }, ' ยท ' + l.note) : null), + h('span', { class: 'small faint' }, + l.status === 'closed' ? t('wlClosedTag') : l.remainingSat < 1 || l.used >= l.uses ? t('wlUsedUpTag') : t('wlLeft', { amount: amt(l.remainingSat) }))))); + } + + function view() { + const s = st(); + if (!available()) { + return h('div', { class: 'col', style: 'gap:12px' }, + h('div', { class: 'card col' }, h('h3', {}, t('wlTitle')), h('div', { class: 'small muted' }, t('wlMainnetOnly'))), + h('button', { class: 'btn-ghost btn-block', onClick: () => goBack(() => { ui.giftMode = false; }) }, t('back'))); + } + const l = s.open && (s.list || []).find((x) => x.id === s.open); + if (l) return h('div', { class: 'col', style: 'gap:12px' }, linkCard(l)); + return h('div', { class: 'col', style: 'gap:12px' }, + formCard(), + listCard(), + h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.wl = null; goBack(() => { ui.giftMode = false; }); } }, t('back'))); + } + + const entry = () => h('button', { id: 'wl-open', class: 'linklike small', style: 'align-self:center;margin-top:2px', onClick: open }, 'โก ' + t('wlTitle')); + + return { view, entry, open }; +} diff --git a/src/features/zaps.js b/src/features/zaps.js index 8ac78fb..8e29409 100644 --- a/src/features/zaps.js +++ b/src/features/zaps.js @@ -533,7 +533,15 @@ export function zapsFeature(ctx) { // a login challenge needs no balance, only the wallet's key const auth = parseLnurlAuth(text); if (auth) { beginLogin(auth); return true; } - if (!canPay() || !ui.send || ui.send.recipients.length !== 1) return false; + if (!ui.send || ui.send.recipients.length !== 1) return false; + // An LNURL may be a withdraw โ it pays US, so it needs no spendable + // balance, and right after a refresh (Spending still connecting) the + // check below turned a scanned withdraw link into a "Zap LNURLโฆ" + // recipient (Adam, 2026-11-08). Only the GET can tell: take it now, + // and a pay one meets the pay checks where it's paid. + const early = parseZapTarget(text); + if (early && early.kind === 'lnurl' && !typed) { begin(early, zapDisplay(early, text), true); return true; } + if (!canPay()) return false; const tap = !typed; // scanned/pasted (a tap), not a keystroke const nof = maybeNoffer(text); if (nof) { |
