# the nostr chain-data tunnel This is the wire protocol between a browser light client and a gateway. It carries a reliable byte stream over NIP-01 events on a public relay, because a browser cannot open a Bitcoin P2P socket and the gateway has no inbound port. ## naming, not a shared secret A gateway is addressed by its public key: ``` nostr+chaindata://?relay=&relay=&chains=btc,xbt&v=1 ``` `nostr+chaindata` is the scheme. The host is the gateway's 64-character lowercase x-only public key. `relay` is repeated for each relay the gateway advertises; at least one is required. `chains` is advisory, `v` is the protocol version, and `name` is an optional label. This is the deliberate difference from NIP-46-style connection strings. The gateway is not a signer and holds no user key; it is a public chain-data service. A shared secret in the URI would add nothing: the client's request is encrypted to the gateway's public key and signed by an ephemeral client key, so any client can obtain public chain data, and an operator who wants to restrict access uses a client-pubkey allowlist. There is no secret to leak in a bookmark or a screenshot. The client key is ephemeral: a new keypair per connection. The gateway's key is persistent, stored in `gateway.key` next to its configuration. ## events Both directions use ephemeral kinds, `20000..29999`. Torly (and NIP-01 guidance) does not store those, which is what a live stream wants: the relay is a pipe, not a mailbox. A dropped stream is a new session, the same way a TCP reset is a new connection. | Direction | Kind | Purpose | |---|---|---| | client -> gateway | `23196` | control and data frames | | gateway -> client | `23197` | control and data frames | A frame event has these tags: ```json ["p", ""], ["s", ""], ["enc", "nip44-v2"], ["v", "1"] ``` and its `content` is a NIP-44 v2 payload. The event author is the sender; the `p` tag is the recipient. The client subscribes to: ```json {"kinds":[23197],"#p":[""],"#s":[""]} ``` The gateway subscribes to: ```json {"kinds":[23196],"#p":[""]} ``` and dispatches by the session id inside the decrypted envelope. It answers on the same relay the request arrived on. The client subscribes and waits for `EOSE` on every relay in the URI before publishing `HELLO`, so no welcome frame can be published before a subscription is live. Version 1 publishes every frame on every connected relay and the gateway answers on every relay it has heard from that session. Duplicates are harmless: sequence numbers, cumulative acknowledgements and HELLO_ACK are all idempotent. One relay can fail without ending the stream. Relays that require NIP-42 receive an `AUTH` event signed by the connecting identity (the client's ephemeral key, or the gateway's persistent key) with the relay URL and challenge. Publications wait until the relay accepts it. ## envelope Inside the NIP-44 payload is compact JSON: ```json {"v":1,"s":"","t":"","q":0,"a":0,"d":"","x":[],"m":0,"w":0,"pv":0,"c":"","n":0,"r":""} ``` | Field | Meaning | |---|---| | `v` | envelope version, currently 1 | | `s` | session id, 16 hex chars | | `t` | `hello`, `hello_ack`, `data`, `ack`, `nack`, `ping`, `pong`, `close` | | `q` | data sequence number, starting at 1 | | `a` | cumulative acknowledgement: highest contiguous data sequence received | | `d` | data bytes (`encoding/json` base64) | | `x` | NACK list of missing sequence numbers | | `m` | HELLO: sender MTU; HELLO_ACK: negotiated MTU | | `w` | HELLO: sender receive window in frames; ACK: receiver's free-frame credit | | `pv` | HELLO/HELLO_ACK protocol version | | `c` | chain name, for example `regtest` | | `n` | ping/pong nonce | | `r` | close reason | The envelope version is not the event kind and not the URI version; it is checked independently. An unknown type or version closes the stream. ## handshake 1. The client opens a WebSocket to a relay in the URI, creates an ephemeral key and a random 8-byte session id. 2. It subscribes to kind `23197` for its public key and the session id, and waits for `EOSE`. 3. It publishes `HELLO` with `mtu`, `w`, `pv` and `c`, and waits for the relay's `OK` so a rejected event is not mistaken for a slow gateway. 4. The gateway decrypts, checks the chain against its served set, the protocol version and the allowlist, creates a session and publishes `HELLO_ACK` with the negotiated `mtu` and window, then dials the node's P2P port. 5. The client takes `min(local, peer)` MTU and window, and the stream is open. The window is a credit: each ACK carries the receiver's free-frame count and the sender may only send new data while it has credit. A `HELLO` for an existing session is answered again; it does not create a second stream. A rejected `HELLO` is answered with `close` carrying a reason. ## stream Each direction has its own sequence space. - Every data frame has a sequence number, starting at 1. - The receiver accepts the next expected sequence, buffers frames that arrive early (bounded by twice the window), and detects a gap. - A duplicate or an in-order frame triggers a cumulative `ack`. The sender removes every unacknowledged frame at or below that number. - A gap older than 300 ms triggers a `nack` with up to 32 missing sequence numbers. The sender retransmits those frames immediately. - A sender retransmits any unacknowledged frame older than 1.5 s, up to 8 times, then closes the stream. - The send window is one credit unit per frame, 32 by default. Every ACK carries `w`, the number of frames the receiver's application queue can still accept. The sender may not send new data without credit; retransmissions do not consume credit. A slow or frozen worker drains the credit to zero, the gateway stops reading from the node, and the node's TCP window provides the backpressure. Nothing in the tunnel grows without bound. - `ping` is sent when nothing has been sent for 15 s. Any received frame resets the idle timer. 90 s of silence closes the stream. - `close` is an orderly close. `reset` is an abort: it is the cancellation primitive. A client that abandons a range or switches gateway sends `reset`, and the gateway closes the node socket immediately instead of waiting for the stream to drain. A local `Conn.Close` sends `close`; `Conn.Reset` sends `reset`. A peer that receives either releases its resources without a reply. These rules make the relay's delivery assumptions (ordered, at-most-once) into an ordered, exactly-once byte stream. They are deliberately small: this is a single stream between two parties, not a general transport. ## registry A gateway announces itself with an addressable event: - kind `30333`, `d` tag = the gateway public key, `t` tag per chain; - content is JSON: `{"version","uri","chains":[...],"relays":[...],"tip":n,"updated_at":n}`. The announcement is renewed every five minutes by default (`-presence`, `-presence-interval`). A client queries kind `30333` and filters the payload by chain; `nostrino-discover` prints the URIs. This is discovery only: the tunnel and the URI do not depend on it. ## identifiers The 8-byte session id, carried in the `s` tag and in every envelope, is the stream's request id. It is fresh for every connection and is the only handle for the session. The per-direction sequence number identifies a frame within the stream. There is no second, independent request id in version 1; a future request/response layer above the stream would carry its own id inside the payload, not reusing the session id. ## WebSocket data plane The relay stream is one data plane. The connection URI may also carry an optional direct endpoint: ``` nostr+chaindata://?relay=wss://relay.example&bridge=wss://gw.example/nostrino ``` The client tries each `bridge` URL first and falls back to the relay stream. The bridge URL is a WebSocket endpoint, not a NIP-01 relay: - the client opens a WebSocket to `bridge` with `?chain=` (and `token=` when the operator configured one); - every binary message from the client carries P2P stream bytes; the gateway parses complete frames, applies the same command whitelist, byte-rate limit and idle bound, and writes them to the node's P2P socket; - node-to-client bytes are written as binary messages. A WebSocket message boundary has no meaning to the P2P stack: the client's adapter treats messages as a byte stream. `wss://` is required for anything other than localhost; the reference build does not terminate TLS itself, so a public deployment puts a reverse proxy in front of the bridge listener. This path is TLS-protected rather than NIP-44-protected, and the gateway sees the same P2P bytes it sees over the relay tunnel; it still holds no keys, signs nothing and enforces the whitelist. The relay stream remains the default and the fallback. It is the encrypted, multi-relay, control-capable path; the WebSocket is the low-latency bulk path. When `-bridge-token` is configured the token is part of the advertised URI, so that URI is a credential and should be treated as one. ## sizes The default plaintext frame is 32 KiB. NIP-44 pads it to a 32-byte boundary and adds 65 bytes of metadata, then base64 expands it: a 32 KiB frame becomes roughly 55 KiB of event content, which stays under a 60 KB event limit once the JSON envelope and tags are added. The MTU is negotiated downward, and the tunnel refuses anything above 96 KiB. Torly's default `MAX_EVENT_BYTES` is 128 KiB; the tunnel's 32 KiB MTU leaves room for the event overhead. A relay with a smaller limit requires a smaller `-mtu` at the bridge (and the same on the client); the HELLO negotiation can only lower the client's value, so the client must not ask for more than the relay accepts. ## encryption NIP-44 v2 exactly: - ECDH on secp256k1, raw x coordinate, then HKDF-Extract with salt `nip44-v2` gives the conversation key. - A random 32-byte nonce per event; HKDF-Expand gives a ChaCha20 key, nonce and HMAC key. - Plaintext is length-prefixed and padded; ChaCha20 encrypts it; HMAC-SHA256 covers `nonce || ciphertext`; the payload is base64 of `0x02 || nonce || ciphertext || mac`. The implementation is in `pkg/nip44` with the official vectors in `pkg/nip44/testdata`. The conversation key is derived from the sender's ephemeral key and the recipient's public key, so the relay cannot read frames and the gateway cannot read anything not addressed to it. ## relay lifetime A public relay closes idle WebSocket connections; torly uses 150 s. The native transport sends a WebSocket ping every 45 s, and the gateway's listener reconnects with exponential backoff up to 30 s if a relay connection or its subscription is lost. A browser `WebSocket` cannot send protocol pings, so a page keeps its relay connection alive with the tunnel's own `ping` frames while a sync is active, and reconnects for the next sync. ## stream lifetime in a browser The client is intended to run in a dedicated Web Worker, created by the page. A worker is not a daemon: it lives while the document lives, and the browser may pause it at any time. That is acceptable for this protocol because it is pull-based. The client asks for a header or a filter range; nothing is pushed except ignoreable `inv`/`headers` announcements, which the client can re-request. Three consequences are part of the design: - **session cleanup.** Closing the tab closes the WebSocket. The bridge sees the stream end and closes the node connection. An idle bound catches a browser that vanishes without a close frame. The gateway never holds a half-open peer indefinitely. - **idempotent ranges.** A request names a start height and a stop hash. The client stores a filter only after verifying its hash, and resumes from the stored filter tip. A half-delivered range is discarded and requested again; it is never partially trusted. - **deadlines are not timers.** A swap settlement deadline belongs to the wallet layer, outside this tunnel. On resume it must be recomputed from block height and wall clock, not from a timer that may never have fired while the worker was frozen. The chain-data tunnel only reports heights; it does not schedule settlement. The swap record is server-side state outside the tunnel. It is not chain data, it is not part of the BIP157 path, and nothing in this repository stores it. ## bridge policy The gateway forwards P2P frames after parsing and re-encoding them. The whitelist is: ``` version verack ping pong sendheaders wtxidrelay sendaddrv2 feefilter getheaders headers getcfheaders cfheaders getcfilters cfilter getcfcheckpt cfcheckpt getdata tx inv notfound ``` Everything else closes the session: `addr`, `addrv2`, `getaddr`, `mempool`, bloom-filter messages, `sendcmpct`, BIP152 block relay and `alert`. The node connection is one TCP connection per stream; the bridge does not terminate the Bitcoin handshake, so the client and node do version/verack end to end and the client's own peer identity is what the node sees. ## what the client verifies The bridge is a transport, not an authority. - Block headers: previous-hash linkage, proof of work against the encoded target, and genesis from the chain profile. - Filter headers: the returned previous filter header must equal the stored one, each filter header must be `SHA256d(filter_hash || previous)`, and the stop hash must be the hash of the last block in the response. - Filters: the block hash must be the header's, and `SHA256d(filter)` must equal the filter hash committed to by the verified filter header. - Matching is local. The gateway never sees the script set. A gateway can withhold data. It cannot make the client accept a different header chain or a filter that does not match its commitment.