From 70db2a286bb1f9f5825907e765d45d4ba49199f2 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 5 Jul 2026 20:58:47 -0700 Subject: Per-wallet network MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The network is now a property of each wallet, not a global mode. Every new wallet is born on the currently-selected network; activating a wallet activates its network (features re-init through the same networkChanged hooks the Settings switch uses); the Settings network card moves the ACTIVE wallet and sets the default for new ones. The stamp persists everywhere accounts do — session, durable directory, and vault — and legacy accounts adopt the current global once. Gift claims compose: an ark gift lists only wallets on its network as claim targets, and when none exist the fresh claim wallet is simply born on the gift's network — existing wallets keep theirs, so opening a foreign-network gift never disturbs anything. The wallet switcher tags non-mainnet wallets with their network. Co-Authored-By: Claude Fable 5 --- src/app.js | 32 +++++++++++++++++++++++--------- src/features/gifts.js | 23 +++++++++++++++-------- 2 files changed, 38 insertions(+), 17 deletions(-) (limited to 'src') diff --git a/src/app.js b/src/app.js index f659097..7eacff1 100644 --- a/src/app.js +++ b/src/app.js @@ -750,7 +750,16 @@ async function activateAccount(acc, opts = {}) { // until the user commits (claims, or chooses to keep it / enters the wallet), // so bailing from an already-claimed gift doesn't leave an empty account. if (opts.gift && !opts.existingClaim) acc.provisional = true; - const netName = getNetwork(); + // The network is a per-wallet property: activating a wallet activates its + // network. Accounts created before this carried none — they adopt the + // current global choice once, then keep it. The global setting remains the + // default for the NEXT new wallet. + if (!acc.network) acc.network = getNetwork(); + const netName = acc.network; + if (netName !== getNetwork()) { + setNetwork(netName); + for (const f of FEATURES) { try { f.networkChanged && f.networkChanged(netName); } catch {} } + } if (acc.type === 'watch') wallet.load({ xpub: acc.xpub, netName, offline: false }); else if (acc.xprv) wallet.load({ xprv: acc.xprv, netName, offline: false }); else wallet.load({ mnemonic: acc.mnemonic, passphrase: acc.passphrase || '', netName, offline: false, spFresh: !!opts.generated }); @@ -869,12 +878,12 @@ function defaultLabel(type) { function loadWatchAccounts() { try { const dir = JSON.parse(localStorage.getItem(WATCH_KEY) || '[]'); - return dir.filter((d) => d.xpub).map((d) => ({ id: d.id, label: d.label, type: 'watch', xpub: d.xpub, autoLock: d.autoLock || 0 })); + return dir.filter((d) => d.xpub).map((d) => ({ id: d.id, label: d.label, type: 'watch', xpub: d.xpub, autoLock: d.autoLock || 0, network: d.network })); } catch { return []; } } function saveDirectory() { try { - const dir = accounts.filter((a) => a.xpub && !a.provisional).map((a) => ({ id: a.id, label: a.label, xpub: a.xpub, autoLock: a.autoLock || 0 })); + const dir = accounts.filter((a) => a.xpub && !a.provisional).map((a) => ({ id: a.id, label: a.label, xpub: a.xpub, autoLock: a.autoLock || 0, network: a.network })); localStorage.setItem(WATCH_KEY, JSON.stringify(dir)); } catch {} } @@ -893,7 +902,8 @@ function addOrGetAccount(partial) { const cid = credId(partial); let acc = accounts.find((a) => credId(a) === cid); if (!acc) { - acc = { id: genId(), ...partial }; + // every new wallet is born on the currently-selected network + acc = { id: genId(), network: getNetwork(), ...partial }; accounts.push(acc); persistAccounts(); } @@ -973,7 +983,7 @@ function hasVault() { return !!loadVaultBlob(); } function writeVault() { if (vaultPassword == null) return; const list = accounts.filter((a) => a.type === 'full' && a.persisted) - .map((a) => (a.xprv ? { label: a.label, xprv: a.xprv } : { label: a.label, mnemonic: a.mnemonic, passphrase: a.passphrase || '' })); + .map((a) => (a.xprv ? { label: a.label, xprv: a.xprv, network: a.network } : { label: a.label, mnemonic: a.mnemonic, passphrase: a.passphrase || '', network: a.network })); try { if (!list.length) localStorage.removeItem(VAULT_KEY); else localStorage.setItem(VAULT_KEY, JSON.stringify(encryptVault(list, vaultPassword))); @@ -992,6 +1002,7 @@ function mergeVaultList(list) { if (v.xprv) { existing.xprv = v.xprv; delete existing.mnemonic; delete existing.passphrase; } else { existing.mnemonic = v.mnemonic; existing.passphrase = v.passphrase || ''; delete existing.xprv; } if (v.label) existing.label = v.label; + if (v.network && !existing.network) existing.network = v.network; existing.persisted = true; } else { const acc = addOrGetAccount( @@ -999,6 +1010,7 @@ function mergeVaultList(list) { ? { type: 'full', label: v.label || defaultLabel('full'), xprv: v.xprv, xpub } : { type: 'full', label: v.label || defaultLabel('full'), mnemonic: v.mnemonic, passphrase: v.passphrase || '', xpub } ); + if (v.network) acc.network = v.network; acc.persisted = true; } } @@ -1475,14 +1487,15 @@ async function doConsolidate() { -// Switch the active Bitcoin network. Changes addresses/balances entirely, so we -// persist the choice and reload the active account (or empty wallet) under it. +// Move the ACTIVE wallet to another network (and make that the default for +// new wallets). Changes addresses/balances entirely, so the account is +// restamped and reloaded under it. function changeNetwork(net) { if (net === getNetwork()) return; setNetwork(net); for (const f of FEATURES) { try { f.networkChanged && f.networkChanged(net); } catch {} } const acc = accounts.find((a) => a.id === activeId); - if (acc) activateAccount(acc); else render(); + if (acc) { acc.network = net; persistAccounts(); activateAccount(acc); } else render(); } @@ -1665,7 +1678,8 @@ function accountsScreen() { h('div', { class: 'col', style: 'gap:0' }, accounts.map((a) => { const isActive = a.id === activeId; - const tag = a.type === 'watch' ? ' · ' + t('watchOnlyTag') : ''; // "saved" shown in the link below, not the title + const netTag = a.network && a.network !== 'mainnet' ? ' · ' + a.network : ''; + const tag = (a.type === 'watch' ? ' · ' + t('watchOnlyTag') : '') + netTag; // "saved" shown in the link below, not the title if (ui.editId === a.id) { return h('div', { class: 'row gap6', style: 'padding:10px 0; border-bottom:1px solid var(--line)' }, h('input', { type: 'text', style: 'flex:1', value: ui.editLabel, autofocus: true, diff --git a/src/features/gifts.js b/src/features/gifts.js index 7cc9e9c..0038f75 100644 --- a/src/features/gifts.js +++ b/src/features/gifts.js @@ -3,6 +3,7 @@ // claim codes / NIP-07 extension). Gift primitives live in ../wallet.js. import { newMnemonic } from '../wallet.js'; +import { getNetwork } from '../api.js'; import { installGiftWallet, previewGift, giftOutpoints, buildClaimTx, giftMinimum, lockGift, previewLockedGift } from './gifts-wallet.js'; import { parseNostrPubkey, npubOf, fetchNostrProfile, decryptWithCode } from '../nostr.js'; import { t } from '../i18n.js'; @@ -69,12 +70,18 @@ export function giftsFeature(ctx) { function claimGift(code) { ui.claimLocked = null; ui.claimArkAmount = null; - const targets = claimTargets(); - // No wallets on this device: adopt the gift's network so a first-timer - // never has to visit Settings before claiming. - if (!targets.length) hook('arkGiftAdoptNetwork', code); - if (targets.length) { setAccounts(targets); ui.claimChoose = { code }; render(); } - else enterWallet(newMnemonic(), '', { gift: code }); + // An ark gift can only land in a wallet on ITS network — other wallets + // aren't valid claim targets. (On-chain gift codes don't carry a network.) + const all = claimTargets(); + const ag = arkGiftOf(code); + const usable = all.filter((a) => !ag || (a.network || getNetwork()) === ag.net); + if (all.length) setAccounts(all); // keep the full wallet list in the session + if (usable.length) { ui.claimChoose = { code }; render(); return; } + // No usable wallet on this device: adopt the gift's network so a + // first-timer (or a wrong-network device) never has to visit Settings. + // Per-wallet networks make this safe — other wallets keep their own. + hook('arkGiftAdoptNetwork', code); + enterWallet(newMnemonic(), '', { gift: code }); } // Claim a gift into an existing wallet (no new seed). activateAccount loads it and @@ -102,7 +109,7 @@ export function giftsFeature(ctx) { ), h('div', { class: 'card col gap6' }, h('div', { class: 'small muted' }, t('claimIntoExisting')), - ...getAccounts().map((a) => + ...getAccounts().filter((a) => !ag || (a.network || getNetwork()) === ag.net).map((a) => h('button', { class: 'btn-block', style: 'display:flex;justify-content:space-between;align-items:center;text-align:left', onClick: () => claimIntoAccount(a, code) }, h('span', {}, a.label, @@ -110,7 +117,7 @@ export function giftsFeature(ctx) { h('span', { class: 'muted', style: 'font-size:18px;line-height:1' }, '\u203a')) ), h('div', { class: 'small faint', style: 'text-align:center' }, t('claimOr')), - h('button', { class: 'btn-primary btn-block', onClick: () => { ui.claimChoose = null; enterWallet(newMnemonic(), '', { gift: code }); } }, t('claimNewWallet')) + h('button', { class: 'btn-primary btn-block', onClick: () => { ui.claimChoose = null; hook('arkGiftAdoptNetwork', code); enterWallet(newMnemonic(), '', { gift: code }); } }, t('claimNewWallet')) ) ); } -- cgit v1.2.3