1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
|
// Two things about the front door.
//
// One: making your own keys has to look like the way in. Four equally
// weighted buttons, where only the sign-in ones said what they did, sent
// people straight past the seed phrase — which IS the wallet — into a login
// that is only a convenience wrapped around one.
//
// Two: a wallet that arrived by SIGN-IN has a recovery phrase its owner has
// never seen, and nobody can reissue it. So it gets shown once, during
// setup, and if they put it off the wallet keeps one quiet line about it.
//
// Run: bun tools/onboard-backup-test.js
import puppeteer from './puppeteer.js';
import { bech32 } from '@scure/base';
import { generateSecretKey } from 'nostr-tools/pure';
import { seedFromNostrKey } from '../src/nostr-login.js';
import { buildHtml } from '../build.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; };
const html = await buildHtml({ minify: true, pwa: false });
const server = Bun.serve({ port: 5291, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) });
const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
const click = (page, t, sel = 'button') => page.evaluate((s, x) => {
const e = [...document.querySelectorAll(s)].find((n) => n.textContent.trim().toLowerCase().includes(x));
if (e) { e.click(); return true; } return false;
}, sel, t.toLowerCase());
const text = (page) => page.evaluate(() => document.body.innerText);
const waitText = async (page, x, ms = 30000) => {
for (let i = 0; i < ms / 250; i++) { if ((await text(page)).toLowerCase().includes(x.toLowerCase())) return true; await sleep(250); }
return false;
};
// Sign in with a pasted nsec and stop at whatever the wizard shows next.
// Its own browser context, so each run meets a genuinely fresh front door
// rather than the wallet the previous one left in localStorage.
async function signIn() {
const ctx = await browser.createBrowserContext();
const page = await ctx.newPage();
await page.setViewport({ width: 390, height: 844 });
await page.goto('http://localhost:5291/', { waitUntil: 'domcontentloaded' });
await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
await page.reload({ waitUntil: 'domcontentloaded' });
// wait for the front door to actually be painted before poking it; the
// sign-in ways are one step in
await waitText(page, 'sign in to an existing account');
await page.evaluate(() => document.getElementById('onb-signin')?.click());
await waitText(page, 'migrate a coinos account');
const sk = generateSecretKey();
const nsec = bech32.encode('nsec', bech32.toWords(sk), 1000);
await click(page, 'nostr');
await waitText(page, 'log in with nostr');
await page.waitForSelector('input[placeholder*="nsec" i], textarea[placeholder*="nsec" i]', { timeout: 15000 });
await page.type('input[placeholder*="nsec" i], textarea[placeholder*="nsec" i]', nsec);
await click(page, 'log in');
return { page, ctx, seed: seedFromNostrKey(sk) };
}
try {
console.log('[the start page]');
{
const page = await browser.newPage();
await page.setViewport({ width: 390, height: 844 });
await page.goto('http://localhost:5291/', { 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.
const front = await page.evaluate(() => {
const btns = [...document.querySelectorAll('button')].filter((b) => b.offsetParent);
const primary = document.getElementById('onb-create');
const signin = document.getElementById('onb-signin');
return {
label: primary ? primary.textContent.trim() : null,
labels: btns.map((b) => b.textContent.trim()),
order: !!primary && !!signin && primary.getBoundingClientRect().top < signin.getBoundingClientRect().top,
footer: (() => { const f = document.querySelector('.footer'); return f ? { buttons: f.querySelectorAll('button, a').length, selects: f.querySelectorAll('select').length } : null; })(),
};
});
check('the way in says what it makes', /wallet/i.test(front.label || ''), front.label || 'missing');
check('...and the only other choice is signing in to one you have', front.order && front.labels.length === 2,
front.labels.join(' | '));
check('...with only the language picker under them', !!front.footer && front.footer.buttons === 0 && front.footer.selects === 1, JSON.stringify(front.footer));
// The welcome card is just the title and the buttons now (42c8d4e): the
// way in itself says what the wallet is — tapping it puts the twelve
// words on screen at once, with what they are.
await page.evaluate(() => document.getElementById('onb-create')?.click());
await sleep(600);
// a heads-up first, no warning icon (2026-11-07): sensitive words next
const intro = await page.evaluate(() => ({ words: document.querySelectorAll('.words .w').length, text: document.body.innerText, warn: !!document.querySelector('.warn-box') }));
check('the words wait behind a heads-up to write them down', intro.words === 0 && /write them down/i.test(intro.text) && !intro.warn && !/⚠/.test(intro.text));
await page.evaluate(() => document.getElementById('seed-intro-next')?.click());
await sleep(400);
const made = await page.evaluate(() => document.querySelectorAll('.words .w .t').length);
check('the way in shows what the wallet actually is: its twelve words', made === 12, made + ' words');
await page.close();
}
// Three: a wallet made elsewhere comes in by its twelve words, and that
// door has to be on the front page too — it used to be a line under the
// freshly minted words behind "Create a new wallet", found by chance.
console.log('\n[restoring from twelve words]');
{
const ctx = await browser.createBrowserContext();
const page = await ctx.newPage();
await page.setViewport({ width: 390, height: 844 });
await page.goto('http://localhost:5291/', { waitUntil: 'domcontentloaded' });
await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
await page.reload({ waitUntil: 'domcontentloaded' });
await waitText(page, 'sign in to an existing account');
await page.evaluate(() => document.getElementById('onb-signin')?.click());
await waitText(page, 'migrate a coinos account');
const door = await page.evaluate(() => {
const btns = [...document.querySelectorAll('button')].filter((b) => b.offsetParent && b.textContent.trim());
const b = document.getElementById('onb-restore');
return b ? { label: b.textContent.trim(), first: btns[0] === b, visible: !!b.offsetParent } : null;
});
check('signing in offers the seed phrase', !!door && door.visible && /seed phrase/i.test(door.label), door ? door.label : 'missing');
check('...first, above the other ways', !!door && door.first);
await page.evaluate(() => document.getElementById('onb-restore')?.click());
await sleep(600);
const pane = await page.evaluate(() => ({
title: /enter seed phrase/i.test(document.body.innerText),
field: !!document.querySelector('textarea[placeholder*="seed words" i]'),
minted: document.querySelectorAll('.words .w .t').length,
}));
check('it opens straight onto the phrase field, no minted words', pane.title && pane.field && pane.minted === 0,
`${pane.title ? 'titled' : 'untitled'}, ${pane.field ? 'field' : 'no field'}, ${pane.minted} minted words`);
const phrase = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
await page.type('textarea[placeholder*="seed words" i]', phrase);
await click(page, 'open wallet');
check('the typed phrase opens the wallet', await waitText(page, 'receive'));
await ctx.close();
}
console.log('\n[signing in, then writing it down]');
{
const { page, ctx, seed } = await signIn();
check('a sign-in is asked to write the phrase down', await waitText(page, 'write down your recovery phrase'));
check('...with the words behind Show seed phrase', await page.evaluate(() => !document.querySelector('.words')));
await click(page, 'show seed phrase');
await sleep(300);
const shown = await page.evaluate(() => [...document.querySelectorAll('.words .w .t')].map((e) => e.textContent.trim()));
check('...and the real twelve words are on screen', shown.length === 12 && shown.join(' ') === seed,
shown.length + ' words' + (shown.join(' ') === seed ? ', matching the derived seed' : ', WRONG'));
await click(page, "i've written it down");
check('confirming lands in the wallet', await waitText(page, 'receive'));
const nag = await text(page);
check('...and there is no reminder afterwards', !/still unwritten/i.test(nag));
await page.reload({ waitUntil: 'domcontentloaded' });
check('...not after a reload either', await waitText(page, 'receive') && !/still unwritten/i.test(await text(page)));
await ctx.close();
}
console.log('\n[signing in, then putting it off]');
{
const { page, ctx } = await signIn();
check('the ask appears', await waitText(page, 'write down your recovery phrase'));
await click(page, 'later');
check('later lands in the wallet too', await waitText(page, 'receive'));
check('...but the wallet keeps a quiet reminder', /still unwritten/i.test(await text(page)));
await page.reload({ waitUntil: 'domcontentloaded' });
await waitText(page, 'receive');
check('...which survives a reload', /still unwritten/i.test(await text(page)));
// and it is a way IN, not just a scold
await click(page, 'write it down');
check('tapping it opens the recovery phrase', await waitText(page, 'recovery phrase'));
await ctx.close();
}
} finally { await browser.close(); server.stop(true); }
console.log(ok ? '\n✅ the front door points at your own keys' : '\n❌ failed');
process.exit(ok ? 0 : 1);
|