tinywallet

git clone https://git.smesh.lol/tinywallet.git

tinywallet

The wallet half of the browser client: a BIP39 seed, BIP84 P2WPKH addresses, and a write-behind store, with no wallet service and no full node RPC anywhere in it.

The chain data comes from nostrino, the compact-filter light client that reaches a chain node through a Nostr relay tunnel. The wallet never speaks to the node: it holds one end of a chain-data channel per chain and asks for a status, a sync, a script match, or a range of filters. What answers is someday a full node, today the light client, and neither side of the channel knows which.

            page
              |  commands + events
        wallet worker  -- this module
         |          |
   store (IDB)   chain-data channel  -- nostrino/pkg/chaindata
                     |
              nostrino wasm instance   (one per chain)
                     |
               relay -> gateway -> node

Layout

packagewhat it is
pkg/mnemonicBIP39: word generation, checksum validation, seed derivation. English wordlist, pinned by hash.
pkg/bech32BIP173 v0, which is what a P2WPKH address is.
pkg/hdBIP32 private derivation, hardened and not, carried with its path.
pkg/walletA seed and a chain profile as a wallet: m/84'/coin'/account'/change/index, P2WPKH scripts.
pkg/storeThe synchronous state and its asynchronous persistence. idb.go is the browser side.
pkg/scriptOutput scripts and addresses: P2WPKH, P2WSH, and the swap's HTLC witness script, pinned to the node's own compilation.
pkg/domainThe wallet's operations: create, restore, unlock, addresses, sync. Chains are an interface.
pkg/apiThe frames a page and the wallet worker exchange: binary, id'd, cancellable.
cmd/tinywallet-wasmThe worker: the domain, the store, and one chain-data client per chain.

No package here opens a socket, reads a config file, or knows a relay. The worker that composes them does that, and it is the only thing that needs a browser.

Two decisions worth reading before the code

The seed is the recovery artifact, so the store can lose a write. IndexedDB is asynchronous and transactional; Bolt's View/Update are synchronous and are what the rest of the wallet code wants to look like. Rather than put a per-write round trip on another thread into the hot path — a wallet matching a block writes in bursts — the cache is the truth and the database is written behind it, in one batch, every couple of seconds, or at a boundary the caller chooses. A flush that never happened costs a rescan from the seed, not a wallet. Values are versioned so a write that lands while a flush is in flight is newer than what was written and stays dirty for the next one.

The channel is the seam. The vocabulary is binary frames with request ids, not JSON: a filter range is one buffer that the browser transport can transfer instead of copy, and a script list is bytes. A frame carries an id, so one request in flight can be cancelled precisely, and a close means the far end died — an in-flight request fails instead of waiting for an answer that cannot come. Because both sides speak only that vocabulary, the wallet does not compile the light client and the light client does not compile the wallet.

Status

Working and tested (go test ./...), with the wasm target compile-checked:

  • BIP39 generation, validation and seed derivation against the official trezor

vectors; 12, 15, 18, 21 and 24 words;

  • BIP32/BIP84 derivation against the BIP84 test vectors, and two chains from one seed

separated by account (btc account 0, blake account 1);

  • the store: coalescing, one batch per flush, failed writes retained, deletion,

reopen, versioned changes during a flush, and the wasm IndexedDB persistence;

  • the setup flow's substance: create (words shown once) or restore, a sealed seed that

a reload opens with the passphrase, the same first address after a reload, and two chains from one seed that share no key;

  • sync: resume from the stored filter tip, match both branches with a lookahead, record

the heights, and refuse a chain that fails rather than recording half of it.

The swap's signing, PSBT and scanning behaviour is surveyed in docs/BLAKE_SWAP_PORT.md: the swap has none of that locally today (it is all node RPC), so the doc lists what has to be built here instead.

  • the worker and its protocol: tinywallet.open(pagePort, chains) starts it, then the page

sends request frames and reads result frames — status, create, restore, unlock, lock, addresses, sync, matches — with a cancel that reaches the chain that is working, and a close that ends it. Built with TinyGo (2.2 MB).

Not in this cut: the page itself, address discovery beyond the lookahead, fetching the blocks a match names, signing, and the swap module that spends what the chain says is spendable.

files