diff options
| -rw-r--r-- | names/server.js | 104 | ||||
| -rw-r--r-- | src/clink.js | 50 | ||||
| -rw-r--r-- | src/features/ark.js | 66 | ||||
| -rw-r--r-- | src/features/names.js | 66 | ||||
| -rw-r--r-- | src/features/zaps.js | 62 | ||||
| -rw-r--r-- | src/i18n.js | 26 |
6 files changed, 330 insertions, 44 deletions
diff --git a/names/server.js b/names/server.js index 406bcf2..bf90b1a 100644 --- a/names/server.js +++ b/names/server.js @@ -28,6 +28,8 @@ import { mnemonicToSeedSync } from '@scure/bip39'; import { lnBackend } from '../bridge/ln.js'; import { ArkManager } from '../src/ark/manager.js'; import { decodeAddress } from '../src/ark/proto.js'; +import { decodeNoffer } from '../src/noffer.js'; +import { decodeBolt11 } from '../src/ark/lightning.js'; const CFG = JSON.parse(readFileSync(process.env.NAMES_CONFIG || join(import.meta.dir, 'config.json'), 'utf8')); @@ -179,21 +181,24 @@ const lnurlMeta = (address) => JSON.stringify([ ['text/identifier', address], ]); -// Ask the recipient's own wallet for an invoice (CLINK offer, kind 21001). -function requestInvoiceFromWallet(offerPk, { amountSat, description, zap }) { +// Ask the recipient's own CLINK service for an invoice (kind 21001). +// dest: { pubkey, relays, offerId } — a hal wallet listening on our relays, +// or an external service (Lightning.Pub beside the user's own node) on the +// relay its noffer names. +function requestInvoiceFromWallet(dest, { amountSat, description, zap }) { return new Promise((resolve) => { let done = false; const finish = (v) => { if (!done) { done = true; try { sub.close(); } catch {} resolve(v); } }; - const key = nip44.getConversationKey(SERVICE_SK, offerPk); - const payload = { offer: 'zap_default', amount_sats: amountSat }; + const key = nip44.getConversationKey(SERVICE_SK, dest.pubkey); + const payload = { offer: dest.offerId, amount_sats: amountSat }; if (description) payload.description = description.slice(0, 100); if (zap) payload.zap = zap; const req = finalizeEvent({ kind: OFFER_KIND, created_at: Math.floor(Date.now() / 1000), - tags: [['p', offerPk], ['clink_version', '1']], + tags: [['p', dest.pubkey], ['clink_version', '1']], content: nip44.encrypt(JSON.stringify(payload), key), }, SERVICE_SK); - const sub = pool.subscribeMany(LNURL_RELAYS, + const sub = pool.subscribeMany(dest.relays, { kinds: [OFFER_KIND], '#e': [req.id], since: Math.floor(Date.now() / 1000) - 5 }, { onevent: (ev) => { @@ -203,11 +208,28 @@ function requestInvoiceFromWallet(offerPk, { amountSat, description, zap }) { } catch {} }, }); - Promise.allSettled(pool.publish(LNURL_RELAYS, req)).catch(() => {}); - setTimeout(() => finish(null), 6000); // wallets that aren't listening + Promise.allSettled(pool.publish(dest.relays, req)).catch(() => {}); + setTimeout(() => finish(null), 6000); // services that aren't listening }); } +// Where CLINK invoice requests for this name go. An external noffer the +// owner registered wins over the hal wallet's own offer key — pointing the +// name at their node is an explicit choice. +const clinkDest = (rec) => (rec?.noffer + ? { pubkey: rec.noffer.pubkey, relays: [...new Set([rec.noffer.relay, ...LNURL_RELAYS])], offerId: rec.noffer.offerId || '' } + : rec?.offerPk ? { pubkey: rec.offerPk, relays: LNURL_RELAYS, offerId: 'zap_default' } : null); + +// An invoice a CLINK service hands back is only usable if it demands exactly +// the sats owed on the right network — paying (or relaying) one on trust +// would let a hostile service name any amount. +function usableInvoice(pr, sat, network = null) { + try { + const dec = decodeBolt11(pr); + return dec.amountMsat === BigInt(sat) * 1000n && (!network || dec.network === network); + } catch { return false; } +} + const ln = CFG.ln ? lnBackend(CFG.ln) : null; // Optional mutinynet CLN — lets staging sell (play-money) hats. Nothing else // uses it: names, offers and forwards remain mainnet affairs. @@ -285,21 +307,31 @@ const arkParamOf = (uri) => { async function forward(name, sat) { const rec = state.names[name]; const dest = rec && arkParamOf(rec.uri); - if (!dest && !rec?.offerPk) { log(`forward: nowhere to send ${sat} sat for ${name}`); return; } + const clink = clinkDest(rec); + if (!dest && !clink) { log(`forward: nowhere to send ${sat} sat for ${name}`); return; } - if (dest && fwd && fwd.balance().spendableSat >= sat) { + const viaFloat = async () => { + if (!dest || !fwd || fwd.balance().spendableSat < sat) return false; await fwd.send(dest, sat); log(`forwarded ${sat} sat to ${name}`); - return; - } - - if (rec?.offerPk && ln) { - const pr = await requestInvoiceFromWallet(rec.offerPk, { amountSat: sat, description: `${name} payment` }); - if (pr) { - await ln.pay(pr, { maxfeeSat: Math.max(2, Math.ceil(sat * 0.005)) }); - log(`delivered ${sat} sat to ${name} over lightning (no float needed)`); - return; + return true; + }; + const viaClink = async () => { + if (!clink || !ln) return false; + const pr = await requestInvoiceFromWallet(clink, { amountSat: sat, description: `${name} payment` }); + if (!pr) return false; + if (!usableInvoice(pr, sat, 'mainnet')) { + log(`forward: ${name}'s service returned an unusable invoice — refusing to pay it`); + return false; } + await ln.pay(pr, { maxfeeSat: Math.max(2, Math.ceil(sat * 0.005)) }); + log(`delivered ${sat} sat to ${name} over lightning (no float needed)`); + return true; + }; + // A name pointed at its owner's own node delivers there first; the float + // is the fallback. Everyone else gets the instant free arkoor send first. + for (const step of rec?.noffer ? [viaClink, viaFloat] : [viaFloat, viaClink]) { + if (await step()) return; } throw new Error(fwd && dest ? 'insufficient float and the wallet is not listening' : 'no delivery route'); } @@ -753,7 +785,7 @@ Bun.serve({ if (m && req.method === 'GET') { const domain = (url.searchParams.get('domain') || DOMAIN).toLowerCase(); const rec = state.names[`${m[1]}@${domain}`]; - if (rec) return json({ name: m[1], domain, taken: true, pubkey: rec.pubkey, manager: rec.manager || null, uri: rec.uri }); + if (rec) return json({ name: m[1], domain, taken: true, pubkey: rec.pubkey, manager: rec.manager || null, uri: rec.uri, noffer: rec.noffer?.raw || null }); const reserved = RESERVED.has(m[1]) || !NAME_RE.test(m[1]) || await takenByCoinosUser(domain, m[1]); return json({ name: m[1], domain, taken: reserved, reserved }); } @@ -777,7 +809,7 @@ Bun.serve({ if (!mine.length) return json({}); const [key, r] = mine[0]; const [name, domain] = key.split('@'); - return json({ name, domain, uri: r.uri }); + return json({ name, domain, uri: r.uri, noffer: r.noffer?.raw || null }); } // --- hats ------------------------------------------------------------ @@ -917,10 +949,16 @@ Bun.serve({ const zap = url.searchParams.get('nostr') || null; const comment = (url.searchParams.get('comment') || '').slice(0, 200); - // 1. the recipient's own wallet, if it is listening - if (rec.offerPk) { - const pr = await requestInvoiceFromWallet(rec.offerPk, { amountSat: sat, description: comment, zap }); - if (pr) { log(`lnurl ${key}: recipient minted ${sat} sat`); return json({ pr, routes: [] }); } + // 1. the recipient's own wallet or node service, if it is listening + const clink = clinkDest(rec); + if (clink) { + const pr = await requestInvoiceFromWallet(clink, { amountSat: sat, description: comment, zap }); + // staging wallets mint signet invoices — only mainnet is checked + if (pr && usableInvoice(pr, sat, domain === 'staging.coinos.io' ? null : 'mainnet')) { + log(`lnurl ${key}: recipient minted ${sat} sat`); + return json({ pr, routes: [] }); + } + if (pr) log(`lnurl ${key}: recipient returned an unusable invoice — minting instead`); } // Staging names are mutinynet wallets: only their own wallet can mint a // right-network invoice. Minting on our mainnet node would take real @@ -1056,6 +1094,20 @@ Bun.serve({ } } if (/[?&]lno=/.test(claim.uri)) return json({ error: 'lno is added by the registrar' }, 400); + // Delivery noffer: absent = keep, '' = clear, a code = point Lightning + // delivery at the owner's own CLINK service (their node's invoice + // minter). Decoded here so a typo can't become a delivery black hole. + let noffer = existing?.noffer; + if (claim.noffer !== undefined) { + if (!claim.noffer) noffer = undefined; + else { + try { + const d = decodeNoffer(String(claim.noffer)); + if (!/^wss?:\/\/./.test(d.relay || '')) throw new Error('offer code names no relay'); + noffer = { pubkey: d.pubkey, relay: d.relay, offerId: d.offerId || '', raw: String(claim.noffer) }; + } catch (e) { return json({ error: 'bad noffer: ' + e.message }, 400); } + } + } // A corrupt ark address recorded here is a payment black hole: forwards // fail forever while the sats sit on the node (goyslop's 190k sat sat // behind an invalid checksum). Refuse it at the door. @@ -1080,6 +1132,8 @@ Bun.serve({ uri: published, recordId, updated: Date.now(), domain, // the wallet's CLINK offer key, so LNURL can ask it for invoices offerPk: (claim.offerPk && /^[0-9a-f]{64}$/.test(claim.offerPk)) ? claim.offerPk : existing?.offerPk, + // the owner's own CLINK service, when they pointed delivery at it + ...(noffer ? { noffer } : {}), offerId: offer?.offerId, bolt12: offer?.bolt12, }; persist(); diff --git a/src/clink.js b/src/clink.js new file mode 100644 index 0000000..9f381c1 --- /dev/null +++ b/src/clink.js @@ -0,0 +1,50 @@ +// CLINK payer — ask an offer code's service for an invoice over nostr. +// The counterpart of src/noffer.js (the codec) and the responder in +// features/nwc.js: a NIP-44-encrypted kind-21001 request goes to the service +// pubkey on the relay the code names, and {bolt11} (or {error}) comes back. + +import { + nip44, finalizeEvent, generateSecretKey, publishOn, subscribeOn, +} from './nostr.js'; + +const OFFER_KIND = 21001; + +// dest: a decoded noffer ({ pubkey, relay, offerId }). Signs with a throwaway +// key, so paying reveals no identity to the service. amountSat may be omitted +// for offers that price themselves (the returned invoice carries the amount). +// Resolves the bolt11 string; rejects on a service error or silence. +export function requestOfferInvoice(dest, { amountSat, description, timeoutMs = 12000 } = {}) { + return new Promise((resolve, reject) => { + const relays = [dest.relay].filter(Boolean); + if (!relays.length) return reject(new Error('offer code names no relay')); + const sk = generateSecretKey(); + const key = nip44.getConversationKey(sk, dest.pubkey); + const payload = { offer: dest.offerId || '' }; + if (amountSat) payload.amount_sats = Math.floor(amountSat); + if (description) payload.description = String(description).slice(0, 100); + const req = finalizeEvent({ + kind: OFFER_KIND, created_at: Math.floor(Date.now() / 1000), + tags: [['p', dest.pubkey], ['clink_version', '1']], + content: nip44.encrypt(JSON.stringify(payload), key), + }, sk); + let done = false; + let close = () => {}; + const finish = (err, bolt11) => { + if (done) return; + done = true; + clearTimeout(timer); + close(); + err ? reject(err) : resolve(bolt11); + }; + const timer = setTimeout(() => finish(new Error('the recipient’s service did not answer')), timeoutMs); + // subscribe before publishing — a fast service can answer within the RTT + close = subscribeOn(relays, { kinds: [OFFER_KIND], '#e': [req.id], since: Math.floor(Date.now() / 1000) - 5 }, (ev) => { + try { + const body = JSON.parse(nip44.decrypt(ev.content, key)); + if (body.bolt11) return finish(null, body.bolt11); + if (body.error) return finish(new Error(body.error)); + } catch {} + }); + publishOn(relays, req).catch(() => {}); + }); +} 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. diff --git a/src/i18n.js b/src/i18n.js index ec151f6..ca7ded5 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -201,10 +201,23 @@ const STR = { onbBoardBody: 'Move some sats from Savings into Spending — payments there are instant, with no mining fees.', topUpFromSavings: 'Top up from Savings', arkRenewedHistory: 'Renewed', - arkDepthNotice: 'Self-custody exit currently costs about {fee} sats in mining fees. Renew for {renew} to bring that down to about {after} sats.', + arkDepthNotice: 'Uncooperative exit costs about {fee} sats.', arkDepthFree: 'free', - arkDepthBtn: 'Renew now', + arkDepthBtn: 'Manage', arkDepthRenewed: 'Renewal requested', + arkDepthRenewBtn: 'Renew now', + arkCoinsTitle: 'Spending coins', + arkCoinsIntro: 'Your Spending balance lives in {n} coins totalling {total}. Each can be pulled on-chain without the server’s help — at a cost that grows with the coin’s history.', + arkCoinsColAmount: 'Amount', + arkCoinsColExpires: 'Expires', + arkCoinsColDepth: 'Depth', + arkCoinsHop: '1 hop', + arkCoinsHops: '{n} hops', + arkCoinsExpiryNote: 'Past its expiry a coin can be reclaimed by the server — the wallet renews on its own well before that.', + arkCoinsExitTitle: 'Uncooperative exit', + arkCoinsExitDesc: 'If the server vanished today, publishing every transaction in each coin’s history would cost about {fee} sats in mining fees. After a renewal it would be one fresh coin, one hop: about {after} sats.', + arkCoinsRenewTitle: 'Renewal', + arkCoinsRenewDesc: 'Renewing asks the server to replace your coins with a single fresh one in its next round (which can be up to an hour out). That flattens the history, restarts the expiry clock, and costs {fee}.', arkUniUneconomical: 'A trustless on-chain exit would cost about {fee} sats in mining fees — more than the {n} sats it would move, so it isn’t offered. For a balance this size, send it out over Lightning instead: no mining fees at all.', arkUniFallbackBody: 'The server didn’t cooperate, but your money can still move without it: your whole Spending balance exits to Savings on-chain. It takes about a day, and roughly {fee} sats of mining fees will be paid from Savings. Funds land in Savings — send them on from there once it completes.', arkUniFallbackShort: 'Savings holds {have} sats — about {need} sats are needed for the mining fees. Top up Savings first.', @@ -719,6 +732,15 @@ const STR = { namesZapCodeCopy: 'Copy code', namesOwnDomain: 'Use your own domain', namesOwnDomainHow: 'Have your own domain? Add this line to its DNS records (DNSSEC must be enabled) and your address works there too:', + namesOwnNode: 'Deliver to your own node', + namesOwnNodeHow: 'Run your own Lightning node? Paste the CLINK offer code (noffer1…) from a service beside it — Lightning payments to your address will be delivered straight to your node instead of into this wallet.', + namesOwnNodeActive: 'Lightning payments to your address are delivered to your own node:', + namesOwnNodeClear: 'Deliver to this wallet again', + namesOwnNodeSet: 'Delivery updated — payments go to your node', + namesOwnNodeCleared: 'Payments land in this wallet again', + namesOwnNodeNoRelay: 'That offer code names no relay', + clinkBadInvoice: 'The recipient’s service returned an invoice that doesn’t match', + clinkFixedAmount: 'This offer has a fixed price', nwcTitle: 'Nostr Wallet Connect', nwcDesc: 'Let another app pay from this wallet over nostr — it never sees your keys, only spends within the limits you set, and you can cut it off at any time.', nwcTabWarning: 'Works while Coinos is open — turn on background answering below to keep working when it isn’t.', |
