aboutsummaryrefslogtreecommitdiffstats
path: root/src/features
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-30 09:31:25 -0700
committerAdam Soltys <[email protected]>2026-08-30 09:31:25 -0700
commitd7d5484b76459c068135ebd7fb33ef19adf33bc0 (patch)
tree3abb240544cbeb487e80b723d7da27e9b85c3f6d /src/features
parentce7ebbd5d057d9ef5a04b7d704d26ece2ed00ff7 (diff)
downloadcoinosv3-d7d5484b76459c068135ebd7fb33ef19adf33bc0.tar.gz
coinosv3-d7d5484b76459c068135ebd7fb33ef19adf33bc0.zip
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
Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01MrkLsNScXe9b6BviUSdmMr
Diffstat (limited to 'src/features')
-rw-r--r--src/features/ark.js30
-rw-r--r--src/features/messages.js91
-rw-r--r--src/features/nostrlogin.js10
3 files changed, 121 insertions, 10 deletions
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) decryptors.push(login.signer);
- for (const d of decryptors) {
+ 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');
+ 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; }
},