From 2d17fd652c3c2c56d6027c9271bf9b9854412677 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Fri, 4 Sep 2026 07:59:52 -0700 Subject: Lightning receives to a name@coinos.io address now wear the ⚡ instead of the Ark mark: a faucet paying the LN address settles on the registrar's CLN and arrives as an anonymous arkoor from the float (the mailbox protocol carries no memo), so the wallet genuinely couldn't tell the rail. The registrar now records a receipt {sat, hash, ts} per LN settlement it forwards via the float (7-day ring per name) and serves them at GET /lnreceipts (NIP-98, owner/manager only — it's a feed of incoming amounts); the wallet fetches them through the names feature (which holds the signer) and fuzzy-matches receive movements by exact amount + delivery within ±3 min, stamping viaLn + the payment hash so a receipt can never claim a second row. History row and detail view show ⚡ for viaLn receives; the tag rides the sync snapshot to other devices via merge enrichment. viaClink deliveries need none of this — the wallet's own LN-receive flow already tags itself. Best-effort by design: an amount+time collision only mislabels an icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01MGzU12QrAA4G6BYGzGVdXz --- src/features/ark.js | 74 ++++++++++++++++++++++++++++++++++++++++++++++----- src/features/names.js | 18 +++++++++++++ 2 files changed, 86 insertions(+), 6 deletions(-) (limited to 'src/features') diff --git a/src/features/ark.js b/src/features/ark.js index 1426592..4c65549 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -222,6 +222,9 @@ export function mergeArkStates(a, b) { if ((rm.feeSat || 0) > 0 && !(lm.feeSat > 0)) take.feeSat = rm.feeSat; if (rm.chainFeeSat != null && lm.chainFeeSat == null) take.chainFeeSat = rm.chainFeeSat; if (rm.serviceFeeSat != null && lm.serviceFeeSat == null) take.serviceFeeSat = rm.serviceFeeSat; + // an LN-origin tag learned on another device (matched against the + // registrar's receipts there) rides the snapshot to this one + if (rm.viaLn && !lm.viaLn) { take.viaLn = true; if (rm.lnHash) take.lnHash = rm.lnHash; } return Object.keys(take).length ? { ...lm, ...take } : lm; }); // Two devices (or the push and the poll, pre-guard) can each record the @@ -357,6 +360,8 @@ export function arkFeature(ctx) { stopNwcFunding(); for (const timer of arkLnTimers.values()) clearInterval(timer); arkLnTimers.clear(); + clearTimeout(lnTagT); + lnTagSeen = -1; ark = null; arkConnectPromise = null; } @@ -459,7 +464,7 @@ export function arkFeature(ctx) { const tx = (wallet.txs || []).find((t2) => t2.txid === txid2); return tx && tx.confirmed ? { confirmed: true, block_height: tx.blockHeight || NaN } : null; }, - onUpdate: () => { maybeAutoSelectSpending(); render(); }, + onUpdate: (m) => { maybeAutoSelectSpending(); scheduleLnTag(m); render(); }, }); ui.arkError = ''; arkConnectPromise = mgr.init().then(() => { @@ -503,6 +508,7 @@ export function arkFeature(ctx) { // Receives arrive in real time over the mailbox stream; the poll is the // fallback and what drives in-flight boards/refreshes forward. mgr.startMailboxStream(); + scheduleLnTag(mgr); // receives that landed while this device was closed arkTimer = setInterval(() => { if (ark === mgr) tick(); }, getNetwork() === 'regtest' ? 5000 : 30000); render(); return mgr; @@ -529,6 +535,60 @@ export function arkFeature(ctx) { return wallet.loadArkState(cfg.ark); } + // Which receives actually arrived over Lightning? A payment to the user's + // name@coinos.io address settles on the registrar's node and lands here as + // an ordinary arkoor from its float wallet — the mailbox protocol carries + // no memo, so the movement alone can't name the rail the sender used. The + // registrar keeps a receipt per LN settlement it forwarded; matching those + // against receive movements (exact amount, delivery within a couple of + // minutes) marks the row viaLn so it wears the ⚡ the sender expects. + // Best-effort and cosmetic: a rare amount+time collision only mislabels an + // icon. The receipt's payment hash is stamped on the matched movement so a + // receipt never claims a second row on later passes. + let lnTagBusy = false, lnTagT = 0, lnTagSeen = -1; + async function tagLnReceives(mgr) { + if (lnTagBusy) return; + // nothing a receipt could match (the registrar keeps them 7 days) — + // don't spend a signed fetch on every boot of a quiet wallet + if (!(mgr.state.movements || []).some((m) => m.type === 'receive' && m.status === 'complete' + && !m.viaLn && (m.ts || 0) > Date.now() - 7 * 86400_000)) return; + lnTagBusy = true; + try { + const receipts = await Promise.resolve(ctx.hook('namesLnReceipts')).catch(() => null); + if (!receipts || !receipts.length || ark !== mgr || !mgr.state) return; + const moves = (mgr.state.movements || []) + .filter((m) => m.type === 'receive' && m.status === 'complete'); + const used = new Set(moves.filter((m) => m.lnHash).map((m) => m.lnHash)); + let changed = false; + for (const r of receipts) { + if (r.hash && used.has(r.hash)) continue; + let best = null; + for (const m of moves) { + if (m.viaLn || m.amountSat !== r.sat) continue; + const dt = Math.abs((m.ts || 0) - r.ts); + if (dt > 180_000) continue; + if (!best || dt < Math.abs((best.ts || 0) - r.ts)) best = m; + } + if (!best) continue; + best.viaLn = true; + if (r.hash) { best.lnHash = r.hash; used.add(r.hash); } + changed = true; + } + if (changed) mgr._save(); + } finally { lnTagBusy = false; } + } + // Fires off onUpdate, so gate on the thing that actually warrants a fetch: + // the count of untagged receives changed (a new receive landed, or a + // snapshot brought some in). Debounced so a catch-up burst asks once. + function scheduleLnTag(mgr) { + const n = (mgr.state.movements || []).reduce((c, m) => + c + (m.type === 'receive' && m.status === 'complete' && !m.viaLn ? 1 : 0), 0); + if (n === lnTagSeen) return; + lnTagSeen = n; + clearTimeout(lnTagT); + lnTagT = setTimeout(() => { if (ark === mgr) tagLnReceives(mgr).catch(() => {}); }, 2000); + } + // A wallet that opens onto "Savings: 0" while its money sits in Spending // is answering the wrong question — a stale device-global selection (or // the default) shouldn't hide the balance. One shot per wallet open; any @@ -1069,11 +1129,13 @@ export function arkFeature(ctx) { 'div', { class: 'item', style: 'cursor:pointer', onClick: () => { ui.arkMoveDetail = m.id; render(); } }, // The circle's glyph names the RAIL the money actually rode: ⚡ for - // Lightning, the Bitcoin mark for the on-chain movements (board pulls - // coins in from chain; offboard/exit push them back out — each is a - // real tx with a txid), and the Ark mark for pure off-chain hops. + // Lightning (including a receive the registrar forwarded from an LN + // settlement — viaLn, matched against its receipts), the Bitcoin mark + // for the on-chain movements (board pulls coins in from chain; + // offboard/exit push them back out — each is a real tx with a txid), + // and the Ark mark for pure off-chain hops. // Direction is carried by the tint + signed amount, no text chip. - m.type.startsWith('ln-') + m.type.startsWith('ln-') || m.viaLn ? h('div', { class: `ico ${incoming ? 'in' : 'out'}` }, '⚡') : ONCHAIN_ARK.has(m.type) ? h('div', { class: `ico ${incoming ? 'in' : 'out'}`, html: BITCOIN_ICON(20) }) @@ -1190,7 +1252,7 @@ export function arkFeature(ctx) { // the rail the user chose is the one to show: a payment SENT over // Lightning stays Lightning here, however it settled underneath; an // on-chain move (board/offboard/exit) wears the Bitcoin mark - m.type.startsWith('ln-') ? h('span', { style: 'font-size:18px' }, '⚡') + m.type.startsWith('ln-') || m.viaLn ? h('span', { style: 'font-size:18px' }, '⚡') : ONCHAIN_ARK.has(m.type) ? h('span', { html: BITCOIN_ICON(18) }) : h('span', { html: ARK_ICON(18) }), h('h3', { style: 'margin:0' }, label), diff --git a/src/features/names.js b/src/features/names.js index f8f97cb..bd4ab4d 100644 --- a/src/features/names.js +++ b/src/features/names.js @@ -359,6 +359,8 @@ export function namesFeature(ctx) { // Suggest the identity's own nostr username as the claim default — nobody // should have to retype who they already are — but only when that name is // actually free on the registrar (or already theirs to retake). + let lnReceipts = { at: 0, p: null }; + let suggestedFor = null; function suggestName() { const me = (hook('nostrLoginIdentity') || {}).pubkey || (wallet.nostrPubkey && wallet.nostrPubkey()); @@ -764,6 +766,22 @@ export function namesFeature(ctx) { h('button', { class: 'btn-ghost btn-block', onClick: () => goBack(() => { ui.nameEditOpen = null; ui.nameClaimError = null; }) }, t('back'))); }, namesAdoptIdentity(signer, npub) { return adoptIdentity(signer, npub); }, + // Recent LN settlements the registrar forwarded to this wallet's name(s) + // as float arkoors — the ark feature matches them against its receive + // movements to put ⚡ on the rows that actually arrived over Lightning. + // Lives here because the registrar call needs the wallet's NIP-98 + // signer. Cached briefly: a receive burst asks more than once. + namesLnReceipts() { + if (!available()) return Promise.resolve([]); + if (lnReceipts.p && Date.now() - lnReceipts.at < 30_000) return lnReceipts.p; + lnReceipts = { + at: Date.now(), + p: post('/lnreceipts', 'GET') + .then((j) => j.receipts || []) + .catch(() => { lnReceipts.at = 0; return []; }), + }; + return lnReceipts.p; + }, // the claimed payment address, for anyone prefilling a lightning address namesAddress() { const st = load(); return st.name ? `${st.name}@${st.domain || DOMAIN()}` : null; }, // Sign-in races this lookup against the seed work: by the time the -- cgit v1.2.3