aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-30 13:01:39 -0700
committerAdam Soltys <[email protected]>2026-08-30 13:01:39 -0700
commit80179a2e3bdc94a83a5969d5957af9eb2f051e50 (patch)
tree805444602fa0acb45af85312809e4e36a21727aa /src
parentd7d5484b76459c068135ebd7fb33ef19adf33bc0 (diff)
downloadcoinosv3-80179a2e3bdc94a83a5969d5957af9eb2f051e50.tar.gz
coinosv3-80179a2e3bdc94a83a5969d5957af9eb2f051e50.zip
The Amethyst signer stops dying every time a stranger's wrap fails to decrypt: only connection-shaped failures (with a real per-op deadline now — a backgrounded signer app used to just hang the call) drop the live bunker, the reconnect surfaces offer only the door the account actually came through (a recorded 'via', so an Amethyst-linked npub no longer stares at Google and passkey buttons), the settings card quietly reattaches from the stored session instead of waiting for a tap, and the DM retry drain eases to three full-strength passes per wrap
Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01MrkLsNScXe9b6BviUSdmMr
Diffstat (limited to 'src')
-rw-r--r--src/features/messages.js5
-rw-r--r--src/features/nostrlogin.js141
-rw-r--r--src/nostr-login.js17
3 files changed, 112 insertions, 51 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index 42446f4..1766e79 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -1089,7 +1089,10 @@ export function messagesFeature(ctx) {
// few full-strength passes.
const pendingWraps = new Map(); // wrap.id -> { wrap, tries }
const PENDING_MAX = 800;
- const PENDING_TRIES = 6;
+ // Full-strength attempts before giving a wrap up. Kept low on purpose: a
+ // bunker signer pays two relay round-trips per attempt, and a wrap that a
+ // complete decryptor set failed to open twice is essentially never ours.
+ const PENDING_TRIES = 3;
let drainTimer = 0;
let draining = false;
diff --git a/src/features/nostrlogin.js b/src/features/nostrlogin.js
index 8e1aa9a..b891572 100644
--- a/src/features/nostrlogin.js
+++ b/src/features/nostrlogin.js
@@ -43,10 +43,19 @@ export function nostrLoginFeature(ctx) {
// signer object that will never answer again, and every send after that
// fails until the page is reloaded. Notice the first failure and drop it, so
// the next attempt resumes a fresh connection instead.
+ //
+ // But only a CONNECTION-shaped failure. A remote signer answering "can't
+ // decrypt that" is a healthy connection doing its job — the DM inbox probes
+ // it with wraps that turn out to be someone else's, and treating each
+ // refusal as a dead signer disconnected Amethyst users over and over.
+ const SIGNER_DEAD = /did not answer|timed? ?out|connect|closed|socket|relay|network/i;
const selfHealing = (signer) => {
if (!signer || signer.kind !== 'bunker') return signer;
const guard = (fn) => async (...args) => {
- try { return await fn(...args); } catch (e) { if (live === wrapped) live = null; throw e; }
+ try { return await fn(...args); } catch (e) {
+ if (live === wrapped && SIGNER_DEAD.test(e?.message || '')) live = null;
+ throw e;
+ }
};
const wrapped = {
...signer,
@@ -75,6 +84,18 @@ export function nostrLoginFeature(ctx) {
return { session: signer.session || null };
};
+ // HOW this account was connected — so the reconnect surfaces can offer the
+ // matching door instead of every door. A Google or passkey flow is running
+ // when ui.nostrLoginVia says so (Google hands us a bunker, passkey a raw
+ // key, so the signer kind alone can't tell); otherwise the signer speaks
+ // for itself.
+ const viaOf = (signer) =>
+ ui.nostrLoginVia === 'google' || ui.nostrLoginVia === 'passkey'
+ ? ui.nostrLoginVia
+ : signer && signer.kind === 'bunker' ? 'bunker'
+ : signer && signer.kind === 'extension' ? 'extension'
+ : 'key';
+
const busy = (v) => { ui.nostrLoginBusy = v; render(); };
const fail = (e) => { attaching = false; ui.nostrLoginError = e.message || String(e); busy(false); };
@@ -125,7 +146,7 @@ export function nostrLoginFeature(ctx) {
new Promise((r) => setTimeout(() => r(null), 1500)),
]);
await ctx.openMnemonic(res.mnemonic, res.passphrase || '', { nostrPubkey: signer.pubkey, spendingHint, nameHint });
- save({ ...load(), pubkey: signer.pubkey, linked: Date.now(), ...sessionOf(signer) });
+ save({ ...load(), pubkey: signer.pubkey, linked: Date.now(), via: viaOf(signer), ...sessionOf(signer) });
attaching = false;
// claim the real npub as the payment address while this signer is live
ctx.hook('namesAdoptIdentity', signer, npubOf(signer.pubkey))?.catch?.(() => {});
@@ -150,7 +171,7 @@ export function nostrLoginFeature(ctx) {
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) });
- save({ ...load(), pubkey: st.signer.pubkey, linked: Date.now(), ...sessionOf(st.signer) });
+ save({ ...load(), pubkey: st.signer.pubkey, linked: Date.now(), via: viaOf(st.signer), ...sessionOf(st.signer) });
attaching = false;
ctx.hook('namesAdoptIdentity', st.signer, npubOf(st.signer.pubkey))?.catch?.(() => {});
busy(false);
@@ -173,7 +194,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) });
+ save({ ...load(), pubkey: signer.pubkey, linked: Date.now(), via: viaOf(signer), ...sessionOf(signer) });
ctx.hook('namesAdoptIdentity', signer, npubOf(signer.pubkey))?.catch?.(() => {});
toast(t('nlLinked'));
busy(false);
@@ -323,12 +344,22 @@ export function nostrLoginFeature(ctx) {
// The ways in, as buttons. `run` receives a signer factory. The expanded
// "Log in with Nostr" card passes nostrOnly — Google and passkey already
// stand on the front door, and repeating them under a Nostr heading reads
- // as noise — while link/reconnect keep them: an account born from a Google
- // or passkey sign-in needs the same door to get back in.
- function signerButtons(run, { nostrOnly = false } = {}) {
+ // as noise. Linking keeps them: a different account may live behind either.
+ //
+ // RECONNECTING is narrower: the account is known, so only the door it came
+ // through belongs. `reconnectVia` says which — Google/passkey show their
+ // button, everything else (bunker, extension, key) gets the nostr surfaces
+ // only; an Amethyst user staring at a Google button was signing into the
+ // wrong identity at worst and noise at best. 'legacy' (pre-`via` state with
+ // no stored session) can't be told apart from a passkey account, so it
+ // keeps every door.
+ function signerButtons(run, { nostrOnly = false, reconnectVia = null } = {}) {
const hasExt = typeof window !== 'undefined' && !!window.nostr;
+ const externals = nostrOnly ? []
+ : reconnectVia && !['google', 'passkey', 'legacy'].includes(reconnectVia) ? []
+ : externalButtons(run);
return h('div', { class: 'col', style: 'gap:8px' },
- ...(nostrOnly ? [] : externalButtons(run)),
+ ...externals,
hasExt
? h('button', { class: 'btn-block', disabled: ui.nostrLoginBusy,
onClick: () => run(() => extensionSigner()) }, t('nlExtension'))
@@ -401,7 +432,7 @@ export function nostrLoginFeature(ctx) {
}
live = selfHealing(signer);
announceLive();
- save({ ...load(), ...sessionOf(signer) });
+ save({ ...load(), via: viaOf(signer), ...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.
busy(false);
@@ -470,6 +501,52 @@ export function nostrLoginFeature(ctx) {
signerButtons(linkOpenWallet));
}
+ // The silent reattach (extension first, then a stored bunker session) —
+ // callable from anywhere that notices the signer missing. One attempt at a
+ // time, and a pause after a failure: identity() asks on every send, and a
+ // signer that isn't answering shouldn't mean a new relay connection per
+ // keystroke.
+ async function resumeLogin() {
+ if (live) return live;
+ const st = load();
+ if (!st.pubkey) return null;
+ if (resuming) return resuming;
+ if (Date.now() - resumeFailedAt < 20_000) return null;
+ resuming = (async () => {
+ if (typeof window !== 'undefined' && window.nostr) {
+ try {
+ const s = await extensionSigner();
+ if (s.pubkey === st.pubkey) return (live = s);
+ } catch {}
+ }
+ if (st.session) {
+ try {
+ const s = await resumeBunker(st.session, { onAuth: (url) => { ui.nostrLoginAuthUrl = url; render(); } });
+ if (s && s.pubkey === st.pubkey) return (live = selfHealing(s));
+ } catch {}
+ }
+ return null;
+ })();
+ try {
+ const s = await resuming;
+ if (!s) resumeFailedAt = Date.now();
+ else announceLive();
+ return s;
+ } finally { resuming = null; }
+ }
+
+ // Which door this account came through, for the reconnect surfaces. State
+ // saved before `via` existed: a stored session means a bunker; without one
+ // it could be extension, key, or passkey — offer everything.
+ const reconnectViaOf = (st) => st.via || (st.session ? 'bunker' : 'legacy');
+
+ // Fire-and-forget resume for render paths: heal the badge without anyone
+ // tapping, repaint when it lands. resumeLogin's own gating keeps this from
+ // dialing on every render.
+ function resumeQuietly() {
+ resumeLogin().then((s) => { if (s) render(); }).catch(() => {});
+ }
+
function settingsCard() {
if (wallet.watchOnly || !wallet.mnemonic) return null;
const st = load();
@@ -482,10 +559,13 @@ export function nostrLoginFeature(ctx) {
h('p', { class: 'small faint', style: 'margin:0' }, t('nlLinkedDesc')),
// Signing needs a live signer, and a reload always drops a remote one.
// Without this the app can tell you it isn't connected and offer you
- // nowhere to fix it.
- live ? null : h('div', { class: 'col', style: 'gap:8px' },
+ // nowhere to fix it. A stored session or an installed extension can
+ // usually reattach without the user doing anything — try that quietly
+ // whenever the card paints disconnected (the resume path rate-limits
+ // itself), and only the doors this account came through are offered.
+ live ? null : (resumeQuietly(), h('div', { class: 'col', style: 'gap:8px' },
h('p', { class: 'small muted', style: 'margin:0' }, t('nlReconnectDesc')),
- signerButtons(reconnectSigner)));
+ signerButtons(reconnectSigner, { reconnectVia: reconnectViaOf(st) }))));
}
// No external account linked — but the wallet still HAS a nostr account:
// the one derived from its seed, already signing chat and holding the
@@ -516,7 +596,7 @@ export function nostrLoginFeature(ctx) {
h('div', { class: 'card col', style: 'gap:12px' },
h('h3', { style: 'margin:0' }, t('nlReconnectTitle')),
h('p', { class: 'small muted', style: 'margin:0' }, t('nlReconnectBody', { npub: (npubOf(st.pubkey) || '').slice(0, 12) })),
- signerButtons(run),
+ signerButtons(run, { reconnectVia: reconnectViaOf(st) }),
ui.nostrLoginError ? h('div', { class: 'notice err' }, ui.nostrLoginError) : null),
h('button', { class: 'btn-ghost btn-block', onClick: () => {
stopNostrConnect();
@@ -568,38 +648,9 @@ export function nostrLoginFeature(ctx) {
// A page reload loses the signer. An installed extension can usually be
// re-attached without prompting, which is what lets the payment address
// stay tied to the user's real identity across reloads. Returns null for
- // signers we cannot silently reattach (pasted keys, bunkers).
- async nostrLoginResume() {
- if (live) return live;
- const st = load();
- if (!st.pubkey) return null;
- // One attempt at a time, and a pause after a failure: identity() asks on
- // every send, and a signer that isn't answering shouldn't mean a new
- // relay connection per keystroke.
- if (resuming) return resuming;
- if (Date.now() - resumeFailedAt < 20_000) return null;
- resuming = (async () => {
- if (typeof window !== 'undefined' && window.nostr) {
- try {
- const s = await extensionSigner();
- if (s.pubkey === st.pubkey) return (live = s);
- } catch {}
- }
- if (st.session) {
- try {
- const s = await resumeBunker(st.session, { onAuth: (url) => { ui.nostrLoginAuthUrl = url; render(); } });
- if (s && s.pubkey === st.pubkey) return (live = selfHealing(s));
- } catch {}
- }
- return null;
- })();
- try {
- const s = await resuming;
- if (!s) resumeFailedAt = Date.now();
- else announceLive();
- return s;
- } finally { resuming = null; }
- },
+ // signers we cannot silently reattach (pasted keys, bunkers without a
+ // stored session).
+ nostrLoginResume() { return resumeLogin(); },
unlockExtra() { return unlockExtra(); },
// The welcome screen's sign-in block: Google and passkey act right there;
// the Nostr button steps into the wizard's signer list.
diff --git a/src/nostr-login.js b/src/nostr-login.js
index 514c36b..4cc1ddf 100644
--- a/src/nostr-login.js
+++ b/src/nostr-login.js
@@ -145,17 +145,24 @@ export async function resumeBunker(session, { onAuth, timeoutMs = 8000 } = {}) {
}
function bunkerAdapter(signer, pubkey, local) {
+ // A dead transport (relay socket gone, signer app killed in the background)
+ // makes a NIP-46 call hang forever, not fail — so every operation carries
+ // its own deadline. The distinct message lets callers tell "the signer
+ // never answered" (connection is dead, reconnect) from "the signer said
+ // no" (an answered error — the connection is fine).
+ const answered = (p, ms = 20_000) => Promise.race([p,
+ new Promise((_, rej) => setTimeout(() => rej(new Error('signer did not answer')), ms))]);
return {
kind: 'bunker', pubkey, label: 'remote signer',
// Everything needed to rebuild this connection later. `local` is the
// client key the remote signer authorised — not the user's identity key,
// which never leaves the signer.
session: local ? { local: hex.encode(local), bp: signer.bp } : null,
- signEvent: (e) => signer.signEvent(e),
- encryptSelf: (txt) => signer.nip44Encrypt(pubkey, txt),
- decryptSelf: (ct) => signer.nip44Decrypt(pubkey, ct),
- encryptTo: (peer, txt) => signer.nip44Encrypt(peer, txt),
- decryptFrom: (peer, ct) => signer.nip44Decrypt(peer, ct),
+ signEvent: (e) => answered(signer.signEvent(e)),
+ encryptSelf: (txt) => answered(signer.nip44Encrypt(pubkey, txt)),
+ decryptSelf: (ct) => answered(signer.nip44Decrypt(pubkey, ct)),
+ encryptTo: (peer, txt) => answered(signer.nip44Encrypt(peer, txt)),
+ decryptFrom: (peer, ct) => answered(signer.nip44Decrypt(peer, ct)),
close: () => { try { signer.close(); } catch {} },
};
}