aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-11-15 13:20:24 -0700
committerAdam Soltys <[email protected]>2026-11-15 13:20:24 -0700
commite4d23a8e32a8feaf0cf7aeb93ce847732ff93330 (patch)
treed3641c81250abd49a8ced3e5daa37cf1daf5799a
parent79d4f77f3f70877c0c4951aff5dc0c42800ab73c (diff)
downloadcoinosv3-e4d23a8e32a8feaf0cf7aeb93ce847732ff93330.tar.gz
coinosv3-e4d23a8e32a8feaf0cf7aeb93ce847732ff93330.zip
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
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01VMan6SwkivduFDDkMmnXjZ
-rw-r--r--src/app.js208
-rw-r--r--src/features/nostrlogin.js128
-rw-r--r--src/i18n.js23
-rw-r--r--src/nostr-login.js12
-rw-r--r--src/passkey.js6
-rw-r--r--src/style.css4
-rw-r--r--tools/balance-settings-row-test.js13
-rw-r--r--tools/onboard-backup-test.js4
-rw-r--r--tools/send-lnaddr-advance-test.js3
-rw-r--r--tools/signup-quick-test.js167
10 files changed, 536 insertions, 32 deletions
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
diff --git a/src/features/nostrlogin.js b/src/features/nostrlogin.js
index 4a811fc..e48e9a2 100644
--- a/src/features/nostrlogin.js
+++ b/src/features/nostrlogin.js
@@ -158,6 +158,17 @@ export function nostrLoginFeature(ctx) {
// and the payment name too — the Receive pane should open with its QR
const nameHintP = ctx.hook('namesLookupByPk', signer.pubkey);
const res = await walletForSigner(signer);
+ // A way back in (added from Settings): the key holds a copy of an
+ // account's seed, and opens that account as ITSELF — its own name,
+ // profile and identity. Nothing about the key is adopted or kept.
+ if (res.mode === 'restored' && res.recovery) {
+ try { if (signer.kind === 'bunker' && signer.close) signer.close(); } catch {}
+ ctx.onbNostrLogin(true);
+ ui.navAnimSkip = true;
+ await ctx.openMnemonic(res.mnemonic, res.passphrase || '', { spendingHint: !!res.spending, spendingOnly: !!res.spendingOnly });
+ busy(false);
+ return;
+ }
if (res.mode === 'new') {
// No wallet on this identity yet — make one and walk straight in.
// The interstitial that used to ask first read as an error screen to
@@ -681,6 +692,122 @@ export function nostrLoginFeature(ctx) {
resumeLogin().then((s) => { if (s) render(); }).catch(() => {});
}
+ // ---- ways back in ------------------------------------------------------
+ // Settings → Wallet: the sign-in methods that can open THIS account on
+ // another device, added after the fact — a passkey, Google, a nostr key —
+ // next to the recovery phrase. Each one gets a copy of the seed encrypted
+ // to its own key (kind 30078, marked `recovery`), so signing in with it
+ // anywhere opens this account as itself. Unlike linking a login, the
+ // account's identity doesn't change.
+ async function addRecovery(makeSigner, via, label = '') {
+ ui.waysBackError = ''; ui.waysBackBusy = via; ui.nostrLoginBusy = true; render();
+ let signer = null;
+ try {
+ if (!wallet.mnemonic) throw new Error(t('nlNeedSeed'));
+ signer = await makeSigner();
+ const st = load();
+ if (st.pubkey && st.pubkey === signer.pubkey) throw new Error(t('waysBackIsLogin'));
+ // a key that already opens a DIFFERENT wallet keeps it: never overwrite
+ const existing = await fetchWalletBackup(signer);
+ if (existing && existing.mnemonic
+ && (existing.mnemonic !== wallet.mnemonic || (existing.passphrase || '') !== (wallet.passphrase || ''))) {
+ throw new Error(t('waysBackTaken'));
+ }
+ await publishWalletBackup(signer, {
+ mnemonic: wallet.mnemonic, passphrase: wallet.passphrase || '',
+ spending: true, recovery: true, spendingOnly: !!ctx.spendingOnlyAccount(),
+ });
+ ctx.addRecoveryMethod({ via, id: signer.pubkey, label });
+ ui.waysBackNostr = false;
+ toast(t('waysBackAdded'));
+ } catch (e) {
+ const msg = String((e && e.message) || e || '');
+ // a dismissed passkey or Google dialog is a plain cancel
+ if (!/NotAllowed|abort/i.test(String((e && e.name) || '') + ' ' + msg)) ui.waysBackError = msg;
+ } finally {
+ try { if (signer && signer.kind === 'bunker' && signer.close) signer.close(); } catch {}
+ ui.waysBackBusy = null; ui.nostrLoginBusy = false;
+ render();
+ }
+ }
+ async function passkeyRecovery() {
+ const pass = await import('../passkey.js');
+ const name = ctx.hook('namesAddress') || 'coinos';
+ await addRecovery(async () => keySigner(await pass.passkeyCreate({ name, displayName: name })), 'passkey');
+ }
+ async function googleRecovery() {
+ // opened in the click itself (user activation) — see googleLogin
+ const popup = window.open('about:blank', 'OAuth', 'width=600,height=600');
+ ui.waysBackError = ''; ui.waysBackBusy = 'google'; render();
+ try {
+ const pome = await import('../pomegranate.js');
+ const { uri, email } = await pome.loginWithGoogle(popup, {});
+ await addRecovery(() => bunkerSigner(uri, { onAuth: (url) => { ui.nostrLoginAuthUrl = url; render(); } }), 'google', email || '');
+ } catch (e) {
+ try { popup && popup.close(); } catch {}
+ ui.waysBackError = (e && e.message) || String(e);
+ ui.waysBackBusy = null;
+ render();
+ }
+ }
+ const fmtDay = (ms) => { try { return new Date(ms).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); } catch { return ''; } };
+ function waysBackCard() {
+ if (wallet.watchOnly || !wallet.mnemonic) return null;
+ const methods = ctx.recoveryMethods() || [];
+ const st = load();
+ const busyVia = ui.waysBackBusy;
+ const hasPasskey = typeof window !== 'undefined' && !!window.PublicKeyCredential && !!navigator.credentials;
+ const done = (mark, title, detail, key) => h('div', { class: 'row gap6 ways-back-row', 'data-way': key, style: 'align-items:center;padding:8px 2px' },
+ h('span', { style: 'display:flex;flex-shrink:0;width:23px;justify-content:center', html: mark }),
+ h('span', { class: 'col', style: 'flex:1;gap:1px;min-width:0' },
+ h('span', { style: 'font-weight:600' }, title),
+ detail ? h('span', { class: 'small muted break' }, detail) : null),
+ h('span', { class: 'ways-back-check', 'aria-label': t('waysBackOn') }, '✓'));
+ const add = (mark, label, via, onClick) => h('button', {
+ class: 'btn-block ways-back-add', 'data-way': via, disabled: !!busyVia, onClick,
+ }, markedLabel(mark, label, busyVia === via));
+ const rows = [];
+ // the sign-in this account came through is a way back in already
+ if (st.pubkey) {
+ const via = st.via || 'nostr';
+ rows.push(done(via === 'google' ? GOOGLE_MARK : via === 'passkey' ? PASSKEY_MARK : OSTRICH,
+ t('waysBackLogin', { via: via === 'google' ? 'Google' : via === 'passkey' ? t('doorPasskey') : 'Nostr' }),
+ via === 'google' || via === 'passkey' ? '' : (npubOf(st.pubkey) || '').slice(0, 20) + '…', 'login'));
+ }
+ for (const m of methods) {
+ if (m.via === 'passkey') rows.push(done(PASSKEY_MARK, t('doorPasskey'), t('waysBackAddedOn', { date: fmtDay(m.at) }), 'passkey'));
+ else if (m.via === 'google') rows.push(done(GOOGLE_MARK, 'Google', m.label || t('waysBackAddedOn', { date: fmtDay(m.at) }), 'google'));
+ else rows.push(done(OSTRICH, 'Nostr', (npubOf(m.id) || '').slice(0, 20) + '…', 'nostr'));
+ }
+ const seen = ctx.seedSeen();
+ rows.push(h('div', { class: 'row gap6 ways-back-row', 'data-way': 'phrase', style: 'align-items:center;padding:8px 2px' },
+ h('span', { style: 'display:flex;flex-shrink:0;width:23px;justify-content:center;font-size:17px' }, '🔑'),
+ h('span', { class: 'col', style: 'flex:1;gap:1px' },
+ h('span', { style: 'font-weight:600' }, t('recoveryPhrase')),
+ h('span', { class: 'small muted' }, t(seen ? 'waysBackPhraseSeen' : 'waysBackPhraseUnseen'))),
+ seen ? h('span', { class: 'ways-back-check' }, '✓')
+ : h('button', { class: 'btn-sm', onClick: () => ctx.showRecoveryPhrase() }, t('waysBackShow'))));
+ const have = (via) => methods.some((m) => m.via === via) || (via !== 'nostr' && st.via === via);
+ const adds = [
+ hasPasskey && !have('passkey') ? add(PASSKEY_MARK, t('waysBackAddPasskey'), 'passkey', () => passkeyRecovery()) : null,
+ !have('google') ? add(GOOGLE_MARK, t('waysBackAddGoogle'), 'google', () => googleRecovery()) : null,
+ ui.waysBackNostr
+ ? h('div', { class: 'col', style: 'gap:8px' },
+ h('div', { class: 'row between' },
+ h('strong', { class: 'small' }, t('waysBackAddNostr')),
+ h('span', { class: 'linklike small', onClick: () => { ui.waysBackNostr = false; ui.waysBackError = ''; render(); } }, t('cancel'))),
+ signerButtons((make) => addRecovery(make, 'nostr'), { nostrOnly: true }))
+ : add(OSTRICH, t('waysBackAddNostr'), 'nostr', () => { ui.waysBackNostr = true; ui.waysBackError = ''; ui.nostrLoginError = ''; render(); }),
+ ].filter(Boolean);
+ return h('div', { class: 'card col ways-back', style: 'gap:6px' },
+ h('h3', {}, t('waysBackTitle')),
+ h('p', { class: 'small muted', style: 'margin:0 0 4px' }, t('waysBackDesc')),
+ h('div', { class: 'col', style: 'gap:0' }, ...rows),
+ adds.length ? h('div', { class: 'col', style: 'gap:8px;margin-top:6px' }, ...adds) : null,
+ ui.waysBackError ? h('div', { class: 'notice err' }, ui.waysBackError) : null,
+ h('p', { class: 'small faint', style: 'margin:4px 0 0' }, t('waysBackNote')));
+ }
+
function settingsCard() {
if (wallet.watchOnly || !wallet.mnemonic) return null;
const st = load();
@@ -879,5 +1006,6 @@ export function nostrLoginFeature(ctx) {
].filter(Boolean);
},
nostrSettingsCards() { return [settingsCard()]; },
+ waysBackCard() { return waysBackCard(); },
};
}
diff --git a/src/i18n.js b/src/i18n.js
index 3e42daa..9b59710 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -1498,6 +1498,29 @@ const STR = {
onbContinue: 'Continue',
onbSkipAvatar: 'Continue',
onbDoneTitle: "You're all set!",
+ onbCreateAccount: 'Create an account',
+ onbNameTitle: 'Claim your name',
+ onbNameBody: "It's your username and your payment address: people pay you, follow you and message you with just your name.",
+ waysBackReminder: 'Add a way back into your account, in case you lose this device.',
+ waysBackReminderGo: 'Add one',
+ addSavings: 'Add a savings account',
+ addSavingsDesc: 'On-chain bitcoin, for keeping. Same account, a second balance.',
+ savingsAddedToast: 'Savings added',
+ waysBackTitle: 'Ways back in',
+ waysBackDesc: 'Any one of these opens this account on a new device, or if this one is lost.',
+ waysBackNote: 'Each one can open your account and spend its money, so only add ones you control.',
+ waysBackOn: 'Set up',
+ waysBackLogin: 'Signed in with {via}',
+ waysBackAddedOn: 'Added {date}',
+ waysBackPhraseSeen: 'Seen. Keep it written down somewhere safe',
+ waysBackPhraseUnseen: 'Twelve words that open this account in any wallet',
+ waysBackShow: 'Show',
+ waysBackAddPasskey: 'Add a passkey',
+ waysBackAddGoogle: 'Add Google',
+ waysBackAddNostr: 'Add a Nostr account',
+ waysBackAdded: 'Added. You can sign in with it now',
+ waysBackIsLogin: 'That is the account you signed in with; it already opens this one.',
+ waysBackTaken: 'That sign-in already opens a different coinos account, so it can’t open this one too.',
onbDoneBody: 'Your spending account is set up. Your address is live and the coinos community chat is waiting for you.',
onbEnter: 'Enter coinos',
spendSetup: 'Set up a spending account',
diff --git a/src/nostr-login.js b/src/nostr-login.js
index 3357f61..562a5d4 100644
--- a/src/nostr-login.js
+++ b/src/nostr-login.js
@@ -514,8 +514,14 @@ export async function fetchWalletBackup(signer, relays = BACKUP_RELAYS) {
// `spending` rides along as a hint: a restored wallet paints its Spending
// side from the first frame instead of reflowing when the name lookup proves
// it seconds later.
-export async function publishWalletBackup(signer, { mnemonic, passphrase, spending }, relays = BACKUP_RELAYS) {
- const content = await signer.encryptSelf(JSON.stringify({ mnemonic, passphrase: passphrase || '', ...(spending ? { spending: true } : {}) }));
+// `recovery` marks a copy kept by a WAY BACK IN (a passkey, Google or nostr
+// key added from Settings): signing in with that key opens the account as
+// itself — its own identity, name and profile — rather than adopting the
+// key as the account's nostr identity the way a login does. `spendingOnly`
+// carries a sign-up's shape (no Savings side yet) to the next device.
+export async function publishWalletBackup(signer, { mnemonic, passphrase, spending, recovery, spendingOnly }, relays = BACKUP_RELAYS) {
+ const content = await signer.encryptSelf(JSON.stringify({ mnemonic, passphrase: passphrase || '',
+ ...(spending ? { spending: true } : {}), ...(recovery ? { recovery: true } : {}), ...(spendingOnly ? { spendingOnly: true } : {}) }));
const evt = await signer.signEvent({
kind: BACKUP_KIND, created_at: Math.floor(Date.now() / 1000),
tags: [['d', SEED_DTAG]], content,
@@ -537,7 +543,7 @@ export async function publishWalletBackup(signer, { mnemonic, passphrase, spendi
// deriving (key in hand) or generating (signer only).
export async function walletForSigner(signer) {
const found = await fetchWalletBackup(signer);
- if (found) return { mnemonic: found.mnemonic, passphrase: found.passphrase, spending: !!found.spending, mode: 'restored' };
+ if (found) return { mnemonic: found.mnemonic, passphrase: found.passphrase, spending: !!found.spending, recovery: !!found.recovery, spendingOnly: !!found.spendingOnly, mode: 'restored' };
if (signer.secret) {
// Publishing the derived seed costs nothing: the derivation is public,
// so anyone holding this key could compute it anyway. It buys the thing
diff --git a/src/passkey.js b/src/passkey.js
index 248e1e6..7d5e6e5 100644
--- a/src/passkey.js
+++ b/src/passkey.js
@@ -54,14 +54,16 @@ export async function passkeySignIn() {
// Create a passkey, then derive the key from it. Some platforms evaluate PRF
// during creation; the rest need a follow-up assertion against the new
// credential — both paths end at the same bytes.
-export async function passkeyCreate() {
+// `name` labels the credential in the platform's passkey picker (a way back
+// into a named account reads "[email protected]", not just "coinos").
+export async function passkeyCreate({ name = 'coinos', displayName = 'Coinos wallet' } = {}) {
const cred = await navigator.credentials.create({
publicKey: {
challenge: crypto.getRandomValues(new Uint8Array(32)),
rp: { name: 'Coinos', id: location.hostname },
user: {
id: crypto.getRandomValues(new Uint8Array(16)),
- name: 'coinos', displayName: 'Coinos wallet',
+ name, displayName,
},
pubKeyCredParams: [{ type: 'public-key', alg: -7 }, { type: 'public-key', alg: -257 }],
authenticatorSelection: { residentKey: 'required', userVerification: 'required' },
diff --git a/src/style.css b/src/style.css
index 1db0984..8206000 100644
--- a/src/style.css
+++ b/src/style.css
@@ -2179,3 +2179,7 @@ html:has(.call-overlay:not(.call-min)) { overflow: hidden; scrollbar-gutter: aut
/* on: the accent with its own text colour (accent-fg). accent-ink, used
before, is light on dark mode's white accent: unreadable (Adam, 2026-11-15) */
.moment-toggle.on { color: var(--accent-fg); background: var(--accent); border: 1px solid var(--accent); }
+
+/* Settings → Wallet: the ways back into an account */
+.ways-back .ways-back-row + .ways-back-row { border-top: 1px solid var(--line); }
+.ways-back .ways-back-check { color: var(--green, #2e9d5b); font-weight: 700; font-size: 16px; padding: 0 4px; }
diff --git a/tools/balance-settings-row-test.js b/tools/balance-settings-row-test.js
index 89e68d9..38d34b3 100644
--- a/tools/balance-settings-row-test.js
+++ b/tools/balance-settings-row-test.js
@@ -2,7 +2,9 @@
// balance card's gear opens. "I simply cannot find this option" (Nils,
// 2026-11-12) — the Spending gear was the only way in.
// Run: bun tools/balance-settings-row-test.js (NO_BUILD=1 to reuse dist/)
-import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
+import puppeteer from './puppeteer.js';
+import { generateMnemonic } from '@scure/bip39';
+import { wordlist } from '@scure/bip39/wordlists/english';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let ok = true;
const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
@@ -32,13 +34,16 @@ try {
await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'mainnet'));
await page.reload({ waitUntil: 'domcontentloaded' });
await sleep(400);
- await click('create a new wallet'); await sleep(600);
- await seedWords(page);
- await skipSeedCheck(page);
+ // on mainnet Create makes a Spending-only account (signup-quick-test); a
+ // seed wallet with Savings alone comes in through the seed-phrase door
+ await page.waitForSelector('#onb-signin'); await page.click('#onb-signin'); await sleep(400);
+ await page.click('#onb-restore'); await sleep(400);
+ await page.waitForSelector('textarea'); await page.type('textarea', generateMnemonic(wordlist)); await click('open wallet');
if (await waitText('claim a username', 8000)) { await click('not now'); await sleep(800); }
await waitText('receive');
await sleep(1500);
check('savings-only wallet lists Savings alone', await (async () => { await openWalletPage(); const v = await page.$$eval('.balance-settings-row', (l) => l.map((e) => e.dataset.view)); await page.goBack(); await page.goBack(); await sleep(500); return v.join() === 'savings'; })());
+ await waitText('set up a spending account', 30000);
await click('set up a spending account'); await sleep(800);
if (await waitText('claim a username', 5000)) { await click('not now'); await sleep(800); }
await waitText('receive'); await sleep(800);
diff --git a/tools/onboard-backup-test.js b/tools/onboard-backup-test.js
index 8fc1071..946f342 100644
--- a/tools/onboard-backup-test.js
+++ b/tools/onboard-backup-test.js
@@ -64,6 +64,10 @@ try {
const page = await browser.newPage();
await page.setViewport({ width: 390, height: 844 });
await page.goto('http://localhost:5291/', { waitUntil: 'domcontentloaded' });
+ // regtest: the classic phrase flow. Where a name can be claimed (mainnet,
+ // mutinynet) Create is the quick sign-up instead — signup-quick-test.js.
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
await sleep(1500);
// Two choices and nothing else (2026-11-06): a new wallet, or one you
// already have — the sign-in methods wait behind the second.
diff --git a/tools/send-lnaddr-advance-test.js b/tools/send-lnaddr-advance-test.js
index fd6c1a1..14640ca 100644
--- a/tools/send-lnaddr-advance-test.js
+++ b/tools/send-lnaddr-advance-test.js
@@ -43,7 +43,8 @@ try {
await page.setViewport({ width: 390, height: 844 });
await page.goto(server.url.origin, { waitUntil: 'domcontentloaded' });
await sleep(400);
- await click('create a new wallet'); await sleep(500); await click('to import'); await sleep(400);
+ await page.waitForSelector('#onb-signin'); await page.click('#onb-signin'); await sleep(400);
+ await page.click('#onb-restore'); await sleep(400);
await page.waitForSelector('textarea'); await page.type('textarea', generateMnemonic(wordlist)); await click('open wallet');
// mainnet onboarding: skip the username step (and anything after it)
for (let i = 0; i < 80 && !(await page.evaluate(() => [...document.querySelectorAll('button')].some((b) => b.textContent.trim() === 'Send'))); i++) {
diff --git a/tools/signup-quick-test.js b/tools/signup-quick-test.js
new file mode 100644
index 0000000..c80547d
--- /dev/null
+++ b/tools/signup-quick-test.js
@@ -0,0 +1,167 @@
+// Signing up is a name and a look, nothing else (Adam, 2026-11-15): "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 … adding additional auth methods like
+// passkey, nostr, or google should be possible once they've created their
+// account."
+//
+// Mainnet, with the Ark server's info read for real and the names registrar
+// mocked (nothing is claimed): Create an account → no twelve words, no check
+// → Claim your name (no "Not now") → Choose your look → in, on a Spending-only
+// wallet (no carousel, no Savings face); the wallet line asks for a way back
+// in; Settings → Wallet shows "Ways back in" (passkey via a CDP virtual
+// authenticator → listed, the line goes away) and "Add a savings account"
+// (→ the Savings face appears); a reload keeps both.
+// Run: bun tools/signup-quick-test.js (NO_BUILD=1 to reuse dist/)
+import puppeteer from './puppeteer.js';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+if (!process.env.NO_BUILD && Bun.spawnSync(['bun', 'build.js']).exitCode !== 0) throw new Error('build failed');
+const server = Bun.serve({ port: 0, fetch: async (req) => {
+ const path = new URL(req.url).pathname;
+ const f = Bun.file('dist' + path);
+ return path.includes('.') && await f.exists() ? new Response(f) : new Response(Bun.file('dist/index.html'));
+} });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+const errs = [];
+const registered = [];
+const cors = { 'access-control-allow-origin': '*', 'access-control-allow-headers': 'content-type,authorization', 'access-control-allow-methods': 'GET,POST,DELETE,OPTIONS' };
+await page.setRequestInterception(true);
+page.on('request', (r) => {
+ const u = new URL(r.url());
+ if (u.origin === server.url.origin || u.hostname === 'ark.coinos.io') return r.continue();
+ if (u.hostname !== 'names.coinos.io') return r.abort();
+ if (r.method() === 'OPTIONS') return r.respond({ status: 200, headers: cors, body: '{}' });
+ const reply = (j, status = 200) => r.respond({ status, headers: { ...cors, 'content-type': 'application/json' }, body: JSON.stringify(j) });
+ if (u.pathname === '/register' && r.method() === 'POST') { const j = JSON.parse(r.postData() || '{}'); registered.push(j.name); return reply({ ok: true, name: j.name }); }
+ if (u.pathname.startsWith('/name/')) return reply({ taken: false });
+ if (u.pathname.startsWith('/pubkey/')) return reply({});
+ return reply({});
+});
+page.on('pageerror', (e) => errs.push(String(e)));
+const click = (x) => page.evaluate((s) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(s)); if (e) e.click(); return !!e; }, x);
+const text = () => page.evaluate(() => document.body.innerText);
+const waitText = async (x, ms = 20000) => { for (let i = 0; i < ms / 250; i++) { if ((await text()).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+const openWalletPage = async () => {
+ await page.evaluate(() => [...document.querySelectorAll('button')].find((e) => e.getAttribute('aria-label') === 'Settings').click()); await sleep(500);
+ await page.evaluate(() => [...document.querySelectorAll('.settings-tile')].find((e) => /^Wallet/.test(e.textContent.trim())).click()); await sleep(600);
+};
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ const cdp = await page.createCDPSession();
+ await cdp.send('WebAuthn.enable');
+ await cdp.send('WebAuthn.addVirtualAuthenticator', { options: {
+ protocol: 'ctap2', transport: 'internal', hasResidentKey: true, hasUserVerification: true, isUserVerified: true, hasPrf: true, automaticPresenceSimulation: true } });
+ await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'mainnet'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await page.waitForSelector('#onb-create', { timeout: 15000 });
+ check('the front door says Create an account', (await page.$eval('#onb-create', (e) => e.textContent.trim())) === 'Create an account');
+ await page.click('#onb-create');
+ check('no seed screens: straight to Claim your name', await waitText('claim your name', 20000));
+ const t1 = await text();
+ check('no twelve words or check on the way', !(await page.$('.words')) && !/which word/i.test(t1));
+ check('no "Not now" on a sign-up\'s name step', !/not now/i.test(t1));
+ await page.waitForSelector('.onb input[type=text]', { timeout: 20000 });
+ await page.type('.onb input[type=text]', 'quicktester');
+ await click('claim');
+ check('the name goes to the registrar', await (async () => { for (let i = 0; i < 80 && !registered.length; i++) await sleep(250); return registered.includes('quicktester'); })(), registered.join());
+ check('then the look: avatar and cover', await (async () => { for (let i = 0; i < 80; i++) { if (await page.$('.onb-cover')) return true; await sleep(250); } return false; })());
+ await click('continue'); await sleep(800);
+ if (await waitText("you're all set", 8000)) await click('enter');
+ await page.waitForSelector('.balance-face', { timeout: 20000 });
+ await sleep(1500);
+ const shape = async () => page.evaluate(() => ({
+ faces: document.querySelectorAll('.balance-face').length,
+ carousel: !!document.querySelector('.bal-carousel'),
+ label: document.querySelector('.balance-face .small.faint')?.textContent.trim(),
+ body: document.body.innerText,
+ }));
+ let s = await shape();
+ check('in, on Spending alone (no Savings face)', s.faces === 1 && !s.carousel && /spending/i.test(s.label || ''), JSON.stringify({ faces: s.faces, carousel: s.carousel, label: s.label }));
+ check('a quiet line asks for a way back in', /add a way back into your account/i.test(s.body));
+ const acct = await page.evaluate(() => JSON.parse(sessionStorage.getItem('btc-wallet-accounts') || '{}'));
+ const a0 = (acct.accounts || [])[0] || {};
+ check('the account: spending-only, phrase unseen, saved to the device', a0.kind === 'spending' && a0.savingsLater && a0.seedSeen === false && a0.persisted, JSON.stringify({ kind: a0.kind, later: a0.savingsLater, seen: a0.seedSeen, persisted: a0.persisted }));
+
+ await openWalletPage();
+ check('Settings → Wallet opens with Ways back in', !!(await page.$('.ways-back')) && /ways back in/i.test(await text()));
+ const views = await page.$$eval('.balance-settings-row[data-view]', (l) => l.map((e) => e.dataset.view));
+ check('only Spending is listed, with Add a savings account', views.join() === 'spending' && !!(await page.$('#add-savings')), views.join());
+ if (process.env.SHOT) { await page.evaluate(() => document.querySelector('.ways-back').scrollIntoView()); await page.screenshot({ path: process.env.SHOT }); }
+ // a passkey: the relays are stubbed, so the publish fails offline — the
+ // error says so and nothing is listed
+ await page.evaluate(() => document.querySelector('.ways-back-add[data-way="passkey"]')?.click());
+ await sleep(4000);
+ const pk = await page.evaluate(() => ({ rows: [...document.querySelectorAll('.ways-back-row')].map((e) => e.dataset.way), err: document.querySelector('.ways-back .notice.err')?.textContent || '' }));
+ check('adding a passkey lists it', pk.rows.includes('passkey') && !pk.err, JSON.stringify(pk));
+ const backups = await page.evaluate(() => (window.__relayGuard.events || []).filter((e) => e.kind === 30078 && e.tags.some((t) => t[0] === 'd' && t[1] === 'coinos:wallet:v1')));
+ check('the reminder line is gone once there is a way back in', await (async () => { await page.goBack(); await page.goBack(); await sleep(600); const gone = !/add a way back into your account/i.test(await text()); await openWalletPage(); return gone; })());
+ // the list and the reminder, as the app sees them (record a method directly)
+ await page.evaluate(() => [...document.querySelectorAll('button')].find((b) => b.textContent.trim() === 'Show')?.click());
+ await sleep(500);
+ check('Show opens the recovery phrase card', /recovery phrase/i.test(await text()));
+
+ await page.evaluate(() => document.getElementById('add-savings')?.click()); await sleep(800);
+ const views2 = await page.$$eval('.balance-settings-row[data-view]', (l) => l.map((e) => e.dataset.view));
+ check('Add a savings account adds Savings beside Spending', views2.join() === 'spending,savings' && !(await page.$('#add-savings')), views2.join());
+ await page.goBack(); await page.goBack(); await sleep(800);
+ s = await shape();
+ check('the wallet now has both faces', s.carousel && s.faces === 2, JSON.stringify({ faces: s.faces, carousel: s.carousel }));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await page.waitForSelector('.balance-face', { timeout: 20000 }); await sleep(1500);
+ s = await shape();
+ check('a reload keeps Savings', s.carousel && s.faces === 2);
+
+ // A new device: everything here wiped, the relays holding only what the
+ // passkey published. Sign in → Passkey opens THIS account, as itself.
+ const before = await page.evaluate(() => {
+ const st = JSON.parse(sessionStorage.getItem('btc-wallet-accounts') || '{}');
+ const a = (st.accounts || [])[0] || {};
+ return { mnemonic: a.mnemonic, nostrPk: a.nostrPk };
+ });
+ before.backups = backups;
+ check('the passkey published one encrypted seed copy', before.backups.length >= 1 && !before.backups[0].content.includes(before.mnemonic.split(' ')[0] + ' '), String(before.backups.length));
+ const backup = before.backups[before.backups.length - 1];
+ await page.evaluateOnNewDocument((ev) => {
+ const send = WebSocket.prototype.send;
+ WebSocket.prototype.send = function (d) {
+ try {
+ const m = JSON.parse(d);
+ if (m[0] === 'REQ' && m.slice(2).some((f) => (f.kinds || []).includes(30078) && (f.authors || []).includes(ev.pubkey))) {
+ const ws = this;
+ setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', m[1], ev]) })), 1);
+ }
+ } catch {}
+ return send.call(this, d);
+ };
+ }, backup);
+ await page.evaluate(async () => {
+ localStorage.clear(); sessionStorage.clear();
+ for (const d of (await indexedDB.databases?.() || [])) if (d.name) indexedDB.deleteDatabase(d.name);
+ localStorage.setItem('btc-wallet-network', 'mainnet');
+ });
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await page.waitForSelector('#onb-signin', { timeout: 15000 });
+ await page.click('#onb-signin'); await sleep(500);
+ await click('passkey');
+ for (let i = 0; i < 120 && !(await page.$('.balance-face')); i++) await sleep(250);
+ if (!(await page.$('.balance-face'))) console.log((await text()).slice(0, 800), errs);
+ const after = await page.evaluate(() => {
+ const st = JSON.parse(sessionStorage.getItem('btc-wallet-accounts') || '{}');
+ const a = (st.accounts || [])[0] || {};
+ const login = Object.keys(localStorage).concat([]).filter((k) => /:nostrlogin$/.test(k)).map((k) => localStorage.getItem(k));
+ return { mnemonic: a.mnemonic, nostrPk: a.nostrPk, kind: a.kind, login, onb: localStorage.getItem('btc-wallet-onb-step') };
+ });
+ check('the passkey opened the same account', after.mnemonic === before.mnemonic);
+ check('as itself: same identity, the passkey key not adopted', after.nostrPk === before.nostrPk && !after.login.some((v) => v && v.includes(backup.pubkey)), JSON.stringify({ was: before.nostrPk?.slice(0, 8), now: after.nostrPk?.slice(0, 8) }));
+ check('no wizard on the way back in', !after.onb && !(await page.$('.onb')));
+ check('no page errors', errs.length === 0, errs.join(' | '));
+} finally {
+ await browser.close(); server.stop(true);
+}
+console.log(ok ? 'PASS' : 'FAIL');
+process.exit(ok ? 0 : 1);