aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-29 00:31:12 -0700
committerAdam Soltys <[email protected]>2026-08-29 00:31:12 -0700
commit8ea0416c08535750839088c060d3252415510cc5 (patch)
tree2908f20062ba516464ddfc5489a65082ac7a6b6a /src
parentf06644e729ab5be2fcfa45e32f8b1de52ea36eb3 (diff)
downloadcoinosv3-8ea0416c08535750839088c060d3252415510cc5.tar.gz
coinosv3-8ea0416c08535750839088c060d3252415510cc5.zip
An address can point home: a name's owner may register their own CLINK offer code and the registrar asks THEIR node for invoices — delivered there first, float as fallback, and no invoice is relayed or paid unless it demands exactly the sats owed on the right network. hal learns to pay pasted noffer1 codes through the zap flow (fixed, variable and payer-chosen pricing, amount verified before the HTLC locks). The depth advisory slims to one line — an uncooperative exit's price and a Manage link — and the new coins screen shows each vtxo's amount, expiry and arkoor depth beside what renewal costs and buys
Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_019WboeoetMF3MTmgT7Ew8yi
Diffstat (limited to 'src')
-rw-r--r--src/clink.js50
-rw-r--r--src/features/ark.js66
-rw-r--r--src/features/names.js66
-rw-r--r--src/features/zaps.js62
-rw-r--r--src/i18n.js26
5 files changed, 251 insertions, 19 deletions
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.',