aboutsummaryrefslogtreecommitdiffstats
path: root/names
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-04 07:59:52 -0700
committerAdam Soltys <[email protected]>2026-09-04 07:59:58 -0700
commit2d17fd652c3c2c56d6027c9271bf9b9854412677 (patch)
tree16e40643a80663cac790386cb01f445ba21f4ea6 /names
parent3e45d8007db4bddf8170fde64f7414b32bbbd98f (diff)
downloadcoinosv3-2d17fd652c3c2c56d6027c9271bf9b9854412677.tar.gz
coinosv3-2d17fd652c3c2c56d6027c9271bf9b9854412677.zip
Lightning receives to a [email protected] 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
Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01MGzU12QrAA4G6BYGzGVdXz
Diffstat (limited to 'names')
-rw-r--r--names/server.js43
1 files changed, 39 insertions, 4 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') {