PROTOCOL.md raw

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://<gateway-pubkey>?relay=<wss-relay>&relay=<wss-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.

DirectionKindPurpose
client -> gateway23196control and data frames
gateway -> client23197control and data frames

A frame event has these tags:

["p", "<peer pubkey>"],
["s", "<session id, 16 hex chars>"],
["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:

{"kinds":[23197],"#p":["<client pubkey>"],"#s":["<session>"]}

The gateway subscribes to:

{"kinds":[23196],"#p":["<gateway pubkey>"]}

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:

{"v":1,"s":"<session>","t":"<type>","q":0,"a":0,"d":"<base64>","x":[],"m":0,"w":0,"pv":0,"c":"","n":0,"r":""}
FieldMeaning
venvelope version, currently 1
ssession id, 16 hex chars
thello, hello_ack, data, ack, nack, ping, pong, close
qdata sequence number, starting at 1
acumulative acknowledgement: highest contiguous data sequence received
ddata bytes (encoding/json base64)
xNACK list of missing sequence numbers
mHELLO: sender MTU; HELLO_ACK: negotiated MTU
wHELLO: sender receive window in frames; ACK: receiver's free-frame credit
pvHELLO/HELLO_ACK protocol version
cchain name, for example regtest
nping/pong nonce
rclose 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.

  1. It subscribes to kind 23197 for its public key and the session id, and

waits for EOSE.

  1. 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.

  1. 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.

  1. 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.

early (bounded by twice the window), and detects a gap.

removes every unacknowledged frame at or below that number.

numbers. The sender retransmits those frames immediately.

times, then closes the stream.

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.

the idle timer. 90 s of silence closes the stream.

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:

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://<gateway-pubkey>?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:

when the operator configured one);

parses complete frames, applies the same command whitelist, byte-rate limit and idle bound, and writes them to the node's P2P socket;

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:

nip44-v2 gives the conversation key.

HMAC key.

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:

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.

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.

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.

target, and genesis from the chain profile.

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.

the filter hash committed to by the verified filter header.

A gateway can withhold data. It cannot make the client accept a different header chain or a filter that does not match its commitment.