diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.js | 166 | ||||
| -rw-r--r-- | src/features/sync.js | 2 | ||||
| -rw-r--r-- | src/i18n.js | 8 | ||||
| -rw-r--r-- | src/nostr.js | 6 | ||||
| -rw-r--r-- | src/wallet.js | 15 |
5 files changed, 161 insertions, 36 deletions
@@ -1009,7 +1009,7 @@ async function activateAccount(acc, opts = {}) { } 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 }); + else wallet.load({ mnemonic: acc.mnemonic, passphrase: acc.passphrase || '', netName, offline: false, spFresh: !!opts.generated, accountIndex: acc.deriveIndex || 0 }); // Record the account-level xpub so this wallet survives a session wipe as a // watch-only entry (see the durable account directory) — you keep seeing your // balance/history and re-enter the seed to spend again. @@ -1111,7 +1111,7 @@ let accounts = []; let activeId = null; const genId = () => 'a' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7); -const credId = (a) => (a.type === 'watch' ? 'w:' + a.xpub : a.xprv ? 'x:' + a.xprv : 'f:' + a.mnemonic + '|' + (a.passphrase || '')); +const credId = (a) => (a.type === 'watch' ? 'w:' + a.xpub : a.xprv ? 'x:' + a.xprv : 'f:' + a.mnemonic + '|' + (a.passphrase || '') + (a.deriveIndex ? '|#' + a.deriveIndex : '')); const activeAccount = () => accounts.find((a) => a.id === activeId) || null; function defaultLabel(type) { @@ -1152,12 +1152,35 @@ function addOrGetAccount(partial) { if (!acc) { // every new wallet is born on the currently-selected network acc = { id: genId(), network: getNetwork(), ...partial }; + // the Add-wallet chooser fixed this wallet's purpose before the seed flow + if (ui.newWalletKind) { acc.kind = ui.newWalletKind; ui.newWalletKind = null; } accounts.push(acc); persistAccounts(); } return acc; } +// A wallet on an existing seed: the next unused BIP84 account index under the +// parent's phrase. Its coins, ark state, cache and nostr identity are all its +// own; the parent's seed phrase backs up both. +function createDerivedWallet(parentId, kind) { + const parent = accounts.find((a) => a.id === parentId); + if (!parent || !parent.mnemonic) return; + const siblings = accounts.filter((x) => x.mnemonic === parent.mnemonic && (x.passphrase || '') === (parent.passphrase || '')); + const next = 1 + Math.max(0, ...siblings.map((x) => x.deriveIndex || 0)); + const acc = { + id: genId(), type: 'full', network: parent.network || getNetwork(), + label: `${parent.label} +${next}`, + mnemonic: parent.mnemonic, passphrase: parent.passphrase || '', + deriveIndex: next, kind, + }; + accounts.push(acc); + persistAccounts(); + if (parent.persisted) { acc.persisted = true; writeVault(); } + ui.addWallet = null; + activateAccount(acc, { fresh: true }); +} + // Commit the active account — clear the "provisional" gift flag so it's no // longer discarded on bail. Called once the user claims, keeps, or enters it. function commitAccount() { @@ -1719,7 +1742,7 @@ function brandHeader(withLock) { settingsBtn(), lockBtn(), h('button', { class: 'btn-sm', onClick: () => { ui.screen = 'accounts'; render(); } }, - acc ? acc.label : t('accounts')), + acc ? viewLabel(acc, accountSel()) : t('accounts')), avatarMenu()) : null ); @@ -2043,6 +2066,39 @@ function claimTargets() { // Account switcher: pick a wallet, add another, or lock the session. function accountsScreen() { if (ui.pw) return h('div', { class: 'col', style: 'gap:16px' }, brandHeader(false), pwPromptCard()); + if (ui.addWallet) { + const aw = ui.addWallet; + const fulls = accounts.filter((x) => x.type === 'full' && x.mnemonic); + return h('div', { class: 'col', style: 'gap:16px' }, + brandHeader(false), + h('div', { class: 'card col', style: 'gap:12px' }, + h('h3', {}, t('addWallet')), + h('div', { class: 'seg', style: 'display:flex;width:100%' }, + ['spending', 'savings'].map((k) => h('button', { + type: 'button', class: (aw.kind === k ? 'active ' : '') + 'grow', + onClick: () => { aw.kind = k; render(); }, + }, k === 'spending' ? t('spendingName') : t('savingsName')))), + h('div', { class: 'col gap6' }, + h('span', { class: 'small muted' }, t('addWalletSeed')), + h('select', { onChange: (e) => { aw.from = e.target.value; render(); } }, + h('option', { value: 'new', selected: aw.from === 'new' }, t('addWalletNewSeed')), + fulls.map((x) => h('option', { value: x.id, selected: aw.from === x.id }, t('addWalletShareSeed', { name: x.label }))))), + aw.from !== 'new' ? h('div', { class: 'small faint' }, t('addWalletShareNote')) : null, + h('div', { class: 'row gap6' }, + h('button', { class: 'btn-ghost grow', onClick: () => { ui.addWallet = null; render(); } }, t('back')), + h('button', { class: 'btn-primary grow', onClick: () => { + const kind = aw.kind; + if (aw.from === 'new') { + ui.newWalletKind = kind; + ui.addWallet = null; + ui.draftMnemonic = ''; ui.createStep = 'gen'; ui.confirm = []; ui.passphrase = ''; ui.confirmPass = ''; + ui.showPass = false; ui.screen = 'unlock'; ui.unlockTab = 'create'; ui.fromWallet = true; ui.unlockError = ''; + render(); + } else { + createDerivedWallet(aw.from, kind); + } + } }, t('addWalletGo'))))); + } if (ui.confirmClear) { return h('div', { class: 'col', style: 'gap:16px' }, brandHeader(false), @@ -2077,11 +2133,12 @@ function accountsScreen() { h('div', { class: 'card col' }, h('h3', {}, t('accounts')), h('div', { class: 'col', style: 'gap:0' }, - accounts.map((a) => { - const isActive = a.id === activeId; + accounts.flatMap((a) => viewsOf(a).map((view) => { + const isActive = a.id === activeId && accountSel() === view; 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) { + const seedTag = accounts.length > 1 && !a.kind ? ' · ' + a.label : ''; + const tag = (a.type === 'watch' ? ' · ' + t('watchOnlyTag') : '') + netTag + seedTag; + if (ui.editId === a.id && ui.editView === view) { 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, onInput: (e) => (ui.editLabel = e.target.value), @@ -2094,19 +2151,20 @@ function accountsScreen() { h('div', { class: 'row between' }, h('button', { class: 'linklike', style: 'text-align:left;flex:1;font-size:15px;' + (isActive ? 'font-weight:600' : ''), - onClick: () => { if (isActive) { ui.screen = 'wallet'; render(); } else switchAccount(a.id); }, - }, (isActive ? '● ' : '○ ') + a.label + tag), + onClick: () => switchToView(a.id, view), + }, (isActive ? '● ' : '○ ') + viewLabel(a, view) + tag), + h('button', { class: 'btn-sm wallet-row-btn', title: t('renameWallet'), onClick: () => { ui.editId = a.id; ui.editView = view; ui.editLabel = viewLabel(a, view); render(); } }, '✎'), h('button', { class: 'btn-sm wallet-row-btn', title: t('walletSettings'), onClick: () => openAccountSettings(a.id), html: '<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:block"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>' }), h('button', { class: 'btn-sm wallet-row-btn', title: t('remove'), onClick: () => { ui.confirmRemove = a.id; render(); } }, '✕') ), - a.type === 'full' + a.type === 'full' && view === viewsOf(a)[viewsOf(a).length - 1] ? h('button', { class: 'linklike small', style: 'align-self:flex-start', onClick: () => (a.persisted ? startForget(a.id) : startSave(a.id)) }, a.persisted ? t('forgetDevice') : t('saveDevice')) : null ); - }) + })) ), - h('button', { class: 'btn-block', onClick: () => { ui.draftMnemonic = ''; ui.createStep = 'gen'; ui.confirm = []; ui.passphrase = ''; ui.confirmPass = ''; ui.showPass = false; ui.screen = 'unlock'; ui.unlockTab = 'create'; ui.fromWallet = true; ui.unlockError = ''; render(); } }, t('addWallet')), + h('button', { class: 'btn-block', onClick: () => { ui.addWallet = { kind: 'spending', from: 'new' }; render(); } }, t('addWallet')), hasVault() ? h('button', { class: 'btn-ghost btn-block', onClick: startChangePw }, t('changePassword')) : null, h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.confirmClear = true; render(); } }, t('clearAll')) ), @@ -2118,9 +2176,12 @@ function renameAccount(id) { const acc = accounts.find((a) => a.id === id); if (acc) { const v = (ui.editLabel || '').trim(); - if (v) acc.label = v; - persistAccounts(); - if (acc.persisted) writeVault(); + if (v) { + // names belong to the VIEW now — Spending and Savings rename separately + acc.viewLabels = { ...(acc.viewLabels || {}), [ui.editView || accountSel()]: v }; + persistAccounts(); + if (acc.persisted) writeVault(); + } } ui.editId = null; render(); @@ -2650,7 +2711,34 @@ function spendingActive() { const addr = featureHook('namesAddress'); return !!addr && !/^npub1/.test(addr); } +// ---- the flat wallet model ------------------------------------------------- +// Each seed record surfaces as up to two TOP-LEVEL wallets: its Spending (Ark) +// and Savings (on-chain) sides. A record with a fixed `kind` (single-purpose, +// possibly sharing another wallet's seed at a higher BIP84 account) surfaces +// once. "Spending" and "Savings" are the default names; renames are per-view. +function viewsOf(a) { + if (!a) return []; + if (a.kind) return [a.kind]; + if (a.type === 'watch') return ['savings']; + return ['spending', 'savings']; +} +function viewLabel(a, view) { + if (a && a.viewLabels && a.viewLabels[view]) return a.viewLabels[view]; + return view === 'spending' ? t('spendingName') : t('savingsName'); +} +function switchToView(id, view) { + ui.account = view; + try { localStorage.setItem(ACCOUNT_KEY, view); } catch {} + if (id === activeId) { ui.screen = 'wallet'; render(); } + else { + const acc = accounts.find((a) => a.id === id); + if (acc) switchAccount(id); + } +} + function accountSel() { + const a = activeAccount(); + if (a && a.kind) return a.kind; // single-purpose wallet: no other side if (!spendingActive()) return 'savings'; // one balance, no choice if (!ui.account) { let saved = null; @@ -2684,33 +2772,52 @@ function balanceCard() { // is the on-chain one. The headline is simply everything you have. const spending = featureHook('spendingSat') || 0; const saving = wallet.spendable; + const acc0 = activeAccount(); + const kindLocked = !!(acc0 && acc0.kind); // single-purpose top-level wallet const hasSpending = spendingActive(); - const sel = hasSpending ? accountSel() : 'savings'; + const sel = kindLocked ? acc0.kind : hasSpending ? accountSel() : 'savings'; const isSpending = sel === 'spending'; + // a single-purpose wallet's switch jumps to a same-seed sibling wallet of + // the opposite purpose, when one exists + const opp = isSpending ? 'savings' : 'spending'; + const sibling = kindLocked + ? accounts.find((x) => x.id !== acc0.id && x.mnemonic && x.mnemonic === acc0.mnemonic + && (x.passphrase || '') === (acc0.passphrase || '') && viewsOf(x).includes(opp)) + : null; // One balance at a time, full size: the card IS the account you're in, and // everything (receive, gifts) follows it. Swipe or tap the dots to flip. const dtAcc = animWindow('acct', sel, 300); const face = h('div', { class: 'balance-face' }, h('div', { class: 'row between', style: 'align-items:center' }, h('div', { class: 'small faint', style: 'text-transform:uppercase;letter-spacing:.06em' }, - hasSpending ? (isSpending ? t('spendingLabel') : t('savingLabel')) : t('balance')), + kindLocked ? viewLabel(acc0, sel) + : hasSpending ? (isSpending ? t('spendingLabel') : t('savingLabel')) : t('balance')), // Swiping only helps on touch, and dots are an indicator rather than a // target — so the switch is a real button that names where it goes. // Before Spending exists, its spot offers to set it up — the one // doorway into the optional spending-account flow. - hasSpending - ? h('button', { - class: 'balance-switch', - onClick: () => setAccountSel(isSpending ? 'savings' : 'spending', isSpending ? 'left' : 'right'), - }, - h('span', { html: SWAP_ICON }), - h('span', { class: 'bsw-label' }, isSpending ? t('savingLabel') : t('spendingLabel'))) - : featureHook('arkReady') && !wallet.watchOnly + kindLocked + ? (sibling + ? h('button', { + class: 'balance-switch', + onClick: () => switchToView(sibling.id, opp), + }, + h('span', { html: SWAP_ICON }), + h('span', { class: 'bsw-label' }, viewLabel(sibling, opp))) + : null) + : hasSpending ? h('button', { class: 'balance-switch', - onClick: () => { ui.onbError = ''; ui.onb = { step: 'spend' }; render(); }, - }, h('span', { class: 'bsw-label' }, t('spendSetup'))) - : null), + onClick: () => setAccountSel(isSpending ? 'savings' : 'spending', isSpending ? 'left' : 'right'), + }, + h('span', { html: SWAP_ICON }), + h('span', { class: 'bsw-label' }, isSpending ? t('savingLabel') : t('spendingLabel'))) + : featureHook('arkReady') && !wallet.watchOnly + ? h('button', { + class: 'balance-switch', + onClick: () => { ui.onbError = ''; ui.onb = { step: 'spend' }; render(); }, + }, h('span', { class: 'bsw-label' }, t('spendSetup'))) + : null), h('div', { class: 'amt', style: firstLoad ? 'opacity:.3' : '' }, firstLoad ? h('span', { class: 'spinner sm', style: 'margin-right:8px' }) : null, animatedAmount('bal:' + sel, isSpending ? spending : saving), ' ', unitTag('unit')), @@ -2734,6 +2841,9 @@ function balanceCard() { // pointless (and confusing) while there's only one. ...(hasSpending ? featureAll('balanceActions') : []).map((a2) => h('button', { class: 'btn-sm', style: 'margin-top:10px', onClick: a2.onClick }, a2.label)), + kindLocked && isSpending && !hasSpending && featureHook('arkReady') && !wallet.watchOnly + ? h('button', { class: 'btn-sm', style: 'margin-top:10px', onClick: () => { ui.onbError = ''; ui.onb = { step: 'spend' }; render(); } }, t('spendSetup')) + : null, // A feature can unfold UI right on the card (ark's inline move panel). (() => { if (!hasSpending) return null; diff --git a/src/features/sync.js b/src/features/sync.js index 35bf358..93451df 100644 --- a/src/features/sync.js +++ b/src/features/sync.js @@ -152,7 +152,7 @@ export function installSyncWallet(wallet) { // identity follows the open wallet wallet.registerLoadHook(() => { - if (wallet.mnemonic) wallet.nostr.load(wallet.mnemonic, wallet.passphrase); + if (wallet.mnemonic) wallet.nostr.load(wallet.mnemonic, wallet.passphrase, wallet.accountIndex || 0); else wallet.nostr.unload(); wallet._syncPubSeen = {}; // a new identity has published nothing yet adoptSyncRelays().catch(() => {}); diff --git a/src/i18n.js b/src/i18n.js index 37a7ec0..50d7c0f 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -669,6 +669,14 @@ const STR = { nwcPaidBody: '-{amount} sats via {name}', settingsNotifications: 'Notifications', searchUsers: 'Search', + spendingName: 'Spending', + savingsName: 'Savings', + renameWallet: 'Rename', + addWalletSeed: 'Seed phrase', + addWalletNewSeed: 'New seed phrase', + addWalletShareSeed: 'Same seed as {name}', + addWalletShareNote: 'Backs up under the existing phrase, but with its own separate coins, history, and identity.', + addWalletGo: 'Continue', zapSentShort: 'Zapped {n}', zapSetupTitle: 'Set your zap amount', zapSetupDesc: 'One tap on ⚡ sends this amount instantly — no form, no confirmation. You can change it any time in Settings → Payments.', diff --git a/src/nostr.js b/src/nostr.js index 8615f7d..2166880 100644 --- a/src/nostr.js +++ b/src/nostr.js @@ -252,8 +252,10 @@ export class NostrSync { setDtag(d) { this.dtag = d || DTAG; } - load(mnemonic, passphrase = '') { - this.sk = nip06.privateKeyFromSeedWords(mnemonic, passphrase || undefined); + load(mnemonic, passphrase = '', accountIndex = 0) { + // a derived sibling wallet (same seed, higher BIP84 account) gets its own + // nostr identity and sync slots — nip06 shifts by account index + this.sk = nip06.privateKeyFromSeedWords(mnemonic, passphrase || undefined, accountIndex || 0); this.pk = getPublicKey(this.sk); this.ck = nip44.getConversationKey(this.sk, this.pk); } diff --git a/src/wallet.js b/src/wallet.js index d94b56f..662ef0f 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -173,13 +173,17 @@ export class Wallet { } // --- setup -------------------------------------------------------------- - load({ mnemonic = '', passphrase = '', xpub = '', xprv = '', netName = 'mainnet', offline = false, spFresh = false }) { + load({ mnemonic = '', passphrase = '', xpub = '', xprv = '', netName = 'mainnet', offline = false, spFresh = false, accountIndex = 0 }) { this.stopRealtime(); // A freshly-generated wallet can't have received silent payments before it // existed, so we start its SP watermark at the current tip (no history scan). this.mnemonic = (mnemonic || '').trim().replace(/\s+/g, ' '); this.passphrase = passphrase; + // BIP84 account index: 0 for ordinary wallets; a wallet sharing another's + // seed lives at the next index, giving it its own coins, ark state, cache + // slot and nostr identity while backing up under one phrase. + this.accountIndex = accountIndex || 0; this.xpub = xpub || ''; this.xprv = xprv || ''; // spending wallet imported from an extended private key this.watchOnly = !!this.xpub && !this.mnemonic && !this.xprv; // view/receive only @@ -261,7 +265,7 @@ export class Wallet { // Cache key identifies the source so a reload rebuilds when it changes. const key = this.watchOnly ? 'pub:' + this.xpub : this.xprv ? 'prv:' + this.xprv - : `${this.netName}|${this.mnemonic}|${this.passphrase}`; + : `${this.netName}|${this.mnemonic}|${this.passphrase}|${this.accountIndex || 0}`; if (this._account && this._accountKey === key) return this._account; let acct; if (this.watchOnly) { @@ -270,9 +274,9 @@ export class Wallet { // A master xprv (depth 0) needs the BIP84 account path derived from it; // an already account-level xprv is used as-is. const node = HDKey.fromExtendedKey(this.xprv); - acct = node.depth === 0 ? node.derive(`m/84'/${coin}'/0'`) : node; + acct = node.depth === 0 ? node.derive(`m/84'/${coin}'/${this.accountIndex || 0}'`) : node; } else { - acct = HDKey.fromMasterSeed(mnemonicToSeedSync(this.mnemonic, this.passphrase)).derive(`m/84'/${coin}'/0'`); + acct = HDKey.fromMasterSeed(mnemonicToSeedSync(this.mnemonic, this.passphrase)).derive(`m/84'/${coin}'/${this.accountIndex || 0}'`); } this._account = acct; this._accountKey = key; @@ -1772,7 +1776,8 @@ export class Wallet { // then shows the last-known balance/history instantly while a fresh scan // runs in the background. _cacheKey() { - const id = this.watchOnly ? this.xpub : this.xprv ? this.xprv : `${this.mnemonic}\n${this.passphrase}`; + let id = this.watchOnly ? this.xpub : this.xprv ? this.xprv : `${this.mnemonic}\n${this.passphrase}`; + if (this.accountIndex) id += `\n#${this.accountIndex}`; // derived siblings get their own slots const bytes = new TextEncoder().encode(id); return 'btc-wallet-cache:' + hex.encode(sha256(bytes)).slice(0, 32); } |
