diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.js | 27 | ||||
| -rw-r--r-- | src/features/ark.js | 7 | ||||
| -rw-r--r-- | src/features/nostrlogin.js | 33 | ||||
| -rw-r--r-- | src/nostr.js | 13 |
4 files changed, 64 insertions, 16 deletions
@@ -7,7 +7,7 @@ import { Wallet, newMnemonic, isValidMnemonic, accountXpubFor, cacheKeyFor, utxoId, parseExtendedKey, xpubToZpub, encryptVault, decryptVault } from './wallet.js'; import { qrSvg } from './qr.js'; import { makeSearcher, resultRows, searchable, punkUrl, warmSearch } from './recipient-search.js'; -import { npubOf } from './nostr.js'; +import { npubOf, seedPubkey } from './nostr.js'; import { nip98Header } from './nip98.js'; import { NOSTR_MARK } from './features/nostrlogin.js'; import { scanQr } from './scan.js'; @@ -1327,17 +1327,32 @@ function signInAnother() { render(); } +// The nostr login persisted against an account's OWN wallet state. Feature +// state lives in localStorage keyed by the seed, so this answers "who is that +// account?" for every account on the device, not just the open one — no +// stamping, nothing to go stale. +function loginPkOf(a) { + try { + const base = a.type === 'watch' ? a.xpub : a.xprv || (a.mnemonic ? `${a.mnemonic}\n${a.passphrase || ''}` : null); + if (!base) return null; + const key = cacheKeyFor(a.deriveIndex ? `${base}\n#${a.deriveIndex}` : base); + return (JSON.parse(localStorage.getItem(key + ':nostrlogin') || 'null') || {}).pubkey || null; + } catch { return null; } +} + // The identities on this device — one per seed (a seed's Spending and // Savings faces are the same person), each with the nostr key its header // avatar wears: the nostr login when there is one, else the seed's own key. -// A key is remembered on the account when it's active (saveDirectory) and in -// the durable directory by xpub, so accounts restored from the vault still -// show a face; one never opened on this device has no key yet and falls back -// to its label. +// Both are read from the account itself; the key remembered on the record +// (saveDirectory) and in the durable directory by xpub is only the fallback, +// for a watch-only entry whose seed isn't here to ask. function identities() { let dir = []; try { dir = JSON.parse(localStorage.getItem(WATCH_KEY) || '[]'); } catch {} const dirPk = (a) => (a.xpub && (dir.find((d) => d.xpub === a.xpub) || {}).nostrPk) || null; + const pkOf = (a) => loginPkOf(a) + || (a.mnemonic ? seedPubkey(a.mnemonic, a.passphrase || '', a.deriveIndex || 0) : null) + || a.nostrPk || dirPk(a); const bySeed = new Map(); for (const a of accounts) { if (a.provisional) continue; @@ -1345,7 +1360,7 @@ function identities() { : a.mnemonic ? 'm:' + a.mnemonic + '\n' + (a.passphrase || '') + '\n' + (a.deriveIndex || 0) : 'x:' + (a.xprv || a.xpub || a.id); const active = a.id === activeId; - const pk = a.nostrPk || dirPk(a); + const pk = pkOf(a); const cur = bySeed.get(key); if (!cur) bySeed.set(key, { id: a.id, pk, active, label: seedName(a), network: a.network, watch: a.type === 'watch' }); else { diff --git a/src/features/ark.js b/src/features/ark.js index 2e97a61..251f740 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -393,6 +393,13 @@ export function arkFeature(ctx) { lnTagSeen = -1; ark = null; arkConnectPromise = null; + // The renewal warning describes the coins of the wallet we're leaving. + // Switching accounts used to carry it onto the next one — with the old + // wallet's amount and deadline, over the new wallet's balance — and the + // 30-minute throttle then kept it there. Both reset with the manager; + // the next connect's first tick re-decides for the wallet now open. + arkRenewWarn = null; + arkAutoRefreshAt = 0; } function arkAvailable() { diff --git a/src/features/nostrlogin.js b/src/features/nostrlogin.js index 5e071da..4d3fe50 100644 --- a/src/features/nostrlogin.js +++ b/src/features/nostrlogin.js @@ -40,6 +40,19 @@ export function nostrLoginFeature(ctx) { let _spendingHinted = false; // relay backup already carries the spending hint let resumeFailedAt = 0; + // The signer, but only if it still belongs to the wallet that's open. + // Checked on every READ rather than at init() alone, because a wallet + // switch has a gap: the new account is loaded and its directory entry + // written (stamping the identity it shows) BEFORE features re-init, and the + // previous account's signer was still answering in that gap — which is how + // two different wallets ended up wearing the same face in the account + // switcher. (Mid-sign-in the link isn't saved yet; `attaching` keeps this + // from severing it.) + const liveSigner = () => { + if (live && !attaching && load().pubkey !== live.pubkey) live = null; + return live; + }; + // A remote signer's connection can die quietly — the app is left holding a // 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 @@ -536,7 +549,7 @@ export function nostrLoginFeature(ctx) { // signer that isn't answering shouldn't mean a new relay connection per // keystroke. async function resumeLogin() { - if (live) return live; + if (liveSigner()) return live; const st = load(); if (!st.pubkey) return null; if (resuming) return resuming; @@ -587,11 +600,12 @@ export function nostrLoginFeature(ctx) { function settingsCard() { if (wallet.watchOnly || !wallet.mnemonic) return null; const st = load(); + const lv = liveSigner(); if (st.pubkey) { return h('div', { class: 'card col' }, h('div', { class: 'row between' }, h('h3', {}, t('nlLinkTitle')), - h('span', { class: 'badge dot' + (live ? ' live' : ' off') }, live ? t('nlConnected') : t('nlDisconnected'))), + h('span', { class: 'badge dot' + (lv ? ' live' : ' off') }, lv ? t('nlConnected') : t('nlDisconnected'))), h('div', { class: 'small muted break' }, npubOf(st.pubkey) || st.pubkey), h('p', { class: 'small faint', style: 'margin:0' }, t('nlLinkedDesc')), // Signing needs a live signer, and a reload always drops a remote one. @@ -600,7 +614,7 @@ export function nostrLoginFeature(ctx) { // 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' }, + lv ? null : (resumeQuietly(), h('div', { class: 'col', style: 'gap:8px' }, h('p', { class: 'small muted', style: 'margin:0' }, t('nlReconnectDesc')), signerButtons(reconnectSigner, { reconnectVia: reconnectViaOf(st) })))); } @@ -626,7 +640,7 @@ export function nostrLoginFeature(ctx) { function reconnectScreen() { const st = load(); const run = (makeSigner) => reconnectSigner(makeSigner).then(() => { - if (live) { ui.nostrReconnect = null; render(); } + if (liveSigner()) { ui.nostrReconnect = null; render(); } }); return h('div', { class: 'col', style: 'gap:16px' }, ctx.brandHeader(false), @@ -654,7 +668,7 @@ export function nostrLoginFeature(ctx) { // background — it closes the screen by itself when it lands). nostrReconnectPrompt() { const st = load(); - if (!st.pubkey || live) return false; + if (!st.pubkey || liveSigner()) return false; ui.nostrReconnect = true; ui.nostrLoginError = ''; this.nostrLoginResume().then((sg) => { @@ -667,9 +681,8 @@ export function nostrLoginFeature(ctx) { // A live signer is bound to the wallet that linked it. Switching to an // account that never did must not inherit the login identity — it used // to, and the fresh wallet then claimed names under the login npub, - // showed its profile, and even wore its hats. (Mid-sign-in the link - // isn't saved yet; `attaching` keeps this from severing it.) - if (live && !attaching && load().pubkey !== live.pubkey) live = null; + // showed its profile, and even wore its hats. + liveSigner(); }, // The nostr identity this session is logged in as, for features that // should speak as the user (the payment address defaults to this npub). @@ -677,7 +690,7 @@ export function nostrLoginFeature(ctx) { // A just-connected signer counts immediately — the persisted link lands // only after the wallet opens, and the header avatar shouldn't show the // wallet key's face in the gap. - if (live) return { pubkey: live.pubkey, npub: npubOf(live.pubkey), signer: live }; + if (liveSigner()) return { pubkey: live.pubkey, npub: npubOf(live.pubkey), signer: live }; const st = load(); if (!st.pubkey) return null; return { pubkey: st.pubkey, npub: npubOf(st.pubkey), signer: live }; @@ -695,7 +708,7 @@ export function nostrLoginFeature(ctx) { // hint into the relay backup while a signer is live, so the NEXT restore // paints Spending from its first frame. Once per session is plenty. spendingEvident() { - if (_spendingHinted || !live || !wallet.mnemonic) return null; + if (_spendingHinted || !liveSigner() || !wallet.mnemonic) return null; _spendingHinted = true; publishWalletBackup(live, { mnemonic: wallet.mnemonic, passphrase: wallet.passphrase || '', spending: true }) .catch(() => { _spendingHinted = false; }); diff --git a/src/nostr.js b/src/nostr.js index fdc797c..50ed1f4 100644 --- a/src/nostr.js +++ b/src/nostr.js @@ -188,6 +188,19 @@ export async function publishOn(relays, evt) { export { nip04, nip44 }; export { getPublicKey, finalizeEvent, generateSecretKey }; +// The nostr identity a seed carries (NostrSync#load's key, without the +// wallet) — so the account switcher can put a face on every account on the +// device, not just the one that's open. Memoized: nip06 stretches the seed. +const _seedPks = new Map(); +export function seedPubkey(mnemonic, passphrase = '', accountIndex = 0) { + const k = `${mnemonic}\n${passphrase}\n${accountIndex}`; + if (_seedPks.has(k)) return _seedPks.get(k); + let pk = null; + try { pk = getPublicKey(nip06.privateKeyFromSeedWords(mnemonic, passphrase || undefined, accountIndex || 0)); } catch {} + _seedPks.set(k, pk); + return pk; +} + export function parseNostrPubkey(input) { const s = (input || '').trim(); if (/^[0-9a-f]{64}$/i.test(s)) return s.toLowerCase(); |
