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