DESIGN.md raw

design notes

why this shape

The browser cannot open a Bitcoin P2P socket, and a home gateway usually has no inbound port. The two ends meet at a public relay. The relay is the control and data plane; the WebSocket "bridge" from docs/NEXT_PHASE.md can be added later as an alternative transport without changing anything above net.Conn.

The client is a P2P peer. The gateway is a byte-stream proxy. Keeping the Bitcoin handshake end to end means the gateway does not need to emulate a peer, and the client's verification does not depend on the gateway's honesty.

packages

pkg/btcwire is a pure codec: framing, compact sizes, the version and handshake messages, headers, the BIP157 messages, inventory and raw transactions. It has no network imports and builds for wasm.

pkg/p2p is the peer: version/verack, frame I/O, automatic pong, and a deadline-bounded read. It works over net.Conn, which is either a TCP socket or a tunnel stream.

pkg/filter is BIP158 basic filters: SipHash-2-4, Golomb-Rice coding, build and match, validated against the ten official bip-0158/testnet-19.json vectors in pkg/filter/testdata.

pkg/neutrino is the client loop:

  1. Seed genesis from the chain profile (hash checked).
  2. getheaders from a block locator (ten single steps, then doubling) until

the peer's advertised height or an empty response. Each header must link to the previous and satisfy its target.

  1. getcfheaders from the next height. The response's previous filter header

must match the stored chain; each hash is folded into a new filter header; the response's stop hash must be the last block's hash. Both the filter hash and the filter header are stored, because the filter is later checked against the hash the header commits to.

  1. getcfilters in ranges. Every filter's block hash and SHA256d(filter) are

verified before storing.

  1. Matching is a local scan of stored filters over the supplied scripts.

pkg/neutrino also defines Store. An Anchor is the trusted starting point: the block hash at a height and, for a checkpoint, the filter header at that height. A genesis anchor leaves the filter height at -1 because filter zero's hash is not known until it is downloaded. Headers and filter headers are contiguous from the anchor; filters may begin later, which is what a swap wallet wants.

Memory is for tests. FileStore is three append-only files (headers.bin, cfheaders.bin, filters.bin) plus anchor.bin, with a height in every record, truncation of a partial tail on open, and a Sync after each batch. A reorg calls Truncate, which rewrites the three files from the in-memory state; reorgs are rare enough that a rewrite is simpler and less error-prone than an append-only undo log.

pkg/idbstore is the browser store. It opens the database, loads the anchor, headers, filter headers and filters (optionally from a birth height) into a Memory, and writes every change behind the sync loop in batched transactions. Flush waits for the queue; the wasm command flushes before it reports a sync done and again when a session ends. A crash between batches loses at most one batch, and the next sync resumes from the last committed filter tip.

pkg/tunnel is the transport. session owns two sequence spaces, credit-based flow control, retransmission, the read queue, heartbeats, close and reset. The client also owns a relay subscription; the gateway listener routes decrypted frames from one shared subscription into per-session inboxes. The listener reconnects and resubscribes with backoff, and the WebSocket transport pings idle connections.

The receive side advertises free frames in every ACK. The send side blocks on credit, so a frozen worker stops the gateway's node-to-tunnel pump and the node's own TCP window takes over. reset aborts a stream; close ends it in order. A client switching gateway or abandoning a range uses reset.

pkg/bridge accepts either a tunnel stream or a WebSocket stream. Both end at the same handleConn: check the chain name, dial the node's P2P port, and copy frames in both directions after checking the command against the whitelist and re-encoding the frame. It never interprets a payload and never performs a handshake of its own. A token bucket limits each direction's byte rate, the tunnel bounds sessions per client, and a presence loop publishes the addressable gateway announcement with the node tip from a periodic handshake.

The WebSocket path is pkg/wsserver on the gateway and pkg/ws/pkg/jsws StreamConn on the client. It is a raw P2P byte stream, not the tunnel envelope: TLS replaces NIP-44 on that path, and the same bridge policy applies. The client's dialer tries bridge URLs in order and then the relay tunnel, so a blocked WebSocket degrades to the encrypted relay path.

cmd/nostrino-wasm exposes the client to a page:

nostrino.sync(uri, chain)   // starts a background sync
nostrino.status()           // JSON: running, status, error, tip, tipHash, added, filters, bytes, elapsed, matches
nostrino.match(scriptHex)   // JSON array of matching heights

The current wasm store is memory, retained for the life of the module. The page can sync incrementally while it stays loaded; a reload rescans. IndexedDB is the next layer and does not change the sync code: the Store interface is the seam.

The intended host is a dedicated Web Worker, not a service worker. A service worker is evicted aggressively and is not an always-on daemon. A dedicated worker lives with the document, may be paused, and is pull-based, so a pause only means the next request is later. The web demo in web/ shows the shape: page -> worker -> wasm -> nostrino.sync, with cancel mapped to the reset path.

Settlement deadlines are explicitly outside this layer. The tunnel reports heights and lets the wallet recompute a deadline from height and wall clock on resume; it never owns a timer whose non-firing could lose money.

crypto

Everything is in-tree, because the target is TinyGo and the repository has no external modules:

signing use the btcec code; the math/big implementation covers arbitrary message lengths and is the reference in tests.

derived from torly.

wasm constraints

The wasm target has no net and no os file system. The client therefore takes a Dial function, and the browser build supplies one backed by pkg/jsws, which wraps the page's WebSocket through syscall/js. The gateway build supplies TCP. The store is an interface for the same reason.

TinyGo's wasm target uses GOOS=js; pkg/jsws is behind //go:build js && wasm and native builds use pkg/ws, which adds TLS for wss://.

limits of the verification

Headers are checked for linkage, proof of work, a future timestamp bound, median-time-past and the expected difficulty. The retarget follows the 2016- block (configurable) Bitcoin rule and the testnet minimum-difficulty exception; regtest is fixed. A header chain that forks below the tip is resolved by walking back to the fork point (bounded), calling Store.Truncate and re-downloading the new branch. The first retarget boundary after a checkpoint is not verifiable because the window's first block is not stored; that one boundary is accepted from the peer and every later one is checked.

A mainnet first sync still needs a trusted checkpoint, because a sync from genesis downloads the whole header and filter-header history. Checkpoints are data: chain.Checkpoint and the profile JSON carry height, block hash and filter header, and -checkpoint-* flags override them. The repository does not ship a mainnet checkpoint value; an operator must obtain one from a node they trust.

The XBT profile is intentionally blank in the binary. Guessing a fork's magic, genesis or proof-of-work limit would silently point a client at the wrong chain. -profile supplies the full JSON, including the difficulty schedule and an optional checkpoint.

test strategy

official BIP158 and NIP-44 vectors and the BIP-340 suite.

exercises the NACK/retransmit path without a network.

neutrino and checks every filter, local matching, and file-store resume.

and the real wasm bundle in Node, which is the acceptance test for the browser target.

compatibility with NEXT_PHASE

23196/23197).

stream over TLS; the relay stream remains the encrypted control/fallback and is still the default when no bridge is advertised.

carries the P2P bytes directly. The bridge's whitelist is the equivalent policy boundary.

chain data and must not enter the BIP157 path. Presence/registry events and the wallet layer belong to the next phases and are not in this repository.