aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--names/server.js43
-rw-r--r--src/features/ark.js74
-rw-r--r--src/features/names.js18
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