aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-10 21:05:58 -0700
committerAdam Soltys <[email protected]>2026-09-10 21:05:58 -0700
commit3cda220c2432181b5f2dfc0c47e28c3e3cb54f18 (patch)
treee727d022556f63d2a6383360fb59a466e58de537 /src
parent1c34bb343c47f6e200575bac967959656ae7becb (diff)
downloadcoinosv3-3cda220c2432181b5f2dfc0c47e28c3e3cb54f18.tar.gz
coinosv3-3cda220c2432181b5f2dfc0c47e28c3e3cb54f18.zip
Each account in the switcher wears its own face, and its own renewal warning.
Two accounts could show the same name and avatar: the identity list read a key stamped onto the account record, and the stamp was taken during the gap in a wallet switch where the new account is already loaded and saved but the features haven't re-inited — so the PREVIOUS account's signer was still answering "who are you". The live signer now re-checks on every read that it still belongs to the open wallet, not just at init(); and the switcher reads each account's own persisted login (or its seed's nostr key) instead of the stamped mirror, so a record stamped wrong before this still lists right. The Spending renewal warning belonged to the wallet that computed it and stayed on screen after switching — the old wallet's amount and deadline over the new wallet's balance, with a 30-minute throttle keeping it there. It now resets with the ark manager, and the next connect re-decides. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
Diffstat (limited to 'src')
-rw-r--r--src/app.js27
-rw-r--r--src/features/ark.js7
-rw-r--r--src/features/nostrlogin.js33
-rw-r--r--src/nostr.js13
4 files changed, 64 insertions, 16 deletions
diff --git a/src/app.js b/src/app.js
index ba9fa66..697b664 100644
--- a/src/app.js
+++ b/src/app.js
@@ -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();