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.
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.
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:
["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.
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":""}
| 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.
and a random 8-byte session id.
23197 for its public key and the session id, and waits for EOSE.
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.
version and the allowlist, creates a session and publishes HELLO_ACK with
the negotiated mtu and window, then dials the node's P2P port.
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.
Each direction has its own sequence space.
early (bounded by twice the window), and detects a gap.
ack. The senderremoves every unacknowledged frame at or below that number.
nack with up to 32 missing sequencenumbers. 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.
ping is sent when nothing has been sent for 15 s. Any received frame resetsthe 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.
A gateway announces itself with an addressable event:
30333, d tag = the gateway public key, t tag per chain;{"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.
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.
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:
bridge with ?chain=<name> (and token=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.
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.
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.
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.
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.
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.
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.
SHA256d(filter) must equalthe 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.