From d7d5484b76459c068135ebd7fb33ef19adf33bc0 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 30 Aug 2026 09:31:25 -0700 Subject: DMs stop losing the race with the signer, and your own address stops paying yourself: undecrypted wraps are kept and retried when a signer comes live (the backfill used to be consumed and dropped whenever the login signer hadn't resumed yet — each device kept a different random subset of history), the inbox also reads the relays our kind-10050 actually advertises, and pasting your own ark address or coinos name into Send now opens the board panel from Savings instead of a silent Spending→Spending self-send; a foreign ark address while Savings is selected says to switch instead of quietly spending the other balance 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_01MrkLsNScXe9b6BviUSdmMr --- src/features/ark.js | 30 ++++++++++++++- src/features/messages.js | 91 ++++++++++++++++++++++++++++++++++++++++++---- src/features/nostrlogin.js | 10 +++++ 3 files changed, 121 insertions(+), 10 deletions(-) (limited to 'src/features') diff --git a/src/features/ark.js b/src/features/ark.js index 5811a2d..df08e35 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -2535,8 +2535,34 @@ export function arkFeature(ctx) { if (!arkAvailable()) throw new Error(t('arkNotConnected')); const sats = ctx.parseAmount(s.recipients[0].amount, ctx.getUnit()); if (!sats || sats <= 0) throw new Error(t('enterValidAmtForN', { n: 1 })); - ui.arkSend = { address: s.recipients[0].address.trim(), amountSat: sats }; - render(); + const dest = s.recipients[0].address.trim(); + const fromSavings = ctx.getAccount() !== 'spending'; + (async () => { + // Whose address is this? The manager may still be connecting, so + // resolve async before painting a review. + let own = false; + try { const mgr = await connectArk(); own = dest === mgr.address(); } + catch (e) { ui.sendError = e.message; render(); return; } + if (own) { + // Your OWN ark address is never a payment — pasting it (or a + // coinos name that resolves to it) means "top up Spending", so + // open the board panel, funded from on-chain Savings. The old + // path made a silent Spending→Spending self-send. + ctx.goHome(); + ui.arkMoveOpen = true; + ui.arkMoveDir = 'toSpending'; + ui.arkBoardAmt = String(sats); + ui.send = blankSend(); + toast(t('arkOwnAddrBoard')); + } else if (fromSavings) { + // An ark payment spends the Spending balance — never silently + // spend a different balance than the one selected. + ui.sendError = t('arkPayFromSpending'); + } else { + ui.arkSend = { address: dest, amountSat: sats }; + } + render(); + })(); return true; } return false; diff --git a/src/features/messages.js b/src/features/messages.js index 5f74c46..42446f4 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -1079,14 +1079,38 @@ export function messagesFeature(ctx) { scheduleRepaint(); } - async function handleInboxWrap(wrap) { - if (seenWraps.has(wrap.id)) return; - seenWraps.add(wrap.id); - const decryptors = []; - if (wallet.nostr && wallet.nostr.sk) decryptors.push(wallet.nostr.sk); + // Wraps nothing could open are NOT consumed. The usual reason is the + // remote signer losing the race with the relay backfill on a fresh load + // (the login signer resumes lazily, the stored wraps arrive first), or a + // single bunker round-trip failing — and dropping the wrap there was how + // whole stretches of DM history quietly went missing, differently on every + // device. Keep them and retry as the decryptor set improves; a wrap that + // never opens (a stranger's wrap that merely p-tags us) falls out after a + // few full-strength passes. + const pendingWraps = new Map(); // wrap.id -> { wrap, tries } + const PENDING_MAX = 800; + const PENDING_TRIES = 6; + let drainTimer = 0; + let draining = false; + + function dmDecryptors() { + const out = []; + if (wallet.nostr && wallet.nostr.sk) out.push(wallet.nostr.sk); + const login = hook('nostrLoginIdentity'); + if (login && login.signer && login.signer.decryptFrom) out.push(login.signer); + return out; + } + // A "full-strength" attempt has every key this wallet expects: retries + // only count against a wrap once the login signer (if one is linked) has + // actually had its chance — the wallet key alone can never open a wrap + // sealed to the login npub. + function decryptorsComplete() { const login = hook('nostrLoginIdentity'); - if (login && login.signer && login.signer.decryptFrom) decryptors.push(login.signer); - for (const d of decryptors) { + return !login || !!(login.signer && login.signer.decryptFrom); + } + + async function openInboxWrap(wrap) { + for (const d of dmDecryptors()) { const got = await unwrapDM(wrap, d).catch(() => null); if (!got) continue; if (got.rumor.kind === 14) { @@ -1107,8 +1131,38 @@ export function messagesFeature(ctx) { scheduleRepaint(); } catch {} } - return; + return true; } + return false; + } + + async function handleInboxWrap(wrap) { + if (seenWraps.has(wrap.id)) return; + seenWraps.add(wrap.id); + if (await openInboxWrap(wrap)) return; + if (pendingWraps.size >= PENDING_MAX) pendingWraps.delete(pendingWraps.keys().next().value); + pendingWraps.set(wrap.id, { wrap, tries: decryptorsComplete() ? 1 : 0 }); + scheduleDrain(8000); + } + + function scheduleDrain(ms) { + if (!pendingWraps.size) return; + clearTimeout(drainTimer); + drainTimer = setTimeout(() => { drainPendingWraps().catch(() => {}); }, ms); + } + + async function drainPendingWraps() { + if (draining || !pendingWraps.size || !dmDecryptors().length) return; + draining = true; + const complete = decryptorsComplete(); + try { + for (const [id, p] of [...pendingWraps]) { + if (await openInboxWrap(p.wrap)) pendingWraps.delete(id); + else if (complete && ++p.tries >= PENDING_TRIES) pendingWraps.delete(id); + } + } finally { draining = false; } + // still holding wraps: the signer may connect later — keep a slow retry + if (pendingWraps.size) scheduleDrain(complete ? 30_000 : 60_000); } function openDirectBundle(json) { @@ -1144,6 +1198,22 @@ export function messagesFeature(ctx) { allUnsubs.push(subscribeOn(DM_RELAYS, { kinds: [1059], '#p': pks, limit: 400 }, (wrap) => { handleInboxWrap(wrap).catch(() => {}); })); + // Senders deliver to the relays our kind-10050 advertises — a list other + // clients may have published with relays beyond our defaults. Read those + // too, or a compliant sender's DM lands somewhere we never look (a reply + // went only to damus/primal and no device ever showed it). + (async () => { + try { + const lists = await Promise.all(pks.map((pk) => fetchInboxRelays(pk).catch(() => []))); + const extras = [...new Set(lists.flat().map((r) => String(r || '').trim().replace(/\/$/, '')))] + .filter((r) => /^wss:\/\//i.test(r) && !DM_RELAYS.includes(r)) + .slice(0, 3); + if (extras.length && dmStarted) + allUnsubs.push(subscribeOn(extras, { kinds: [1059], '#p': pks, limit: 400 }, (wrap) => { + handleInboxWrap(wrap).catch(() => {}); + })); + } catch {} + })(); ensureDmRelayList().catch(() => {}); } @@ -2601,6 +2671,9 @@ export function messagesFeature(ctx) { ui.userSearch = { q: '', rows: null }; return true; }, + // nostr-login just connected or resumed a signer: wraps the backfill + // couldn't open a moment ago can be opened now — drain right away. + nostrSignerLive() { scheduleDrain(0); }, // Anyone (ark's history, other features) can open a profile or render a // small clickable identity chip. showProfile(pk) { openProfile(pk); return true; }, @@ -2663,6 +2736,8 @@ export function messagesFeature(ctx) { threads.clear(); pendingDirect.clear(); seenWraps.clear(); // the next account must decrypt wraps this one couldn't + clearTimeout(drainTimer); + pendingWraps.clear(); dmStarted = false; listsSynced = false; }, diff --git a/src/features/nostrlogin.js b/src/features/nostrlogin.js index cef1628..8e1aa9a 100644 --- a/src/features/nostrlogin.js +++ b/src/features/nostrlogin.js @@ -78,6 +78,11 @@ export function nostrLoginFeature(ctx) { const busy = (v) => { ui.nostrLoginBusy = v; render(); }; const fail = (e) => { attaching = false; ui.nostrLoginError = e.message || String(e); busy(false); }; + // A signer just went live: features holding work only a signer can do + // (the DM inbox's undecrypted wraps) get their cue. Deferred a tick so + // the `live` assignment has settled wherever this is called from. + const announceLive = () => { setTimeout(() => { try { ctx.hook('nostrSignerLive'); } catch {} }, 0); }; + // Open the wallet a signer identifies. New accounts confirm first, because // publishing an encrypted seed to relays deserves an explicit yes. async function loginWith(makeSigner) { @@ -108,6 +113,7 @@ export function nostrLoginFeature(ctx) { const spendingHint = !!res.spending; attaching = true; live = selfHealing(signer); + announceLive(); // hand the wizard over BEFORE the screen flips: walletScreen routes // back into onboarding while ui.onb is set, so clearing it afterwards // re-animated the welcome screen for a beat before the wallet appeared @@ -140,6 +146,7 @@ export function nostrLoginFeature(ctx) { if (!existing) await publishWalletBackup(st.signer, { mnemonic }); attaching = true; live = selfHealing(st.signer); + announceLive(); ctx.onbNostrLogin(!!existing); // before the screen flips — see loginWith ui.navAnimSkip = true; // the handover swaps instantly, no fade await ctx.openMnemonic(mnemonic, (existing && existing.passphrase) || '', { nostrPubkey: st.signer.pubkey, spendingHint: !!(existing && existing.spending) }); @@ -165,6 +172,7 @@ export function nostrLoginFeature(ctx) { } await publishWalletBackup(signer, { mnemonic: wallet.mnemonic, passphrase: wallet.passphrase || '' }); live = selfHealing(signer); + announceLive(); save({ ...load(), pubkey: signer.pubkey, linked: Date.now(), ...sessionOf(signer) }); ctx.hook('namesAdoptIdentity', signer, npubOf(signer.pubkey))?.catch?.(() => {}); toast(t('nlLinked')); @@ -392,6 +400,7 @@ export function nostrLoginFeature(ctx) { throw new Error(t('nlWrongAccount', { npub: npubOf(st.pubkey) || '' })); } live = selfHealing(signer); + announceLive(); save({ ...load(), ...sessionOf(signer) }); // No toast: the badge flips to "connected" and the reconnect controls // disappear, which says it better than a pill floating over the footer. @@ -587,6 +596,7 @@ export function nostrLoginFeature(ctx) { try { const s = await resuming; if (!s) resumeFailedAt = Date.now(); + else announceLive(); return s; } finally { resuming = null; } }, -- cgit v1.2.3