aboutsummaryrefslogtreecommitdiffstats
path: root/src/features
diff options
context:
space:
mode:
Diffstat (limited to 'src/features')
-rw-r--r--src/features/ark.js66
-rw-r--r--src/features/names.js66
-rw-r--r--src/features/zaps.js62
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.