From 971db911d47af391695a9d83a3d31c089bfee782 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Tue, 4 Aug 2026 01:06:24 -0700 Subject: Never mistake a failed backup lookup for 'no wallet yet' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signing in with a key whose wallet backup exists on relays could still land in 'create a new wallet' when the lookup failed (relays unreachable, signer decrypt denied) — and confirming would have replaced the real backup on any relay the new one reached. Absence is now only believed when a relay actually answered; decrypt failures surface as errors; and the create path takes one last look and adopts a backup that surfaces rather than clobbering it. Co-Authored-By: Claude Fable 5 --- src/nostr-login.js | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) (limited to 'src/nostr-login.js') diff --git a/src/nostr-login.js b/src/nostr-login.js index ec18942..301bbeb 100644 --- a/src/nostr-login.js +++ b/src/nostr-login.js @@ -187,16 +187,30 @@ export function keySigner(sk) { // Look for a wallet this nostr account already published. Returns the // mnemonic, or null when the account has no wallet yet. +// +// "No wallet" is a claim, not a default: acting on it mints a NEW wallet +// whose backup would replace this one on every relay it reaches. So absence +// is only believed when at least one relay actually answered, and a backup +// we can see but not decrypt is an error, never a shrug — each query is +// per-relay because a pooled query swallows individual connection failures. export async function fetchWalletBackup(signer, relays = BACKUP_RELAYS) { const pool = new SimplePool(); + const filter = { kinds: [BACKUP_KIND], authors: [signer.pubkey], '#d': [SEED_DTAG] }; try { - const evs = await pool.querySync(relays, - { kinds: [BACKUP_KIND], authors: [signer.pubkey], '#d': [SEED_DTAG] }, { maxWait: 6000 }); - const newest = evs.sort((a, b) => b.created_at - a.created_at)[0]; + const results = await Promise.allSettled(relays.map(async (url) => { + await pool.ensureRelay(url, { connectionTimeout: 5000 }); + return pool.querySync([url], filter, { maxWait: 6000 }); + })); + const answered = results.filter((r) => r.status === 'fulfilled'); + if (!answered.length) throw new Error('could not reach any relay to look for an existing wallet — check your connection and try again'); + const newest = answered.flatMap((r) => r.value).sort((a, b) => b.created_at - a.created_at)[0]; if (!newest) return null; - const body = JSON.parse(await signer.decryptSelf(newest.content)); - return body && body.mnemonic ? body : null; - } catch { return null; } finally { try { pool.close(relays); } catch {} } + let body; + try { body = JSON.parse(await signer.decryptSelf(newest.content)); } + catch { throw new Error('found this account’s wallet but could not decrypt it — approve the decryption prompt in your signer and try again'); } + if (!body || !body.mnemonic) throw new Error('found this account’s wallet backup but it is unreadable'); + return body; + } finally { try { pool.close(relays); } catch {} } } // Publish (or replace) the encrypted wallet association for this account. -- cgit v1.2.3