From e4d23a8e32a8feaf0cf7aeb93ce847732ff93330 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 15 Nov 2026 13:20:24 -0700 Subject: Signing up is a name and a look: no seed phrase to copy or check, no Savings until you want it, and ways back in you add afterwards. "i think i'd like to move away from requiring people to backup and verify a seed phrase to sign up. we should just have them claim a name and pick their avatar/cover and they should be in. we should also not create a savings account by default but let them add it later if they want to. adding additional auth methods like passkey, nostr, or google should be possible once they've created their account so that they can get back into it with one or all of those methods or by seed phrase." (Adam, 2026-11-15). Where a name can be claimed (mainnet, mutinynet, with Ark and the registrar), the front door's "Create an account" makes the account at once (seed saved to the device, never shown) and goes Claim your name (no Not now) → Choose your look → in. The account is Spending alone; Settings → Wallet → Balance settings has "Add a savings account" (same seed, account 0), and on-chain money or history turning up adds it by itself. Settings → Wallet opens with "Ways back in": add a passkey, Google or a Nostr account, each of which gets a copy of the seed encrypted to its own key (kind 30078, marked recovery), and signing in with it on another device opens this account as itself, no identity adopted; the recovery phrase sits in the same list with Show. Until one exists the wallet carries a quiet "Add a way back into your account" line. The shape and the list survive the session (directory) and sync between devices (snapshot). Regtest and builds without Ark keep the classic phrase flow, and so does the /migrate door's Create. tools/signup-quick-test.js (mainnet, mocked registrar, virtual passkey): the whole sign-up, Add savings, a reload, then a wiped page signs back in with the passkey to the same account and identity. balance-settings-row, send-lnaddr-advance and onboard-backup reach their seed wallets through the phrase door or on regtest; features-smoke, onboard-cover, onboard-migrate-door, nostr-login, gift-profile-setup, passkey-lock and seed-cache pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VMan6SwkivduFDDkMmnXjZ --- src/app.js | 208 ++++++++++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 186 insertions(+), 22 deletions(-) (limited to 'src/app.js') diff --git a/src/app.js b/src/app.js index 3f1daf3..5cbd9de 100644 --- a/src/app.js +++ b/src/app.js @@ -16,7 +16,7 @@ import { nip98Header } from './nip98.js'; import { uploadPublicMedia, uploadStream, mediaUploadSigner, MediaUploadError } from './media-upload.js'; import { NOSTR_MARK } from './features/nostrlogin.js'; import { scanQr } from './scan.js'; -import { dataSources, getSource, setSource, getNetwork, setNetwork, NETWORKS } from './api.js'; +import { dataSources, getSource, setSource, getNetwork, setNetwork, NETWORKS, getArkConfig } from './api.js'; import { STAGING } from './build-flags.js'; import { buildFeatures, loadDeferredFeatures } from './features/index.js'; import { cacheReady, cacheStore } from './cache-store.js'; @@ -1883,7 +1883,16 @@ async function enterWallet(mnemonic, passphrase, opts = {}) { // it — Google, a passkey and a nostr key all end up at a seed the user has // never seen, and which nobody can reissue for them. That is the one case // worth asking about, so it's recorded once and never guessed at again. - if (acc.seedSeen === undefined) acc.seedSeen = !opts.nostrPubkey; + // A sign-up (Create an account) shows no phrase either: it stays one tap + // away in Settings, next to the other ways back in (passkey, Google, nostr). + if (acc.seedSeen === undefined) acc.seedSeen = !opts.nostrPubkey && !opts.unseen; + if (opts.signup && !acc.signupAt) acc.signupAt = Date.now(); + // Spending first: an account made by signing up has no Savings side until + // its owner adds one (or on-chain money turns up — see savingsEvident). A + // recovery sign-in carries the same shape over from the account's backup. + if (opts.spendingOnly && !acc.kind && !acc.deriveIndex && !acc.savingsAdded && acc.type === 'full') { + acc.kind = 'spending'; acc.savingsLater = true; + } else if (opts.signup && acc.kind === 'spending') acc.savingsLater = true; await activateAccount(acc, { ...opts, fresh: true }); } @@ -2193,7 +2202,11 @@ 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, network: d.network, nostrPk: d.nostrPk })); + 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, nostrPk: d.nostrPk, + // a sign-up's Spending-only shape and its ways back in outlive the session + ...(d.spendingOnly ? { kind: 'spending', savingsLater: true } : {}), + ...(d.signupAt ? { signupAt: d.signupAt } : {}), + ...(Array.isArray(d.recovery) && d.recovery.length ? { recovery: d.recovery } : {}) })); } catch { return []; } } function saveDirectory() { @@ -2210,7 +2223,10 @@ function saveDirectory() { || (wallet.nostrPubkey && wallet.nostrPubkey()) || a.nostrPk; } catch {} } - return { id: a.id, label: a.label, xpub: a.xpub, autoLock: a.autoLock || 0, network: a.network, nostrPk: a.nostrPk, seedSeen: !!a.seedSeen }; + return { id: a.id, label: a.label, xpub: a.xpub, autoLock: a.autoLock || 0, network: a.network, nostrPk: a.nostrPk, seedSeen: !!a.seedSeen, + ...(a.kind === 'spending' && a.savingsLater ? { spendingOnly: true } : {}), + ...(a.signupAt ? { signupAt: a.signupAt } : {}), + ...(a.recovery && a.recovery.length ? { recovery: a.recovery } : {}) }; }); localStorage.setItem(WATCH_KEY, JSON.stringify(dir)); } catch {} @@ -3231,7 +3247,7 @@ function settingsTab() { // matters if the phone is lost tonight case 'wallet': if (ui.delAcct && a) return deleteAccountView(a); - return page(a ? [recoveryCard(a), balanceSettingsCard(a), walletNameCard(a), pubkeyCard(a), historyExportCard(), deleteAccountCard(a)] : []); + return page(a ? [featureHook('waysBackCard'), recoveryCard(a), balanceSettingsCard(a), walletNameCard(a), pubkeyCard(a), historyExportCard(), deleteAccountCard(a)] : []); case 'payments': return page(featureAll('settingsCards').reverse()); case 'pos': return page(featureAll('posSettingsCards').reverse()); // the till's own card first, then the terminal's case 'network': return page([networkCard(), explorerCard()]); // an old bookmark of the tile; the cards live under Advanced now @@ -3939,7 +3955,21 @@ function balanceSettingsCard(a) { h('span', { style: 'font-weight:600' }, viewLabel(a, view)), h('span', { class: 'small muted' }, t(view === 'spending' ? 'spendingSettingsDesc' : 'savingsSettingsDesc'))), h('span', { class: 'muted' }, '›')); - return h('div', { class: 'card col', style: 'gap:4px' }, h('h3', {}, t('balanceSettingsTitle')), ...views.map(row)); + // a sign-up's Spending-only account: Savings is one tap away + const addRow = a.kind === 'spending' && a.savingsLater && !a.deriveIndex + ? h('button', { + id: 'add-savings', + class: 'btn-ghost btn-block balance-settings-row', + style: 'display:flex;align-items:center;gap:10px;text-align:left;padding:10px 4px', + onClick: () => { addSavings(a); toast(t('savingsAddedToast')); }, + }, + h('span', { style: 'display:flex;color:var(--muted)', html: BITCOIN_ICON(18) }), + h('span', { class: 'col', style: 'flex:1;gap:2px' }, + h('span', { style: 'font-weight:600' }, t('addSavings')), + h('span', { class: 'small muted' }, t('addSavingsDesc'))), + h('span', { class: 'muted' }, '+')) + : null; + return h('div', { class: 'card col', style: 'gap:4px' }, h('h3', {}, t('balanceSettingsTitle')), ...views.map(row), addRow); } function walletNameCard(a) { const saveName = () => { @@ -4274,6 +4304,21 @@ async function publicMediaUpload(file) { // Whether the wizard can offer Spending at all: an Ark-capable build on a // network with the names registrar, for a full (signing) account. Elsewhere // the offer would be a spinner that never resolves. +// Signing up (Adam, 2026-11-15): "we should just have them claim a name and +// pick their avatar/cover and they should be in." Where a name can be +// claimed — an Ark server and the names registrar on this network — Create +// makes the account straight away: no twelve words to copy or check, no +// Savings side. The phrase waits in Settings with the other ways back in +// (passkey, Google, nostr), and a quiet line on the wallet asks for one. +// Elsewhere (regtest, a build without Ark) there's no name to claim, the +// wallet IS its seed, and Create keeps the classic phrase flow. +function quickSignupOk() { + if (!['mainnet', 'mutinynet'].includes(getNetwork())) return false; + try { + if (!FEATURES.some((f) => f.id === 'ark') || !FEATURES.some((f) => f.id === 'names')) return false; + return !!getArkConfig(); + } catch { return false; } +} function canOfferSpending() { const acc = activeAccount(); if (!acc || acc.type !== 'full' || wallet.watchOnly) return false; @@ -4310,7 +4355,8 @@ function onboardScreen() { // they lose the Google account and find out it was never a backup. // Handled further down, where page()/title() exist. const arrived = activeAccount(); - if (arrived && arrived.mnemonic && !arrived.seedSeen) o.step = 'backup'; + // (a sign-up was never meant to see it: straight on to the name) + if (arrived && arrived.mnemonic && !arrived.seedSeen && !arrived.signupAt) o.step = 'backup'; else if (canOfferSpending()) { // Before the home screen: one offer to set up Spending (a name and a // payment address), with Not now a single tap away. @@ -4318,10 +4364,11 @@ function onboardScreen() { } else { ui.onb = null; try { localStorage.removeItem(ONB_STEP_KEY); } catch {} - // On-chain first: the wizard's promise is "your keys, your coins", and - // Spending stays out of sight until the user sets it up. - ui.account = 'savings'; - try { localStorage.setItem(ACCOUNT_KEY, 'savings'); } catch {} + // On-chain first for a seed wallet: the wizard's promise is "your + // keys, your coins". A sign-up is Spending alone. + const home = arrived && arrived.kind ? arrived.kind : 'savings'; + ui.account = home; + try { localStorage.setItem(ACCOUNT_KEY, home); } catch {} ui.tab = 'receive'; return null; // the caller falls through to the wallet itself } @@ -4415,6 +4462,24 @@ function onboardScreen() { } // Making your own keys: the words are the wallet, so show them at once, // no Generate to tap. + // Create an account: a fresh seed opens at once as a Spending-only + // account; the wizard carries on to the name and the look. + const startQuick = async () => { + if (o.creating) return; + o.creating = true; ui.onbError = ''; render(); + try { + clearSeedDrafts(); + ui.passphrase = ''; ui.confirmPass = ''; ui.draftMnemonic = ''; + ui.newWalletKind = 'spending'; + await enterWallet(newMnemonic(), '', { generated: true, unseen: true, signup: true }); + } catch (e) { + ui.onbError = e.message || String(e); + } finally { + ui.newWalletKind = null; + o.creating = false; + render(); + } + }; const startCreate = () => { ui.unlockTab = 'create'; ui.unlockError = ''; @@ -4443,11 +4508,16 @@ function onboardScreen() { title(t('onbWelcomeTitle')), // spaced as the sign-in list is (10px) h('div', { class: 'col', style: 'gap:10px' }, - h('button', { - // a stable hook for tests, so this button survives its own copy - id: 'onb-create', - class: 'btn-primary btn-block', style: 'font-size:17px;padding:14px', onClick: startCreate, - }, t('onbStart')), + quickSignupOk() + ? h('button', { + // a stable hook for tests, so this button survives its own copy + id: 'onb-create', 'data-quick': '1', + class: 'btn-primary btn-block', style: 'font-size:17px;padding:14px', disabled: !!o.creating, onClick: startQuick, + }, o.creating ? h('span', { class: 'spinner sm' }) : t('onbCreateAccount')) + : h('button', { + id: 'onb-create', + class: 'btn-primary btn-block', style: 'font-size:17px;padding:14px', onClick: startCreate, + }, t('onbStart')), h('button', { id: 'onb-signin', class: 'btn-block', style: 'font-size:17px;padding:14px', @@ -4572,14 +4642,18 @@ function onboardScreen() { ]); } if (o.step === 'spend') { + // A sign-up's name is the account itself — what people pay, follow and + // message — so it's claimed here, not skipped. + const signupAcc = activeAccount(); + const signup = !o.fromGift && !!(signupAcc && signupAcc.signupAt && signupAcc.kind === 'spending'); return page([ - title(t(o.fromGift ? 'onbGiftNameTitle' : 'onbSpendTitle')), - h('p', { class: 'muted', style: 'margin:0' }, t(o.fromGift ? 'onbGiftNameBody' : 'onbSpendBody')), + title(t(o.fromGift ? 'onbGiftNameTitle' : signup ? 'onbNameTitle' : 'onbSpendTitle')), + h('p', { class: 'muted', style: 'margin:0' }, t(o.fromGift ? 'onbGiftNameBody' : signup ? 'onbNameBody' : 'onbSpendBody')), featureHook('namesClaimForm') || h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('onbNameWait'))), // Optional means optional: one tap to the wallet, no questions — on // Savings, the account that exists without any setup (or, after a // gift, the account the gift arrived in). - h('button', { class: 'btn-ghost btn-block', onClick: () => { + signup ? null : h('button', { class: 'btn-ghost btn-block', onClick: () => { const home = o.home || 'savings'; ui.onb = null; try { localStorage.removeItem(ONB_STEP_KEY); } catch {} @@ -4915,22 +4989,107 @@ wallet.registerCacheExtension({ }, }); +// ---- Savings, later -------------------------------------------------------- +// An account made by signing up is Spending alone (kind 'spending', marked +// savingsLater): no on-chain face, no carousel, no Move money. Its owner adds +// Savings from Settings → Wallet whenever they like — the same seed, account +// 0, so it is the on-chain wallet that was always underneath. A balance must +// never hide, so on-chain money or history arriving adds it by itself. +function addSavings(acc, { quiet = false } = {}) { + if (!acc || acc.kind !== 'spending' || !acc.savingsLater) return false; + delete acc.kind; delete acc.savingsLater; + if (!acc.spendingSetup) acc.spendingSetup = Date.now(); + acc.savingsAdded = Date.now(); + persistAccounts(); + if (acc.persisted) writeVault(); + if (acc.id === activeId) { try { wallet.saveCache(); } catch {} } + if (!quiet) render(); + return true; +} +function savingsEvident() { + const a = activeAccount(); + if (!a || a.kind !== 'spending' || !a.savingsLater || !wallet.loaded) return; + if ((wallet.spendable || 0) > 0 || (wallet.pendingIncoming || 0) > 0 || (wallet.txs || []).length) addSavings(a, { quiet: true }); +} +// The shape rides the synced snapshot, so the phone opens the account the +// way the laptop has it: Spending-only until Savings is added on either. +// Commutative: savingsAdded only ever turns on and wins over spendingOnly. +wallet.registerCacheExtension({ + mergeAlways: true, + save: () => { + const a = activeAccount(); + if (!a) return {}; + if (a.savingsAdded) return { savingsAdded: a.savingsAdded }; + return a.kind === 'spending' && a.savingsLater ? { spendingOnly: true } : {}; + }, + load: (d) => { + const a = activeAccount(); + if (!d || !a || a.type !== 'full' || a.deriveIndex) return; + if (d.savingsAdded) { if (addSavings(a, { quiet: true }) === false && !a.savingsAdded) { a.savingsAdded = d.savingsAdded; persistAccounts(); } return; } + if (d.spendingOnly && !a.kind && !a.savingsAdded && !(wallet.txs || []).length && !(wallet.spendable || 0)) { + a.kind = 'spending'; a.savingsLater = true; + persistAccounts(); + } + }, +}); + +// ---- ways back in ---------------------------------------------------------- +// The sign-in methods that can open this account somewhere else, besides its +// recovery phrase: a passkey, a Google account, a nostr identity — each holds +// a copy of the seed encrypted to its own key on the relays (see the +// nostrlogin feature's addRecovery). Listed on the account so Settings can +// say what's set up; the list rides the synced snapshot, union by key. +function recoveryMethods(a = activeAccount()) { + return (a && Array.isArray(a.recovery)) ? a.recovery : []; +} +function sameSeed(a, x) { + return x.mnemonic && x.mnemonic === a.mnemonic && (x.passphrase || '') === (a.passphrase || ''); +} +function mergeRecovery(list) { + const a = activeAccount(); + if (!a || !a.mnemonic || !Array.isArray(list) || !list.length) return false; + let changed = false; + for (const x of accounts) { + if (x !== a && !sameSeed(a, x)) continue; + const have = new Map(recoveryMethods(x).map((m) => [m.id, m])); + for (const m of list) if (m && m.id && !have.has(m.id)) { have.set(m.id, m); changed = true; } + x.recovery = [...have.values()].sort((p, q) => (p.at || 0) - (q.at || 0)); + } + if (changed) { persistAccounts(); if (a.persisted) writeVault(); } + return changed; +} +function addRecoveryMethod(m) { + if (!mergeRecovery([{ ...m, at: m.at || Date.now() }])) return; + try { wallet.saveCache(); } catch {} + render(); +} +wallet.registerCacheExtension({ + mergeAlways: true, + save: () => { const r = recoveryMethods(); return r.length ? { recovery: r } : {}; }, + load: (d) => { if (d && d.recovery) mergeRecovery(d.recovery); }, +}); + // A wallet whose recovery phrase has never been on screen keeps one quiet // line about it. "Later" is a real answer, so this is a row and not a modal — // but it doesn't dismiss, because the risk doesn't go away on its own. It // disappears the moment the phrase is actually seen (here or in settings). function backupNotice() { + savingsEvident(); const a = activeAccount(); if (!a || !a.mnemonic || a.seedSeen || wallet.watchOnly) return null; + // A sign-up never saw a phrase and wasn't asked to: what it needs is A way + // back in, any one — and once it has one, nothing to nag about. + const signup = !!a.signupAt; + if (signup && recoveryMethods(a).length) return null; return h('div', { class: 'small muted', style: 'margin:10px 0 0;text-align:center' }, - t('backupReminder'), + t(signup ? 'waysBackReminder' : 'backupReminder'), ' ', h('button', { class: 'linklike small', onClick: () => { ui.tab = 'settings'; ui.settingsPage = 'wallet'; - ui.revealShown = 'masked'; + if (!signup) ui.revealShown = 'masked'; render(); - } }, t('backupReminderGo'))); + } }, t(signup ? 'waysBackReminderGo' : 'backupReminderGo'))); } // Safe mode (see BOOT_GUARD in build.js): the app crashed on open twice in a @@ -6857,6 +7016,11 @@ const ctx = { getAccount: () => accountSel(), setAccount: (a, dir) => setAccountSel(a, dir), offerProfileSetup, pickConfirm, + // the ways back in (Settings → Wallet): what's set up, and adding one + recoveryMethods: () => recoveryMethods(), addRecoveryMethod, + spendingOnlyAccount: () => { const a = activeAccount(); return !!(a && a.kind === 'spending' && a.savingsLater); }, + seedSeen: () => !!(activeAccount() || {}).seedSeen, + showRecoveryPhrase: () => { ui.tab = 'settings'; ui.settingsPage = 'wallet'; ui.revealShown = 'masked'; render(); }, // Open (or create) a wallet from a mnemonic — used by nostr login. openMnemonic: async (mnemonic, passphrase, opts) => enterWallet(mnemonic, passphrase, opts), // Profile pictures retain the metadata-scrubbing image uploader. Public -- cgit v1.2.3