index.html raw
1 <!doctype html>
2 <html lang="en">
3 <head>
4 <meta charset="utf-8">
5 <title>tinywallet — two chains, one seed</title>
6 <style>
7 body { font: 14px/1.5 system-ui, sans-serif; margin: 0; padding: 24px; background: #14161a; color: #e6e6e6; }
8 h1 { font-size: 18px; margin: 0 0 4px; }
9 p.note { color: #9aa0a6; margin: 0 0 20px; }
10 .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(360px, 1fr)); gap: 16px; }
11 .card { border: 1px solid #2b2f36; border-radius: 10px; padding: 16px; background: #1a1d22; }
12 .card h2 { font-size: 15px; margin: 0 0 8px; display: flex; justify-content: space-between; }
13 .status { color: #9aa0a6; font-size: 12px; white-space: pre-wrap; margin: 8px 0; }
14 label { display: block; margin: 8px 0 2px; color: #9aa0a6; font-size: 12px; }
15 input, select, textarea { width: 100%; box-sizing: border-box; background: #0f1115; color: #e6e6e6;
16 border: 1px solid #2b2f36; border-radius: 6px; padding: 6px; font: inherit; }
17 textarea { height: 64px; resize: vertical; }
18 button { margin: 8px 6px 0 0; background: #2f6feb; border: 0; color: #fff; border-radius: 6px; padding: 6px 10px; font: inherit; cursor: pointer; }
19 button.secondary { background: #303540; }
20 button:disabled { opacity: .45; cursor: default; }
21 .words { font-family: ui-monospace, monospace; background: #0f1115; border: 1px solid #6b4; border-radius: 6px;
22 padding: 10px; margin-top: 8px; word-spacing: 8px; line-height: 2; }
23 .address { font-family: ui-monospace, monospace; color: #8fd18f; word-break: break-all; }
24 .error { color: #ff8a80; }
25 </style>
26 </head>
27 <body>
28 <h1>tinywallet</h1>
29 <p class="note">Two chains, one seed. The keys never leave this page; the chains are read by
30 nostrino over the relay, and the wallet talks to it in frames.</p>
31
32 <p><button id="connect">Connect</button> <span id="connect-status" class="status"></span></p>
33
34 <div class="grid" id="cards"></div>
35
36 <script type="module">
37 import { Wallet, Writer, Reader, METHOD } from './wallet.js';
38
39 const CHAINS = [
40 { name: 'btc', network: 'regtest', label: 'shatcoin (BTC)' },
41 { name: 'blake', network: 'regtest', label: 'blakecoin (XBT)' },
42 ];
43
44 // Caller wraps a worker whose Go module parks in select{}. Every call is one request.
45 class Caller {
46 constructor(worker) {
47 this.worker = worker;
48 this.seq = 1;
49 this.pending = new Map();
50 this.ready = new Promise((resolve, reject) => (this.settle = { resolve, reject }));
51 worker.onmessage = (event) => this.receive(event.data);
52 }
53
54 receive(message) {
55 if (message.kind === 'ready') return this.settle.resolve();
56 if (message.kind === 'failed') return this.settle.reject(new Error(message.error));
57 if (message.kind === 'log') {
58 // A worker's console is not the page's; the loader forwards it so a failed connection
59 // says why instead of leaving the page on "opening the workers…".
60 (window.__workerLogs = window.__workerLogs || []).push(message.level + ': ' + message.text);
61 console.warn('[worker ' + message.level + '] ' + message.text);
62 return;
63 }
64 if (message.kind !== 'call') return;
65 const waiter = this.pending.get(message.id);
66 if (!waiter) return;
67 this.pending.delete(message.id);
68 if (message.error) waiter.reject(new Error(message.error));
69 else waiter.resolve(message.result);
70 }
71
72 async call(fn, ...args) {
73 await this.ready;
74 const id = this.seq++;
75 // A MessagePort can only cross into a worker if it is transferred, and postMessage
76 // without a transfer list refuses it outright ("a MessagePort could not be cloned
77 // because it was not transferred"). The wallet is handed its chain channels inside an
78 // array of descriptors, so the search for ports is recursive rather than one level deep.
79 const transfer = [];
80 const collect = (value) => {
81 if (value instanceof MessagePort) transfer.push(value);
82 else if (Array.isArray(value)) value.forEach(collect);
83 else if (value && typeof value === 'object') Object.values(value).forEach(collect);
84 };
85 args.forEach(collect);
86 return new Promise((resolve, reject) => {
87 this.pending.set(id, { resolve, reject });
88 this.worker.postMessage({ kind: 'call', id, fn, args }, transfer);
89 });
90 }
91 }
92
93 let wallet = null;
94
95 function spawn(wasm, global) {
96 const worker = new Worker('worker.js?wasm=' + encodeURIComponent(wasm) + '&global=' + global);
97 return new Caller(worker);
98 }
99
100 function el(tag, attrs = {}, children = []) {
101 const node = document.createElement(tag);
102 for (const [key, value] of Object.entries(attrs)) node.setAttribute(key, value);
103 for (const child of children) node.append(child);
104 return node;
105 }
106
107 const cards = new Map();
108
109 for (const chain of CHAINS) {
110 const card = el('div', { class: 'card' });
111 const title = el('h2', {}, [chain.label]);
112 const status = el('div', { class: 'status' }, ['not connected']);
113 const uri = el('input', { placeholder: 'nostr+chaindata://<gateway>?relay=wss://…' });
114 uri.value = localStorage.getItem('uri:' + chain.name) || '';
115 const profile = el('input', { placeholder: 'profile JSON (only for a chain with no built-in profile)' });
116 profile.value = localStorage.getItem('profile:' + chain.name) || '';
117 const words = el('select');
118 for (const count of [12, 24]) words.append(el('option', { value: String(count) }, [count + ' words']));
119 const passphrase = el('input', { type: 'password', placeholder: 'passphrase that seals the seed' });
120 const phrase = el('textarea', { placeholder: 'the words, to restore' });
121 const output = el('div', { class: 'status' });
122 const actions = el('div');
123
124 const create = el('button', {}, ['Create']);
125 const restore = el('button', { class: 'secondary' }, ['Restore']);
126 const unlock = el('button', { class: 'secondary' }, ['Unlock']);
127 const next = el('button', { class: 'secondary' }, ['Next receive address']);
128 const sync = el('button', { class: 'secondary' }, ['Sync']);
129 const coins = el('button', { class: 'secondary' }, ['Coins']);
130 const lock = el('button', { class: 'secondary' }, ['Lock']);
131 actions.append(create, restore, unlock, next, sync, coins, lock);
132
133 const show = (text, className) => {
134 output.className = 'status' + (className ? ' ' + className : '');
135 output.textContent = text;
136 };
137
138 const refresh = async () => {
139 if (!wallet) return;
140 try {
141 const state = await wallet.status(chain.name);
142 status.textContent =
143 'profile ' + state.profile + ' · account ' + state.account + ' · ' + state.hrp + '\n' +
144 (state.hasWallet ? (state.unlocked ? 'unlocked' : 'locked') : 'no wallet yet') +
145 ' · tip ' + state.tip + ' · filters ' + state.filters +
146 ' · matches ' + state.matches +
147 '\naddresses: ' + state.receive + ' receive, ' + state.change + ' change';
148 } catch (error) {
149 status.textContent = 'error: ' + error.message;
150 }
151 };
152
153 const need = () => {
154 if (!wallet) throw new Error('connect first');
155 return wallet;
156 };
157
158 create.onclick = async () => {
159 try {
160 const result = await need().create(chain.name, Number(words.value), passphrase.value);
161 passphrase.value = '';
162 show('write these words down. they are the wallet, and they are shown once.', 'error');
163
164 const shown = el('div', { class: 'words' }, [result.mnemonic]);
165 const confirm = el('textarea', { placeholder: 'type the words back to confirm you have them' });
166 const check = el('button', {}, ['Confirm']);
167 const panel = el('div', {}, [shown, el('label', {}, ['confirm']), confirm, check]);
168 output.after(panel);
169
170 check.onclick = () => {
171 if (confirm.value.trim().split(/\s+/).join(' ') !== result.mnemonic) {
172 show('that is not what was shown. the words are still above; copy them exactly.', 'error');
173 return;
174 }
175 panel.remove();
176 show('confirmed. first receive address (m/84\'/' + (chain.name === 'btc' ? 0 : 1) + '/0\'/0/0):');
177 output.append(el('div', { class: 'address' }, [result.address]));
178 refresh();
179 };
180 } catch (error) {
181 show(error.message, 'error');
182 }
183 };
184
185 restore.onclick = async () => {
186 try {
187 const result = await need().restore(chain.name, phrase.value, passphrase.value);
188 passphrase.value = '';
189 phrase.value = '';
190 show('restored. first receive address:');
191 output.append(el('div', { class: 'address' }, [result.address]));
192 refresh();
193 } catch (error) {
194 show(error.message, 'error');
195 }
196 };
197
198 unlock.onclick = async () => {
199 try {
200 await need().unlock(chain.name, passphrase.value);
201 passphrase.value = '';
202 show('unlocked');
203 refresh();
204 } catch (error) {
205 show(error.message, 'error');
206 }
207 };
208
209 lock.onclick = async () => {
210 try {
211 await need().lock(chain.name);
212 show('locked');
213 refresh();
214 } catch (error) {
215 show(error.message, 'error');
216 }
217 };
218
219 next.onclick = async () => {
220 try {
221 const state = await need().status(chain.name);
222 const derived = await need().addresses(chain.name, false, state.receive, 1);
223 show('receive address ' + derived.indexes[0] + ':');
224 output.append(el('div', { class: 'address' }, [derived.addresses[0]]));
225 refresh();
226 } catch (error) {
227 show(error.message, 'error');
228 }
229 };
230
231 sync.onclick = async () => {
232 sync.disabled = true;
233 show('syncing…');
234 try {
235 const result = await need().sync(chain.name, 0);
236 show('from ' + result.from + ' to tip ' + result.tip + ' (filters ' + result.filters + ')\n' +
237 result.scripts + ' scripts matched · ' + result.found.length + ' new height(s)' +
238 (result.found.length ? ': ' + result.found.join(', ') : ''));
239 refresh();
240 } catch (error) {
241 show(error.message, 'error');
242 } finally {
243 sync.disabled = false;
244 }
245 };
246
247 coins.onclick = async () => {
248 try {
249 const held = await need().unspent();
250 show(held.length ? held.length + ' coin(s)' : 'no coins yet: a match only becomes a coin when a block is fetched and verified');
251 for (const coin of held) {
252 output.append(el('div', { class: 'address' }, [coin.value + ' sat · ' + coin.txid + ':' + coin.vout + ' · height ' + coin.height]));
253 }
254 } catch (error) {
255 show(error.message, 'error');
256 }
257 };
258
259 card.append(title, status, el('label', {}, ['gateway uri']), uri,
260 el('label', {}, ['chain name in the URI']), profile,
261 el('label', {}, ['network profile']), el('div', { class: 'status' }, [chain.network]),
262 el('label', {}, ['seed']), words, el('label', {}, ['passphrase']), passphrase,
263 el('label', {}, ['words to restore']), phrase, actions, output);
264 cards.set(chain.name, card);
265 document.getElementById('cards').append(card);
266 }
267
268 window.connect = async () => {
269 const chains = [];
270
271 for (const chain of CHAINS) {
272 const card = cards.get(chain.name);
273 const uri = card.querySelector('input').value.trim();
274 const profile = card.querySelectorAll('input')[1].value.trim();
275 if (!uri) throw new Error(chain.label + ': a gateway URI is needed');
276
277 localStorage.setItem('uri:' + chain.name, uri);
278 localStorage.setItem('profile:' + chain.name, profile);
279
280 const channel = new MessageChannel();
281 const caller = spawn('nostrino-channel.wasm', 'nostrinoChannel');
282 const problem = await caller.call('open', channel.port2, uri, chain.name, profile);
283
284 if (problem) throw new Error(chain.label + ': ' + problem);
285
286 chains.push({ name: chain.name, network: chain.network, port: channel.port1 });
287 }
288
289 const channel = new MessageChannel();
290 wallet = new Wallet(channel.port1);
291 window.wallet = wallet;
292
293 const caller = spawn('tinywallet.wasm', 'tinywallet');
294 const problem = await caller.call('open', channel.port2, chains);
295
296 if (problem) throw new Error('wallet: ' + problem);
297 };
298
299 const connectButton = document.getElementById('connect');
300 const connectStatus = document.getElementById('connect-status');
301
302 connectButton.onclick = async () => {
303 connectButton.disabled = true;
304 connectStatus.textContent = 'opening the workers…';
305 try {
306 await window.connect();
307 connectStatus.textContent = 'connected: the wallet drives ' + CHAINS.map((c) => c.name).join(' and ');
308 for (const chain of CHAINS) {
309 const card = cards.get(chain.name);
310 const state = await wallet.status(chain.name);
311 card.querySelector('.status').textContent =
312 'profile ' + state.profile + ' · account ' + state.account + ' · ' + state.hrp + '\n' +
313 (state.hasWallet ? (state.unlocked ? 'unlocked' : 'locked') : 'no wallet yet');
314 }
315 } catch (error) {
316 connectStatus.textContent = 'error: ' + error.message;
317 connectButton.disabled = false;
318 }
319 };
320
321 window.addEventListener('error', (event) => console.error('page:', event.message));
322 </script>
323 </body>
324 </html>
325