diff options
Diffstat (limited to 'src/features')
| -rw-r--r-- | src/features/ark.js | 66 | ||||
| -rw-r--r-- | src/features/names.js | 66 | ||||
| -rw-r--r-- | src/features/zaps.js | 62 |
3 files changed, 177 insertions, 17 deletions
diff --git a/src/features/ark.js b/src/features/ark.js index 90249a9..cd4fa47 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -1526,21 +1526,69 @@ export function arkFeature(ctx) { for (const v of spend) { try { depth = Math.max(depth, ark._decoded(v).genesis.length); } catch {} } if (depth < EXIT_DEPTH_ADVISORY) return null; let exitFee = 0; try { exitFee = estimateExitFeeSat(ark); } catch {} - let renewFee = 0; try { renewFee = ark.refreshFee(spend, ark._tipH || 0); } catch {} // after a renewal everything is ONE coin, ONE hop: parent + its fee // child + the claim, ~530 vB all in at today's rate const feeRate = Math.max(1, (wallet.feeRates && wallet.feeRates.halfHourFee) || 2); const afterFee = Math.ceil(530 * feeRate); if (afterFee >= exitFee) return null; // renewing wouldn't help — stay quiet return h('div', { class: 'small muted', style: 'margin:10px 0 0;text-align:center' }, - t('arkDepthNotice', { fee: fmtAmount(exitFee), renew: renewFee > 0 ? fmtAmount(renewFee) + ' sats' : t('arkDepthFree'), after: fmtAmount(afterFee) }), + t('arkDepthNotice', { fee: fmtAmount(exitFee) }), ' ', - h('button', { class: 'linklike small', disabled: !!ui.arkBusy, onClick: () => { - // fire and let the round machinery carry it — rounds can be an hour out - ark.refresh(spend.map((v) => v.id)).catch((e) => toast(e.message)); - toast(t('arkDepthRenewed')); - render(); - } }, t('arkDepthBtn'))); + h('button', { class: 'linklike small', onClick: () => { ui.arkCoinsPage = true; render(); } }, + t('arkDepthBtn'))); + } + + // The screen behind that notice's Manage link: every spendable coin with + // its expiry and arkoor depth, what an uncooperative exit costs today, and + // what a renewal costs and buys. + function arkCoinsPage() { + const mgr = ark; + const spend = ((mgr && mgr.state && mgr.state.vtxos) || []).filter((v) => v.state === 'spendable'); + const tip = (mgr && mgr._tipH) || 0; + const back = () => { ui.arkCoinsPage = null; render(); }; + if (!spend.length) { ui.arkCoinsPage = null; return null; } // nothing left to manage + const totalSat = spend.reduce((n, v) => n + v.amountSat, 0); + let exitFee = 0; try { exitFee = estimateExitFeeSat(mgr); } catch {} + let renewFee = 0; try { renewFee = mgr.refreshFee(spend, tip); } catch {} + const feeRate = Math.max(1, (wallet.feeRates && wallet.feeRates.halfHourFee) || 2); + const afterFee = Math.ceil(530 * feeRate); + const depthOf = (v) => { try { return mgr._decoded(v).genesis.length; } catch { return null; } }; + const expiresOf = (v) => (v.expiryHeight && tip + ? new Date(Date.now() + (v.expiryHeight - tip) * 600_000).toLocaleDateString() : '—'); + const renew = () => { + // fire and let the round machinery carry it — rounds can be an hour out + mgr.refresh(spend.map((v) => v.id)).catch((e) => toast(e.message)); + toast(t('arkDepthRenewed')); + back(); + }; + const cell = (s, extra = '') => h('span', { class: 'small', style: 'flex:1;' + extra }, s); + const head = (s, extra = '') => h('span', { class: 'small faint', style: 'flex:1;' + extra }, s); + return h('div', { class: 'col', style: 'gap:16px' }, + h('div', { class: 'card col', style: 'gap:8px' }, + h('h3', { style: 'margin:0' }, t('arkCoinsTitle')), + h('p', { class: 'small muted', style: 'margin:0' }, + t('arkCoinsIntro', { n: spend.length, total: fmtAmount(totalSat) + ' ' + unitLabel() })), + h('div', { class: 'col', style: 'gap:4px;margin-top:4px' }, + h('div', { class: 'row gap6' }, + head(t('arkCoinsColAmount')), head(t('arkCoinsColExpires'), 'text-align:center'), head(t('arkCoinsColDepth'), 'text-align:right')), + ...spend.map((v) => { + const d = depthOf(v); + return h('div', { class: 'row gap6' }, + cell(fmtAmount(v.amountSat) + ' ' + unitLabel()), + cell(expiresOf(v), 'text-align:center'), + cell(d == null ? '—' : d === 1 ? t('arkCoinsHop') : t('arkCoinsHops', { n: d }), 'text-align:right')); + })), + h('p', { class: 'small faint', style: 'margin:4px 0 0' }, t('arkCoinsExpiryNote'))), + h('div', { class: 'card col', style: 'gap:8px' }, + h('h4', { style: 'margin:0' }, t('arkCoinsExitTitle')), + h('p', { class: 'small muted', style: 'margin:0' }, + t('arkCoinsExitDesc', { fee: fmtAmount(exitFee), after: fmtAmount(afterFee) }))), + h('div', { class: 'card col', style: 'gap:8px' }, + h('h4', { style: 'margin:0' }, t('arkCoinsRenewTitle')), + h('p', { class: 'small muted', style: 'margin:0' }, + t('arkCoinsRenewDesc', { fee: renewFee > 0 ? fmtAmount(renewFee) + ' ' + unitLabel() : t('arkDepthFree') })), + h('button', { class: 'btn-primary btn-block', disabled: !!ui.arkBusy, onClick: renew }, t('arkDepthRenewBtn'))), + h('button', { class: 'btn-ghost btn-block', onClick: back }, t('back'))); } function autoWithdrawCard() { @@ -2357,7 +2405,7 @@ export function arkFeature(ctx) { id: 'ark', init() { _autoSelected = false; initArk(); }, stop() { stopArk(); }, - screenView() { return ui.arkExitPage ? arkExitPage() : null; }, + screenView() { return ui.arkCoinsPage ? arkCoinsPage() : ui.arkExitPage ? arkExitPage() : null; }, receiveTakeover() { const offboarded = arkOffboardedScreen(); if (offboarded) return offboarded; diff --git a/src/features/names.js b/src/features/names.js index 0291b40..2c69a84 100644 --- a/src/features/names.js +++ b/src/features/names.js @@ -12,6 +12,7 @@ // record falls back to the Lightning-address flow (zaps feature). import { resolveBip353, parsePaymentName, parseBip21 } from '../bip353.js'; +import { decodeNoffer } from '../noffer.js'; import { nip98Header } from '../nip98.js'; import { qrSvg } from '../qr.js'; import { isArkAddress } from './ark.js'; @@ -88,9 +89,12 @@ export function namesFeature(ctx) { return addr ? `bitcoin:?ark=${encodeURIComponent(addr)}` : null; } - async function claim(name, { signer, manager, quietProfile } = {}) { + async function claim(name, { signer, manager, quietProfile, noffer } = {}) { 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. + const nof = noffer !== undefined ? noffer : (load().noffer || undefined); const j = await post('/register', 'POST', { name, uri, domain: DOMAIN(), // lets the address take Lightning payments from LNURL-only wallets: @@ -99,9 +103,10 @@ export function namesFeature(ctx) { // when the login identity claims its own npub name, it nominates the // wallet key to keep the record updated afterwards ...(manager ? { manager } : {}), + ...(nof !== undefined ? { noffer: nof } : {}), }, signer); const prev = load().name; - save({ name, domain: DOMAIN(), uri, offerPk: hook('nwcOfferPubkey') || null, updated: Date.now() }); + save({ ...load(), name, domain: DOMAIN(), uri, offerPk: hook('nwcOfferPubkey') || null, noffer: nof || 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 @@ -137,7 +142,8 @@ export function namesFeature(ctx) { if (!pk) return; 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 }); + if (r && r.name) save({ ...load(), name: r.name, domain: r.domain || DOMAIN(), uri: r.uri, + ...(r.noffer !== undefined ? { noffer: r.noffer } : {}) }); } catch (e) { console.warn('names: lookup failed —', e.message); } } @@ -177,7 +183,8 @@ export function namesFeature(ctx) { fetch(`${REGISTRAR}/pubkey/${login.pubkey}?domain=${encodeURIComponent(DOMAIN())}`).then((x) => x.json()), 8000, 'registrar'); if (r && r.name && !/^npub1/.test(r.name)) { - save({ ...st, name: r.name, domain: r.domain || DOMAIN(), uri: r.uri }); + save({ ...st, name: r.name, domain: r.domain || DOMAIN(), uri: r.uri, + ...(r.noffer !== undefined ? { noffer: r.noffer } : {}) }); st = load(); } } catch {} @@ -582,6 +589,52 @@ 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) { + const st = load(); + if (!st.name) { ui.namesNofferError = t('namesNeedArk'); render(); return; } + ui.namesNofferBusy = true; ui.namesNofferError = null; render(); + try { + if (raw) { + const d = decodeNoffer(raw); + if (!d.relay) throw new Error(t('namesOwnNodeNoRelay')); + } + await claim(st.name, { noffer: raw || '' }); + ui.namesNofferInput = ''; + toast(raw ? t('namesOwnNodeSet') : t('namesOwnNodeCleared')); + } catch (e) { ui.namesNofferError = e.message; } + ui.namesNofferBusy = false; render(); + } + + function ownNodeSection() { + const st = load(); + if (!st.name) return null; + return h('div', { class: 'col', style: 'gap:8px;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); + } + function clinkPane() { const code = hook('nwcOfferString'); if (!code) return h('div', { class: 'notice err small', style: 'width:100%' }, t('namesNeedArk')); @@ -589,7 +642,8 @@ export function namesFeature(ctx) { h('div', { html: qrSvg(code) }), h('div', { class: 'addr-box break', style: 'width:100%;font-size:10px' }, code), copyBtn(code, t('copy')), - h('p', { class: 'small muted', style: 'margin:0' }, t('namesZapCodeHow'))); + h('p', { class: 'small muted', style: 'margin:0' }, t('namesZapCodeHow')), + ownNodeSection()); } // ---- more options ------------------------------------------------------- @@ -677,7 +731,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 }); + if (!st.name) save({ name: rec.name, domain: rec.domain || DOMAIN(), uri: rec.uri, noffer: rec.noffer ?? 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 ee4c6a0..7dfc272 100644 --- a/src/features/zaps.js +++ b/src/features/zaps.js @@ -14,6 +14,9 @@ import { t } from '../i18n.js'; 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 { maybeBolt11 } from '../ark/lightning.js'; export function zapsFeature(ctx) { const { h, ui, render, wallet, blankSend, parseAmount, getUnit, unitTag, hook, toast, fmtAmount, unitLabel } = ctx; @@ -23,6 +26,14 @@ export function zapsFeature(ctx) { const shortNpub = (npub) => (npub && npub.length > 16 ? npub.slice(0, 12) + '…' + npub.slice(-6) : npub); + // A pasted CLINK offer code (`noffer1…`) — decoded, or null. The bech32 + // checksum means a partial paste never matches, so auto-advance is safe. + function maybeNoffer(text) { + const s = String(text || '').trim().replace(/^lightning:/i, '').toLowerCase(); + if (!/^noffer1/.test(s)) return null; + try { return { ...decodeNoffer(s), raw: s }; } catch { return null; } + } + // Kick off resolution of a parsed target. `display` is what we show as the // recipient until a profile name arrives. function begin(target, display) { @@ -35,6 +46,30 @@ export function zapsFeature(ctx) { async function resolve(z) { let target = z.target; + // A CLINK offer needs no HTTP at all: the code itself says who to ask. + if (target.kind === 'noffer') { + const n = target.noffer; + // pricing 1 = the service prices the payment itself: ask for an invoice + // with no amount and let the pay review screen show what came back — + // the user still approves the actual sats before anything is locked. + if (n.priceType === 1) { + z.status = 'invoicing'; render(); + const invoice = await requestOfferInvoice(n, {}); + if (ui.zap !== z) return; + const dec = maybeBolt11(invoice); + if (!dec || !dec.amountSat) throw new Error(t('clinkBadInvoice')); + ui.zap = null; + hook('startLnPay', invoice, { name: z.name, address: z.address, pk: null, comment: '', isZap: false }); + render(); + return; + } + const fixedSat = n.priceType === 0 && n.priceSats ? n.priceSats : null; + if (fixedSat) z.amount = getUnit() === 'sats' ? String(fixedSat) : (fixedSat / 1e8).toFixed(8); + z.params = { minSendable: 1000, maxSendable: 21e15, commentAllowed: 100, allowsNostr: false, fixedSat }; + z.status = 'ready'; + render(); + return; + } // An npub needs a profile lookup to find its lud16/lud06. if (target.kind === 'npub') { const profile = await wallet.nostrProfile(target.pk); @@ -85,7 +120,7 @@ export function zapsFeature(ctx) { async function confirm() { const z = ui.zap; if (!z || z.status !== 'ready') return; - const sats = parseAmount(z.amount, getUnit()); + const sats = z.params.fixedSat || parseAmount(z.amount, getUnit()); const p = z.params; if (!sats || sats <= 0) { z.error = t('enterValidAmtForN', { n: 1 }); return render(); } const msat = sats * 1000; @@ -95,6 +130,20 @@ export function zapsFeature(ctx) { return render(); } z.error = ''; z.status = 'invoicing'; render(); + if (z.target.kind === 'noffer') { + try { + const invoice = await requestOfferInvoice(z.target.noffer, { amountSat: sats, description: z.comment }); + // Never pay on trust: the invoice must say exactly the sats agreed. + const dec = maybeBolt11(invoice); + if (!dec || dec.amountMsat !== BigInt(sats) * 1000n) throw new Error(t('clinkBadInvoice')); + ui.zap = null; + hook('startLnPay', invoice, { name: z.name, address: z.address, pk: null, comment: z.comment, isZap: false }); + render(); + } catch (e) { + z.status = 'ready'; z.error = e.message; render(); + } + return; + } try { // Attribute a real zap only when the server supports it and we know the // recipient's nostr key; otherwise fall back to a plain LNURL-pay. @@ -171,11 +220,14 @@ export function zapsFeature(ctx) { h('div', { class: 'col gap6' }, h('div', { class: 'input-group' }, h('input', { type: 'number', min: '0', inputmode: 'decimal', placeholder: t('lnPayAmount'), value: z.amount, + disabled: !!p.fixedSat, onInput: (e) => { z.amount = e.target.value; render(); } }), h('div', { style: 'display:flex;align-items:center' }, unitTag())), broke ? h('div', { class: 'notice info' }, t('zapNoBalance')) - : h('div', { class: 'small faint' }, `Min ${min.toLocaleString()} · max ${max.toLocaleString()} sats`), + : p.fixedSat + ? h('div', { class: 'small faint' }, t('clinkFixedAmount')) + : h('div', { class: 'small faint' }, `Min ${min.toLocaleString()} · max ${max.toLocaleString()} sats`), commentOk ? h('input', { type: 'text', class: 'mono-input', placeholder: t('arkZapCommentPh'), value: z.comment, maxlength: p.allowsNostr ? '280' : String(p.commentAllowed || 280), @@ -195,6 +247,12 @@ export function zapsFeature(ctx) { // A lightning address / lnurl (always), or an npub that Ark didn't claim. matchSendText(text, typed) { 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); + begin({ kind: 'noffer', noffer: nof, address: short }, short); + return true; + } const target = parseZapTarget(text); if (!target) return false; // npub with no nostr seam to look up a profile → can't resolve here. |
