aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/features/ark.js74
-rw-r--r--src/features/names.js18
2 files changed, 86 insertions, 6 deletions
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