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 --- tools/balance-settings-row-test.js | 13 ++- tools/onboard-backup-test.js | 4 + tools/send-lnaddr-advance-test.js | 3 +- tools/signup-quick-test.js | 167 +++++++++++++++++++++++++++++++++++++ 4 files changed, 182 insertions(+), 5 deletions(-) create mode 100644 tools/signup-quick-test.js (limited to 'tools') 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); -- cgit v1.2.3