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
|
// A remote signer (Amethyst) that wasn't heard the moment the app needed it
// — the tab was frozen, a socket cut, the signer app asleep — put up
// "Reconnect your signer" and then never tried the stored session again, so
// every time meant a brand-new connection (Adam's phone, 2026-10-29: the
// stored session answered in 0.7 s the moment anyone asked). Now the screen
// keeps trying the stored session and closes itself once the signer answers.
// The remote signer is played by this script over a shimmed relay.
// Run: bun tools/signer-resume-test.js
import puppeteer from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import * as nip44 from 'nostr-tools/nip44';
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 SK = generateSecretKey(); const PK = getPublicKey(SK); // the account, held by the remote signer
const CLIENT_SK = generateSecretKey(); const CLIENT = getPublicKey(CLIENT_SK); // the client key it authorised long ago
const html = await buildHtml({ minify: true, pwa: false });
const server = Bun.serve({ port: 5319, fetch: (req) => { const p = new URL(req.url).pathname; if (p === '/verify-worker.js' || p === '/nip46.js' || p.startsWith('/punks')) return new Response(Bun.file('dist' + p)); return new Response(html, { headers: { 'content-type': 'text/html' } }); } });
const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
const page = await browser.newPage();
await page.setUserAgent('Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0 Mobile Safari/537.36');
await page.evaluateOnNewDocument((fx) => {
window.__amberUrls = [];
window.__coinosAmberLaunch = (u) => { window.__amberUrls.push(u); };
window.__published = [];
const subs = [];
const fits = (ev, f) => (!f.kinds || f.kinds.includes(ev.kind)) && (!f['#p'] || ev.tags.some((t) => t[0] === 'p' && f['#p'].includes(t[1])))
&& (!f.authors || f.authors.includes(ev.pubkey)) && (!f.since || ev.created_at >= f.since);
// the test pushes the signer's answers through here, to every live sub that wants them
window.__inject = (ev) => { for (const s of subs) if (s.fs.some((f) => fits(ev, f))) s.ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', s.id, ev]) })); };
const Real = window.WebSocket;
window.WebSocket = function (...a) {
const ws = new Real(...a);
const send = ws.send.bind(ws);
ws.send = (d) => {
try {
const m = JSON.parse(d);
if (m[0] === 'REQ') {
const fs = m.slice(2);
for (let i = subs.length - 1; i >= 0; i--) if (subs[i].ws === ws && subs[i].id === m[1]) subs.splice(i, 1);
subs.push({ ws, id: m[1], fs });
const evs = fx.filter((ev) => fs.some((f) => fits(ev, f)));
setTimeout(() => {
for (const ev of evs) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', m[1], ev]) }));
ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) }));
}, 0);
return;
}
if (m[0] === 'CLOSE') { for (let i = subs.length - 1; i >= 0; i--) if (subs[i].ws === ws && subs[i].id === m[1]) subs.splice(i, 1); return; }
if (m[0] === 'EVENT') {
window.__published.push(m[1]);
setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['OK', m[1].id, true, '']) })), 0);
return;
}
} catch {}
return send(d);
};
return ws;
};
window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real);
}, []);
const errs = [];
page.on('pageerror', (e) => errs.push(String(e)));
const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
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; };
// The remote signer: answers the connect, then get_public_key / nip44_decrypt.
let client = CLIENT, awake = false, handled = new Set(), decrypts = 0;
const signerLoop = async () => {
if (!awake) return;
const pub = await page.evaluate(() => window.__published.filter((e) => e.kind === 24133));
for (const ev of pub) {
if (handled.has(ev.id) || !client || ev.pubkey !== client) continue;
handled.add(ev.id);
const ck = nip44.getConversationKey(SK, ev.pubkey);
const req = JSON.parse(nip44.decrypt(ev.content, ck));
let result;
if (req.method === 'get_public_key') result = PK;
else if (req.method === 'nip44_decrypt') { decrypts++; result = nip44.decrypt(req.params[1], nip44.getConversationKey(SK, req.params[0])); }
else if (req.method === 'nip44_encrypt') result = nip44.encrypt(req.params[1], nip44.getConversationKey(SK, req.params[0]));
else if (req.method === 'sign_event') { const u = JSON.parse(req.params[0]); result = JSON.stringify(finalizeEvent(u, SK)); }
else if (req.method === 'ping') result = 'pong';
else if (req.method === 'connect') result = 'ack';
const resp = finalizeEvent({ kind: 24133, created_at: Math.floor(Date.now() / 1000), tags: [['p', ev.pubkey]], content: nip44.encrypt(JSON.stringify({ id: req.id, result }), ck) }, SK);
await page.evaluate((e) => window.__inject(e), resp);
}
};
const waitFor = async (fn, ms = 15000) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn)) return true; await sleep(200); } return false; };
const pump = async (ms) => { for (let i = 0; i < ms / 250; i++) { await signerLoop(); await sleep(250); } };
try {
await page.setViewport({ width: 390, height: 844 });
await page.goto('http://localhost:5319/', { waitUntil: 'domcontentloaded' });
await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400);
await click('create a new wallet'); await sleep(600);
await page.waitForSelector('.words .w .t');
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
await click('skip verification');
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
const local = Array.from(CLIENT_SK, (b) => b.toString(16).padStart(2, '0')).join('');
await page.evaluate(([k, pk, local]) => {
localStorage.setItem(k + ':nostrlogin', JSON.stringify({ pubkey: pk, linked: Date.now(), via: 'bunker',
session: { local, bp: { pubkey: pk, relays: ['wss://relay.coinos.io'], secret: null } } }));
}, [base, PK, local]);
console.log('\n[the signer is asleep when the app needs it]');
await page.reload({ waitUntil: 'domcontentloaded' });
await waitText('receive', 20000);
await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]')?.click());
await waitFor(() => !!document.querySelector('.feed-post-fab'));
await page.evaluate(() => document.querySelector('.feed-post-fab').click());
await waitFor(() => !!document.querySelector('.post-input'), 8000);
await page.focus('.post-input');
await page.keyboard.type('hello', { delay: 10 });
await page.evaluate(() => document.querySelector('.post-actions .btn-primary').click());
check('the reconnect screen comes up', await waitText('reconnect your signer', 45000));
await sleep(16000); // let the screen's first try run out unanswered
check('...and stays while the signer is silent', (await text()).toLowerCase().includes('reconnect your signer'));
console.log('\n[the signer wakes up]');
awake = true;
const t0 = Date.now();
let gone = false;
for (let i = 0; i < 160 && !gone; i++) { await signerLoop(); gone = !(await text()).toLowerCase().includes('reconnect your signer'); if (!gone) await sleep(250); }
check('the screen closes by itself, no new connection', gone, gone ? `${Math.round((Date.now() - t0) / 1000)} s after the signer answered` : 'still up after 40 s');
const st = await page.evaluate((k) => JSON.parse(localStorage.getItem(k + ':nostrlogin') || '{}'), base);
check('the stored session is the same one', st.session && st.session.local === local);
check('no nostrconnect link was needed', !(await page.evaluate((pk) => window.__published.some((e) => e.kind === 24133 && e.pubkey !== pk), CLIENT)));
check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
} finally { await browser.close(); server.stop(true); }
console.log(ok ? '\n✅ a stored remote-signer session comes back by itself' : '\n❌ failed');
process.exit(ok ? 0 : 1);
|