diff options
| -rw-r--r-- | names/server.js | 43 | ||||
| -rw-r--r-- | src/features/ark.js | 74 | ||||
| -rw-r--r-- | src/features/names.js | 18 |
3 files changed, 125 insertions, 10 deletions
diff --git a/names/server.js b/names/server.js index 3072c8d..4b606a1 100644 --- a/names/server.js +++ b/names/server.js @@ -305,13 +305,27 @@ const arkParamOf = (uri) => { return m ? decodeURIComponent(m[1]) : null; }; +// A float arkoor delivery is indistinguishable from any other ark receive on +// the wallet's side (the mailbox protocol carries no memo), so remember each +// LN settlement we forwarded that way: the wallet asks /lnreceipts and +// matches by amount + delivery time to put the ⚡ on the right history rows. +// Bounded per name; entries age out on the invoice-retention horizon. Only +// float deliveries are recorded — a viaClink delivery runs the wallet's own +// LN-receive flow, which tags itself. +function recordLnReceipt(name, sat, hash) { + const all = state.lnReceipts ||= {}; + const cutoff = Date.now() - 7 * 86400_000; + all[name] = [...(all[name] || []).filter((r) => r.ts > cutoff), + { sat, ts: Date.now(), ...(hash ? { hash } : {}) }].slice(-50); +} + // Deliver a settled payment to the name's owner. Two ways, and we try the // one that works while they're asleep first: // 1. an arkoor send from the float — instant, free, needs no cooperation // 2. ask their wallet for an invoice and pay it over Lightning — needs no // float at all, but only works while something of theirs is listening // Failing both, it queues and retries; nothing is ever dropped silently. -async function forward(name, sat) { +async function forward(name, sat, hash) { const rec = state.names[name]; const dest = rec && arkParamOf(rec.uri); const clink = clinkDest(rec); @@ -320,6 +334,8 @@ async function forward(name, sat) { const viaFloat = async () => { if (!dest || !fwd || fwd.balance().spendableSat < sat) return false; await fwd.send(dest, sat); + recordLnReceipt(name, sat, hash); + persist(); log(`forwarded ${sat} sat to ${name}`); return true; }; @@ -463,11 +479,11 @@ async function settleLoop() { persist(); } try { - await forward(name, sat); + await forward(name, sat, inv.payment_hash); } catch (e) { log(`forward failed for ${name} (${sat} sat): ${e.message} — queued`); state.pending = state.pending || []; - state.pending.push({ name, sat, ts: Date.now() }); + state.pending.push({ name, sat, hash: inv.payment_hash, ts: Date.now() }); persist(); } } @@ -579,7 +595,7 @@ setInterval(async () => { const still = []; for (const p of q) { if (p.next && Date.now() < p.next) { still.push(p); continue; } - try { await forward(p.name, p.sat); } + try { await forward(p.name, p.sat, p.hash); } catch (e) { log(`forward retry failed for ${p.name} (${p.sat} sat, try ${(p.tries || 0) + 1}): ${e.message}`); p.tries = (p.tries || 0) + 1; @@ -870,6 +886,25 @@ Bun.serve({ } } + // Recent LN settlements forwarded to the caller's name(s) as float + // arkoors — the wallet matches them against its receive movements to + // mark which ones actually arrived over Lightning. NIP-98 authed and + // owner-only: this is a feed of incoming amounts. + if (url.pathname === '/lnreceipts' && req.method === 'GET') { + if (!rateOk(ip, 30)) return json({ error: 'rate limited' }, 429); + const a = await checkNip98(req, url, ''); + if (a.error) return json({ error: a.error }, 401); + const receipts = []; + for (const [key, rec] of Object.entries(state.names)) { + if (rec.pubkey !== a.pubkey && rec.manager !== a.pubkey) continue; + for (const r of (state.lnReceipts || {})[key] || []) { + receipts.push({ name: key, sat: r.sat, ts: r.ts, hash: r.hash || null }); + } + } + receipts.sort((x, y) => y.ts - x.ts); + return json({ receipts: receipts.slice(0, 100) }); + } + // Prefix search over registered names — powers recipient search in the // wallet's DMs. Public data (every name is a public DNS record). if (url.pathname === '/search' && req.method === 'GET') { 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 + // [email protected] 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 |
