Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Start Here

Rings is a peer-to-peer network for the sovereign age. Browser tabs and native daemons join one overlay, find each other by DID, and exchange messages over direct WebRTC datachannels routed by a Chord DHT. Direct connections do not need an application server in the data path. Bootstrap and signaling establish the first links; if ICE selects a configured TURN relay, that relay carries the transport traffic.

The overlay is the communication layer: it routes by DID and, once two peers have completed the E2E handshake, encrypts to a DID. It does not hide who is talking to whom. Privacy is the job of the privacy layer, the onion loops that bound what each relay can learn to its predecessor and its successor; the contract of each layer is drawn in the security model.

On top of the overlay, Rings gives applications a namespace-scoped protocol runtime. A protocol is a pure state machine; its interpreter shell performs the side effects, and only within its own namespace. Built-in protocols cover peer service relay and echo; yours are registered under a namespace of their own.

Rings is written in Rust and compiles to native binaries and to WebAssembly, so the same node runs in a terminal, in a browser tab, and inside a host that speaks C. This book is the reference for all three; the Guide on the site is its short form.

Where Rings fits

✅ supported · ❌ not provided. Qualifications are listed below the table.

NetworkBrowser P2PStructured P2PPrivacy layerE2E encryption
Rings✅✅ Chord✅ Separate layer✅¹
libp2p✅✅ Kademlia²❌✅³
aMule / Kad❌✅ Kademlia❌❌⁴
Nostr❌❌❌✅⁵
Nym mixnet❌❌✅ Full mixnet path✅⁶
Tor❌❌ Relay network✅ Onion circuits✅⁷
I2P❌✅ netDb²✅ Tunnel network✅⁶
WebTorrent (browser)✅❌❌✅³

Browser P2P means the browser itself connects as a peer; a browser UI or gateway client does not qualify. Structured P2P includes DHT-based discovery; it does not imply that application traffic is routed through the DHT. Privacy means network metadata protection, separate from payload encryption.

  1. Rings E2E streams require the E2E handshake; plain overlay messages are not E2E-encrypted.
  2. libp2p offers an optional Kademlia DHT. I2P uses a Kademlia-based netDb for discovery; messages travel through tunnels.
  3. Encrypted peer connections: libp2p includes circuit-relayed connections; browser WebTorrent uses WebRTC/DTLS. This does not make published content private or add E2E to pubsub forwarding.
  4. aMule protocol obfuscation is not a secure E2E guarantee. This row covers Kad; eD2k uses indexing servers.
  5. Nostr supports encrypted messages, such as NIP-44 payloads; public events are not encrypted.
  6. Between Nym clients or I2P destinations. Traffic beyond an exit/outproxy needs application encryption.
  7. Tor provides E2E for onion services; ordinary websites need HTTPS beyond the exit. A privacy layer is not an unconditional anonymity guarantee.

Primary sources

Sources for the comparison

Sources checked September 22, 2026.

Choose a runtime

RuntimeStart with
Native nodeInstall, then Host a Native Node and Operate a Node from the CLI
Browser (Wasm) nodeBuild for Wasm and Browser Node and Extension
Another languageEmbed via C FFI
An AI coding agentllms.txt

If you are new to Rings, start with a native node: it is the shortest path to a running peer and to the JSON-RPC API every runtime shares.

How it works

Beyond the book

  • Rings whitepaper: the protocol paper.
  • Security model: the assumptions, deployment models, the boundary between DID authentication and Sybil resistance, and the contracts of the communication layer and the privacy layer. Read it before deploying.
  • Roadmap: where the network layer and the privacy layer are heading.
  • Source on GitHub: the code, examples, and issues.
  • License: AGPL-3.0-only, so a product or hosted service built on Rings publishes its source; a commercial license for use outside those terms is available on request.

Install a Native Node


description: Start from using rings-node

Install a native node

Installation

You can install the rings binary from a prebuilt release, from Cargo, or from source.

Prebuilt binaries

Every release ships rings for macOS (aarch64-apple-darwin, x86_64-apple-darwin) and Linux (x86_64-unknown-linux-musl, a static binary that runs on any distribution), plus the WebAssembly package for browsers. Download the archive for your platform, unpack it, and put rings on your PATH:

unzip rings-v0.38.0-aarch64-apple-darwin.zip
install -m 755 rings /usr/local/bin/rings
rings --version

From Cargo

The workspace denies compiler warnings, so build with the Rust release pinned in the repository’s rust-toolchain.toml; a newer compiler can add a warning that fails the build:

cargo +1.97.0 install --locked rings-node

Rings Network is written in Rust. Cargo is a package management tool for the Rust language. You can learn about how to install and use Cargo here.

From Source

git clone https://github.com/RingsNetwork/rings
cd rings
cargo install --path crates/node

Usage

rings <command> [options]

Commands

  • help: displays the usage information.
  • init: creates a default configuration file, ~/.rings/config.yaml unless --location says otherwise. This file can be edited to customize the behavior of the rings-node daemon. The generated file states every section explicitly, including a complete gateway: section with enabled: false (see Native Gateway).
  • run: runs the rings-node daemon. By default, the daemon will use ~/.rings/config.yaml for configuration. Use the “-c” or “–config” option to specify a custom configuration file.

Options

  • -c, --config <FILE>: specifies a custom configuration file to use instead of the default ~/.rings/config.yaml. The configuration file is used to specify the network configuration, account settings, and other parameters that control the behavior of the rings-node daemon.
  • -h, --help: displays the usage information.
  • -V, --version: displays the version information for rings-node.

Host a Native Node

Prepare your configuration

After installation, you can create your configuration by:

rings init

This command will generate a configuration file named config.yaml for you. The default path is $HOME/.rings/config.yaml. You can also customize the path by using rings init --location <path>. Additionally, you can specify the key used by the Rings node by using rings init -k <your private key>. The generated file states every section explicitly, including a complete gateway: section with enabled: false, so it is the one place you edit; see Native Gateway.

By default, an ECDSA key pair will be automatically generated for you. The key pair in Rings Network is used for user identification and message signing. The key pair is utilized in various components of the network, so it is essential for users to ensure the security of their key pair. In the Native Node environment, since the private key is stored in plaintext, we strongly discourage the use of any asset-related key pair to host Rings Network.

More about config.yamlyou can find at:

config.yaml.md

Ready go!

Now you can start your rings-node by using the following command:

rings run

You can use rings run --help to check which settings are supported by the run command.

# rings run --help
Runs a foreground, composable Rings node.

Usage: rings run [OPTIONS]

Options:
      --gateway
          Start the native TUN gateway from the config's gateway section for this run; the section alone never starts it (rings init writes it with enabled: false) [env: GATEWAY=]
      --external-api-addr <EXTERNAL_API_ADDR>
          Rings node external api listen address. If not provided, use external_api_addr in config file or 127.0.0.1:50001 [env: EXTERNAL_API_ADDR=]
      --internal-api-port <INTERNAL_API_PORT>
          Rings node internal api listen port. If not provided, use internal_api_port in config file or 50000
      --api-token-path <API_TOKEN_PATH>
          API Bearer token file guarding the internal API and the external status and registry reads; the external handshake (nodeDid, answerOffer) is public. Relative paths are resolved next to the node config file [env: API_TOKEN_PATH=]
      --api-allowed-origin <API_ALLOWED_ORIGINS>
          Exact browser origin permitted to call the authenticated API; repeat as needed [env: API_ALLOWED_ORIGINS=]
      --allow-remote-external-api
          Explicitly permit external_api_addr to bind a non-loopback address [env: ALLOW_REMOTE_EXTERNAL_API=]
      --ice-servers <ICE_SERVERS>
          ICE server list. If not provided, use ice_servers in config file or stun://stun.l.google.com:19302 [env: ICE_SERVERS=]
      --stabilize-interval <STABILIZE_INTERVAL>
          Stabilization interval in seconds. If not provided, use stabilize_interval in config file or 15 [env: STABILIZE_INTERVAL=]
      --bootstrap-seed <BOOTSTRAP_SEED>
          Seed document URL (file:// or http(s)://) whose peers join the managed bootstrap targets of the config's bootstrap section; the run redials each target through its HTTP endpoint whenever it stops being reachable through the overlay [env: BOOTSTRAP_SEED=]
      --external-ip <EXTERNAL_IP>
          external ip address [env: EXTERNAL_IP=]
      --webrtc-udp-port-min <WEBRTC_UDP_PORT_MIN>
          Minimum UDP port used by native WebRTC ICE gathering. Must be paired with --webrtc-udp-port-max. [env: WEBRTC_UDP_PORT_MIN=]
      --webrtc-udp-port-max <WEBRTC_UDP_PORT_MAX>
          Maximum UDP port used by native WebRTC ICE gathering. Must be paired with --webrtc-udp-port-min. [env: WEBRTC_UDP_PORT_MAX=]
      --storage-path <STORAGE_PATH>
          Storage files location. If not provided, use storage.path in config file or ~/.local/share/rings [env: STORAGE_PATH=]
      --storage-capacity <STORAGE_CAPACITY>
          Storage capacity. If not provider, use storage.capacity in config file or 200000000 [env: STORAGE_CAPACITY=] [default: 200000000]
      --reassembly-profile <REASSEMBLY_PROFILE>
          Inbound chunk reassembly memory profile [env: REASSEMBLY_PROFILE=] [default: production] [possible values: production, constrained]
      --advertise-onion-relay
          Advertise this node as an onion relay in the online-node registry [env: ADVERTISE_ONION_RELAY=]
      --advertise-onion-exit
          Publish this node as an onion exit in the application-layer exit registry; requires --advertise-onion-relay [env: ADVERTISE_ONION_EXIT=]
      --onion-exit-service <ONION_EXIT_SERVICE>
          Onion exit service to serve: tcp or https. May be repeated. [env: ONION_EXIT_SERVICE=]
      --onion-exit-allow-target <ONION_EXIT_ALLOW_TARGET>
          Allow-list target for onion exit policy. May be repeated. [env: ONION_EXIT_ALLOW_TARGET=]
      --onion-exit-deny-target <ONION_EXIT_DENY_TARGET>
          Deny-list target for onion exit policy. May be repeated. [env: ONION_EXIT_DENY_TARGET=]
      --onion-exit-max-sessions <ONION_EXIT_MAX_SESSIONS>
          Maximum onion sessions this exit will serve [env: ONION_EXIT_MAX_SESSIONS=]
      --onion-exit-max-bytes-per-minute <ONION_EXIT_MAX_BYTES_PER_MINUTE>
          Maximum bytes per minute this exit will serve [env: ONION_EXIT_MAX_BYTES_PER_MINUTE=]
      --onion-exit-heartbeat-interval-secs <ONION_EXIT_HEARTBEAT_INTERVAL_SECS>
          Onion-exit registry heartbeat interval in seconds [env: ONION_EXIT_HEARTBEAT_INTERVAL_SECS=]
      --onion-exit-ttl-secs <ONION_EXIT_TTL_SECS>
          Onion-exit registry descriptor TTL in seconds [env: ONION_EXIT_TTL_SECS=]
      --onion-http-proxy-addr <ONION_HTTP_PROXY_ADDR>
          Bind a local HTTP CONNECT proxy that routes client TCP streams through onion exits, e.g. 127.0.0.1:18080 [env: ONION_HTTP_PROXY_ADDR=]
      --onion-http-proxy-header-timeout-secs <ONION_HTTP_PROXY_HEADER_TIMEOUT_SECS>
          Maximum seconds to wait for one HTTP CONNECT header [env: ONION_HTTP_PROXY_HEADER_TIMEOUT_SECS=]
      --onion-http-proxy-max-connections <ONION_HTTP_PROXY_MAX_CONNECTIONS>
          Maximum concurrent local HTTP CONNECT proxy connections [env: ONION_HTTP_PROXY_MAX_CONNECTIONS=]
  -c, --config <CONFIG>
          Config file location [env: CONFIG=] [default: ~/.rings/config.yaml]
  -h, --help
          Print help

With the rings run command, you can override the configurations specified in the config.yaml file using command-line arguments. Additionally, you can use the -c parameter to specify a different config file, which is particularly useful when you need to run multiple instances of the Rings Network.

Enable the native gateway

The generated gateway: section is disabled, so rings run creates no TUN device. To start the gateway for one run without editing the file:

rings run --gateway

To start it on every run, set enabled: true in the section. Either way the interface needs CAP_NET_ADMIN on Linux or the foreground helper; see Native Gateway.

Operate a Node from the CLI

rings is one binary in two roles. rings run is the node: it joins the overlay and serves the JSON-RPC API. Every other subcommand is a client of a running node: it reads the same config.yaml to find the node’s endpoint and the Bearer token that guards its internal API, issues one request, and prints the reply. The API chapter documents the methods the clients call; this chapter documents the commands.

rings <command> [options]

  init          Initializes a node with the given configuration.
  new-delegation   Creates a new delegatee signing key.
  run           Runs a foreground, composable Rings node.
  connect       Connects to a remote peer.
  peer          Manages peers on the network.
  send          Sends a message to another peer.
  service       Registers or looks up a service on the network.
  pubsub        Provides chat room-like functionality on the Rings Network.
  inspect       Show information of swarm: transport table, successors, predecessor, finger table.

rings <command> --help lists every option of a command; rings run --help is reproduced in Host a Native Node.

Addressing the node

The client commands share these options:

OptionMeaningDefault
-c, --config <FILE>The node configuration to read~/.rings/config.yaml
-u, --endpoint-url <URL>The node’s internal JSON-RPC endpointendpoint_url from the config, http://127.0.0.1:50000
--api-token-path <FILE>Bearer token file of the node’s internal API; a relative path resolves next to the config fileapi_token_path from the config

Each option is also read from the environment variable of the same name in upper case (ENDPOINT_URL, API_TOKEN_PATH, …).

Join the overlay

A node needs one peer to start from; Chord stabilization discovers the rest. There are four ways to get that first connection.

Through a peer’s HTTP endpoint

rings connect node https://node.rings.rs

The remote peer’s external API answers the handshake (nodeDid, answerOffer) without authentication, so no token is needed by default. A peer that gates its handshake behind its token takes --remote-api-token-file <FILE>.

Through a seed

rings connect seed file:///etc/rings/seed.json
rings connect seed https://example.org/seed.json

A seed is a JSON document listing peers by DID and endpoint URL; the node tries them in order:

{
  "peers": [
    { "did": "0x…", "url": "https://node.rings.rs" }
  ]
}

An entry may carry an api_token for a peer whose handshake is gated.

Through the DHT

rings connect did 0x…

Routes a connection request to the DID through the peers already connected; the two nodes then exchange SDP over the overlay and open a direct datachannel.

By hand

When neither node has a reachable HTTP endpoint, carry the handshake yourself. On the initiator:

rings connect offer <peer-did>

prints an encoded offer. Deliver it to the peer, which answers it:

rings connect answer <offer>      # or `-` to read the offer from stdin

and prints an encoded answer. Bring that back to the initiator:

rings connect accept <answer>     # or `-`

Exchange SDP explains what each message carries.

Keeping a bootstrap peer reachable

Each rings connect form connects once. A node that later loses its transport to that peer, and every other peer with it, stays partitioned until something reconnects it. For peers the node should always be able to reach, list them under bootstrap.peers in config.yaml, or hand rings run a seed document for one run:

rings run --bootstrap-seed file:///etc/rings/seed.json

A file:// document is read at startup; an http(s):// document is fetched once at startup, so the node does not start while the document’s host is unreachable.

rings run then supervises those targets: whenever one is no longer reachable through the overlay it is redialed through its HTTP endpoint, in a short burst first and then every five minutes plus up to thirty seconds of jitter, until it is admitted again. A target that other peers can route to is left alone.

Peers

rings peer list
rings peer disconnect <address>

Messages

rings send message <to-did> <namespace> <data>

Delivers data to the protocol the peer registered under namespace. Receiving applications embed rings-node and install a namespaced extension protocol; the removed /ws endpoint is not an application-message boundary.

rings pubsub <topic>

Opens a chat-room loop on topic: each line you type is published to the topic, and messages other peers publish to it are printed as they arrive. Each message expires by the element lifetime rule of DHT storage.

Services

rings service register <name>
rings service lookup <name>

register publishes this node as a provider of name in the DHT; lookup returns the DIDs currently providing it. A registration expires by the element lifetime rule of DHT storage, so a provider runs register again periodically to stay listed. Decentralized services describes the model.

Inspect

rings inspect

Prints the swarm as the node sees it: the transport table, successors, predecessor, and finger table.

Delegations and keys

rings init writes the configuration and a delegatee signing key (~/.rings/delegatee_key unless -s, --delegatee-key says otherwise). rings new-delegation writes a fresh delegatee key. Those two delegation-creation commands accept -k or --key-file for the account signer; rings run and the RPC client commands do not. A native node stores the resulting delegatee key in plain text, so never derive it from a key that holds assets.

A delegation bounds the life of the process that uses it (#1063). Every frame a node sends carries its own delegation, and from 25 s before the delegation expires (one delivery bound, the margin that keeps a frame from expiring in flight) every frame is refused. The node can send nothing from then on, and the delegation cannot be replaced without a restart. So rings run:

  • refuses to start with a delegatee key that has less than those 25 s left (DelegationHorizonReached, error code 1003);
  • logs a warning once the end is less than a day away, giving the end in Unix milliseconds;
  • exits with a non-zero status at that end, with the same error, instead of running on mute.

--ttl of rings init and rings new-delegation (default 30 days) therefore sets how long a node can run. Run it under a supervisor that restarts it (Restart=always under systemd, --restart unless-stopped under Docker), and write a fresh delegatee key before the restart, with rings new-delegation. A restart with the old key fails at startup with that error, so the outage is explicit rather than silent. A plain Restart=always alone only repeats that error: the restart needs its re-mint step, for example ExecStartPre= running rings new-delegation for the node’s key.

The deadline is checked at least once a minute against the wall clock, so a host suspended across it still exits within a minute of resuming.

Status observability

Rings Node exposes operator observations through its existing authenticated status route:

TOKEN="$(tr -d '\r\n' < ~/.rings/api-token)"
curl --fail --header "Authorization: Bearer ${TOKEN}" \
  'http://127.0.0.1:50000/status?view=observability'

The compact projection is available on both the internal and external listeners because both already serve /status. It inherits their existing Bearer-token requirement. This change adds no new HTTP route and no unauthenticated surface.

Why the projection is separate from the default body

GET /status keeps its existing response shape for compatibility. That legacy response is a full inspection: besides peers and DHT topology, it serializes every persistent and cache storage value. Its size is therefore data-dependent and may approach the configured storage capacity; the default native data-storage capacity is 200,000,000 bytes before JSON overhead. Do not poll the full view as a lightweight health check.

GET /status?view=observability avoids the full storage inspection. It returns only the node version plus a bounded observability object. The largest collections are capped at 32 recent message records and 32 peer-rating records. A conservative maximum-width production fixture is about 18 KiB and is kept below 32 KiB by a regression test. This makes the projection suitable for periodic polling without adding the storage payload or performing the legacy full status serialization.

Polling interval and request cost

Poll GET /status?view=observability at most once every 60 seconds per node. Do not use it for high-frequency polling, do not start a new request while the previous request is still running, and avoid having multiple independent collectors poll the same node. Configure a client timeout so a slow request cannot accumulate overlapping work.

The response size is bounded, but the work needed to assemble a current snapshot is not constant. Each request walks the live persistent DHT storage view to count mailbox carriers and held messages; the walk reads and decodes stored entries and retires expired entries. Snapshot assembly also projects the current retained peer measurements. Its CPU, storage I/O, allocation, and lock-hold costs therefore grow with the node’s stored data and retained peer state. The 60-second interval is an operational recommendation intended to limit this work; it is not a claim that the endpoint has constant-time cost.

Do not periodically poll the default GET /status response. It additionally serializes every persistent and cache storage value and is intended for explicit inspection rather than monitoring.

The JSON object’s schema_version is the compatibility boundary. Fields may be added within the current version; incompatible meaning or removal requires a schema-version increment.

Scope and privacy

Message and lookup counters are node-local and begin when the current process starts. They are not topic totals, network totals, or durable accounting. Recent message activity retains the last 32 completed operations in memory. Each record contains only sequence, time, action, finite traffic category, compile-time message class, and outcome. Payloads, application topic strings, DIDs, transaction IDs, transport addresses, delegation identifiers, and key material are excluded.

Peer DIDs appear only in the authenticated peer_ratings array, which is sorted and capped at 32 records. Use the existing paginated listPeerMeasurements RPC when every retained peer is needed. Lookup correlation identifiers and mailbox identifiers are never exported.

Values and alert semantics

FieldKindMeaning
messagescountersSuccessful sent, received, forwarded, and stored operations plus aggregate failures during this process. A send or forward succeeds when its next hop accepts the logical transfer.
recent_messagesbounded recordsOldest-first completion records for the last 32 message operations. message_class is a protocol variant, not an application-controlled topic.
session_keygauges and countersActive delegation validity, creation, expiry, and remaining time. Runtime rotation is currently unsupported and its counters remain zero; a replacement delegation is loaded on restart. A native daemon exits 25 s before expires_at_ms, when no frame can leave any more (see Delegations and keys).
mailboxesgauges and counterLive relay-inbox carriers and held messages retained by this node, plus successful offline-message holds during this process.
dht_lookupscounters, gauge, histogramSuccessor and storage lookup rounds observed locally, split into success, explicit failure, and 30-second timeout, with bounded in-flight state and cumulative latency buckets.
peer_ratingsbounded recordsUp to 32 DID-sorted local assessments with recent send/receive evidence and local byte-credit score inputs. Use listPeerMeasurements for the paginated complete set. These are advisory local assessments, not network-wide reputation.
health.process_api_healthygaugeThe authenticated handler answered and assembled its dependencies.
health.overlay_readygaugeConservative overlay readiness: at least one admitted peer and one DHT successor are present, and the node’s own delegation is more than 25 s from its expiry, so its frames still leave. It is deliberately distinct from process/API health.

counter_scope is node_local_process_lifetime, and process_started_at_ms makes resets explicit. Use deltas or rates over cumulative counters because every process restart resets them. Mailbox counts come from the live DHT storage view, which retires expired entries before counting them.

Native Gateway

A native node can forward explicitly selected IPv4/TCP destinations through Rings onion loops, the privacy layer, over a TUN device. The gateway is configured by the gateway: section of config.yaml, which rings init writes in full with enabled: false; see config.yaml for the generated section.

Presence of the section is not consent to start a TUN device:

gateway starts ⟺ section present ∧ (enabled = true ∨ rings run --gateway)

A section that omits enabled is inert, so plain rings run on the generated file creates no interface. To run the gateway once without editing the file:

rings run --gateway

On a config written before the section existed, --gateway fails and prints the exact section to append. The generated plan assigns one host address from the RFC 6598 shared address space (100.64.0.1/32) and captures no destination, so enabling it creates the interface without steering traffic until you list a prefix in included_routes. A TUN device also needs CAP_NET_ADMIN on Linux or the foreground helper described below; the configuration grants neither.

The rest of this page is the operator guide from the rings-gateway crate.

Traffic-selection contract

The operator owns traffic selection. Let C be the normalized set in included_routes:

installed destination capture routes = C

The platform binding deduplicates and normalizes C, but does not add routes derived from a default route, IPv6, DNS, TURN, ICE, SDP, bootstrap endpoints, or observed peers. A host-wide 0.0.0.0/0 capture, or any set of prefixes whose union covers the entire IPv4 address space, is rejected. Interface addresses must be IPv4 /32 host addresses so setup cannot create a connected capture prefix. An empty set is valid when external routing owns packet selection instead.

This follows the same mechanism-versus-policy separation as OpenVPN. OpenVPN’s --route option selects individual prefixes, while --redirect-gateway def1 explicitly requests full-tunnel routing and implements it with two /1 routes. That is a valid OpenVPN policy, but Rings intentionally has no redirect-gateway equivalent. Capturing every host destination would also capture the ordinary underlay traffic carrying existing Rings neighbor links; avoiding recursion would require dynamic bypass state or guaranteed relay. Rings requires neither. A /0 or equivalent covering prefix set is therefore rejected as an intent stop-line outside the selective gateway contract. Near-cover sets remain valid, including the entire IPv4 space minus one /32; this rejection is not a proof against recursion. If the operator explicitly selects an underlay destination, it enters the same fail-closed path as any other selected destination and Rings does not create a direct bypass.

The gateway’s fail-closed guarantee applies only after traffic enters it. A selected flow either opens its immutable target through a valid Onion route and compatible TCP exit, or fails without direct fallback. Traffic outside C continues to use ordinary host routing. Rings bootstrap, SDP messages, and ICE also keep their ordinary routing behavior. STUN remains an ordinary discovery facility. TURN may be configured, but gateway and server capability must never depend on it or force relay-only ICE.

Traffic can enter the shared Onion egress through:

  • an exact host route or destination prefix installed by the packet binding;
  • an external router or policy-routing rule owned by the operator; or
  • an explicit application proxy: the native HTTP CONNECT listener.

The native HTTP CONNECT proxy and packet ingress share Onion target validation, route construction, TCP stream opening, bidirectional transfer, half-close, and accounting. They differ in idleness: a CONNECT tunnel ends after the relay’s own idle timeout, while a captured flow’s idleness belongs to the gateway’s flow_idle_timeout. A future SOCKS5 frontend should add only ingress parsing and authentication policy.

Pointing several DNS names at one gateway address does not preserve the original generic TCP target. Optional DNS integration therefore needs a dedicated synthetic prefix and an explicit synthetic IP -> original target mapping. Rings does not discover or mutate system DNS.

Process model

rings run remains a foreground Unix-style process. This crate does not install, start, stop, or wrap systemd, launchd, the Windows Service Control Manager, or another supervisor.

On Linux and macOS, privileged interface and route effects are isolated in the separately launched gateway-config-unix foreground helper. The node keeps one mode-0600 Unix socket connected for the gateway lease. The helper creates the TUN/utun interface, installs only included_routes, passes the packet descriptor with SCM_RIGHTS, and owns cleanup. A normal teardown removes owned routes in reverse order. A node disconnect leaves the interface and selected routes alive so those selected destinations remain fail-closed; a restarted node may resume the lease with the same plan. Unrelated host traffic remains unaffected.

The helper never invokes privilege elevation. Operators choose the narrow mechanism:

  • Linux can grant only CAP_NET_ADMIN to the helper or run it in an already-privileged namespace.
  • macOS can launch the helper explicitly as root and use --socket-owner UID for one node user.
  • Windows uses the in-process Wintun and route binding, so the foreground node needs the relevant host privileges.

Build and run a Unix helper for one node lifecycle:

cargo build --release -p rings-gateway --bin gateway-config-unix

# Linux
sudo setcap cap_net_admin=ep target/release/gateway-config-unix
target/release/gateway-config-unix \
  --socket "$HOME/.rings/gateway-helper.sock" \
  --ledger "$HOME/.rings/gateway-routes.json" \
  --interface rings0

# macOS
sudo install -d -o root -g wheel -m 0755 /var/db/rings-gateway
sudo target/release/gateway-config-unix \
  --socket "/var/db/rings-gateway/helper-$(id -u).sock" \
  --socket-owner "$(id -u)" \
  --ledger "/var/db/rings-gateway/routes-$(id -u).json"

Both direct parents must belong to the helper’s effective UID. Every canonicalized ancestor must belong to that UID or root and reject group/other writes. The socket is mode 0600 and may be assigned to the authorized node UID. Startup probes an existing socket and refuses to unlink it while a helper is listening; only an unreachable stale socket node is removed.

Then run the node separately:

rings run --gateway --config "$HOME/.rings/config.yaml"

Node configuration

The current packet milestone is IPv4/TCP. Only the listed destination prefixes enter the packet gateway. UDP or fragmented IPv4 packets that enter a listed prefix are dropped; IPv6 routes are rejected at configuration time. Unlisted IPv4, all IPv6, and ordinary DNS traffic remain outside gateway policy.

rings init writes a complete gateway: section with enabled: false: the interface-only plan (GatewayPlan::interface_only, one RFC 6598 host address, no capture routes, the IPv6 minimum MTU) under the default runtime limits. The section starts nothing by itself; the gateway runs only under an explicit enabled: true or rings run --gateway, and a section that omits enabled is inert.

# STUN remains ordinary discovery. TURN is optional and never a gateway prerequisite.
ice_servers: stun://stun.l.google.com:19302

gateway:
  enabled: true
  plan:
    # Host prefixes are required so interface setup cannot create a connected capture subnet.
    addresses: ["100.64.0.1/32"]
    # Only these destinations are routed to TUN/utun/Wintun.
    included_routes: ["1.1.1.1/32"]
    mtu: 1280
  max_flows: 1024
  flow_idle_timeout: 300
  tcp_buffer_bytes: 65536
  # Must match the foreground helper's --socket path on Linux/macOS.
  unix_helper_socket: "/var/db/rings-gateway/helper-501.sock"
  # Used directly on Windows; the Unix helper owns its separate --ledger path.
  route_ledger_path: "~/.rings/gateway-routes.json"
  status_refresh_secs: 2

For externally steered packet operation, included_routes may be empty. The packet interface still exists, but Rings installs no destination route:

gateway:
  enabled: true
  plan:
    addresses: ["100.64.0.1/32"]
    included_routes: []
    mtu: 1280

For application-proxy-only operation, leave gateway disabled and configure the existing native HTTP CONNECT listener. That path creates no packet interface. SOCKS5 remains a future frontend to the same Onion egress.

Runtime input never expands the capture set. If an operator deliberately selects a destination that Rings itself uses for bootstrap or ICE, the configured route has the same effect as any other operator route; Rings does not silently punch a bypass around it.

At establishment, the native binding reads one route-table snapshot and rejects every exact or more-specific existing route inside a capture prefix because longest-prefix routing would let that sub-range bypass the gateway. Rings deliberately has no route-change subscription. A route added after that snapshot, including while establishment finishes, can therefore shadow a capture prefix and send that sub-range through the host’s new route; operators must prevent or monitor such route-table mutation. The fail-closed guarantee begins only after a packet actually enters the gateway.

Removed route-authority fields such as routing_mode, excluded_routes, dns_policy, and dns_servers are rejected inside plan instead of being silently ignored. The gateway never interprets old full-capture configuration as a new selective-capture plan.

Cleanup and status

The durable route ledger contains only explicitly selected capture routes. Its persistent .lock sidecar is held exclusively for the complete native-controller lifetime, including startup reconciliation, so a second helper or Windows node using the same ledger fails before it can touch the first instance’s live routes. The sidecar file may remain after shutdown; the operating system releases its advisory lock when the process exits. Startup reconciliation removes only stale owned routes. On macOS it first requires the current exact route to carry the interface identity recorded in the ledger; an absent route clears the intent, while a foreign or ambiguous owner is preserved and reported rather than deleted. A failed teardown returns its linear lease so cleanup remains retryable. On Unix, the helper retains its packet descriptor after transfer. On Windows, the node retains the Wintun device after a cleanup failure. These behaviors keep affected selected destinations fail-closed without claiming control over unrelated traffic.

Runtime limits reject more than 16,384 flows, TCP buffers larger than 1 MiB, or a declared four-buffer-per-flow budget above 1 GiB. The Unix client bounds connection and response operations to 10 seconds by default with a hard 30-second maximum.

The loopback-only internal API exposes gateway status when configured:

GET /gateway/status

It reports interface, lifecycle, normalized capture routes, active-flow count, Onion-exit availability with the reason it is not available, and the cause of the most recent flow reset, without granting route mutation authority.

Health and rejection diagnostics

Packet admission lifecycle and exit availability are independent. An active gateway with no usable exit reports degraded health together with its reason; discovery recovery changes the health projection without restarting the packet interface. Inspect the shared status handle for current health.

Every captured-flow reset names its cause in last_flow_reset and in an info-level rings_gateway::runtime event. A silent client ends a flow as idle-timeout only when its onion stream was open and healthy; the same silence names what the flow was waiting on otherwise: handshake-expired, open-expired while the onion stream was still opening, stream-stalled while a frame of the stream was known missing, and stream-reset once its onion session had failed. Enable debug tracing for rings_gateway::runtime to also see typed packet-drop and flow-rejection reasons. These events contain neither packet bodies nor destination addresses. Configuration is checked at runtime construction before resource allocation; standalone server and TCP stack construction also reject invalid configuration.

DNS Relay

A DNS relay names every node of a Rings network on the internet and relays its services there. An operator delegates a DNS zone to a relay node, for example rings.rs or a user’s p2p.example.com. Every node then has the name <did>.<zone>, and the services it announces are reachable at that name by ordinary internet software: a mail server delivering to me@<did>.<zone>, an SSH client, a browser (#1049).

  mail server ──DNS──▶ relay: <did>.<zone>  AAAA addr(did), MX <did>.<zone>
  mail server ──TCP──▶ relay: (addr(did), 25) ──TCP relay over Rings──▶ node did ──▶ 127.0.0.1:2525

Every DID gets its own IPv6 address in the relay’s prefix. A protocol that names no host before its first byte reaches the server, as SMTP and SSH do not, is therefore routed by destination address alone: the relay parses nothing it carries, and TLS, where a service uses it, ends on the node.

The model

  Z        the zones delegated to the relay (NS records at their parents)
  o        an owner; label(o) is its canonical DNS label, a DID's "0x" and 40 hex digits
  σ(o)     what o announces: its ports, each with or without a PROXY header, and its TXT
           records, or ⊥
  P, k     the relay's IPv6 prefix and secret address key
  addr(o)  = P ∨ (HMAC-SHA256_k(label(o)) ∧ hostmask(P))
  B        the address book: addr(o) ↦ o for every owner the relay answered for
  μ        the memory: the last σ(o) seen; it answers without a lookup for a freshness window,
           and for a grace period g after o vanishes

  DNS      label(o).z   AAAA  addr(o)                 when σ(o) ≠ ⊥ (or μ(o) is within g)
           label(o).z   MX    10 label(o).z           when σ(o) serves port 25
           s.label(o).z TXT   txt(σ(o))(s)
           any other name in z: NXDOMAIN, with z's SOA

  ingress  a TCP connection to (a, p) is relayed to port p of B a when σ(B a) serves p,
           a PROXY v2 header first when σ says so, else closed

Each owner has its own IPv6 address, so a protocol that names no host before its first byte reaches the server, as SMTP and SSH do not, is routed by destination address alone: the relay parses nothing it carries. The address key stops anyone from grinding an owner onto another owner’s address, and the book keeps the map’s inverse, injective, across restarts. The memory keeps an owner that goes offline named for the grace period, so a mail server retries a delivery instead of bouncing it; an owner that announces nothing is forgotten at once. The book and the memory hold at most max_owners owners, and at most max_lookups directory lookups run at once, so neither made-up names nor made-up owners grow the relay’s state or its work without bound.

Running a relay

A relay is a native node with a dns_relay section in its config.yaml:

dns_relay:
  zones:
    - origin: rings.rs
      nameservers: [ns1.example.net]
      contact: hostmaster.example.net
  prefix: 2001:db8:1::/64
  ports: [22, 25, 80, 443]
  state_dir: ~/.rings/dns-relay
  • zones: the zones delegated to the relay. A nameserver lies outside every relayed zone, so the relay never needs glue for it. Zones are pairwise disjoint.
  • prefix: the IPv6 prefix routed to this host; every owner gets an address in it. A /64 is plenty.
  • ports: the ports the ingress accepts on, for every address in the prefix.
  • state_dir: where the relay keeps its address key (address.key) and, as append-only journals, its address book (address-book.jsonl) and its memory of announcements (announcements.jsonl). Keep it: a new key moves every owner to a new address.
  • dns_listen (default [::]:53 and 0.0.0.0:53), ingress_listen (default ::, an IPv6 address), ttl_secs and negative_ttl_secs (default 60), book_retention_secs (default eight days), grace_secs (default seven days), fresh_secs (default 15: how long an owner’s last lookup answers without asking the DHT again), max_owners (default 65536: the owners the relay keeps an address and a memory for, the least recently answered forgotten first), max_lookups (default 64: DHT lookups in flight), max_connections (default 4096) and max_connections_per_owner (default 64).

On the host:

  1. Make the prefix local, so the wildcard listener receives every address in it: ip -6 route add local 2001:db8:1::/64 dev lo. The provider must route the prefix to the host.
  2. Delegate the zone at its parent: rings.rs. NS ns1.example.net., and give the nameserver an A/AAAA record pointing at the relay host.
  3. Let the node bind port 53 and the ingress ports (on Linux, CAP_NET_BIND_SERVICE).

DNS listens on every dns_listen address over UDP and TCP, and on an IPv6 address IPv6 only, so the default’s two wildcards are two sockets on every system, even where IPv6 sockets are dual-stack by default, as on Linux. A host running a local stub resolver, such as systemd-resolved (the default on Debian and Ubuntu), already holds port 53 on 127.0.0.53 and 127.0.0.54, and the IPv4 wildcard cannot bind beside it. Such a host lists its own addresses instead: dns_listen: ["[::]:53", "192.0.2.1:53"], with the IPv4 address the nameserver’s A record reaches (behind a provider’s NAT, the host’s private address).

The ingress listens IPv6 only, so a host service on the same port’s IPv4 wildcard, such as an sshd on 0.0.0.0:22, keeps every IPv4 connection. An IPv6 host service on one of the ingress ports keeps its connections when it listens on the host’s own address (ListenAddress 2001:db8::1 for sshd), not on [::]: the kernel prefers the exact address to the relay’s wildcard listener, and two wildcard listeners on one port cannot both bind. A relay node may announce services of its own: its own name resolves like any other, and the ingress dials its own targets directly, since the overlay has no hop to itself.

What the relay answers

  • <did>.<zone> AAAA is the owner’s address, MX is the name itself when the owner serves port 25, and the TXT records the owner publishes are served at their names under its own.
  • A name that is no DID, or a DID that announces nothing, is NXDOMAIN; a DID whose descriptor the relay cannot fetch is SERVFAIL, which resolvers retry and do not cache.
  • An ANY query is answered with one record set of the name, as RFC 8482 recommends, so the relay is a poor amplifier of forged queries.
  • Any 40 hex digits name an owner, so a flood of queries for made-up owners would become as many DHT lookups. At most max_lookups are in flight: a query that finds them taken is answered from the relay’s memory, or with SERVFAIL at once.
  • An owner that goes offline vanishes from the DHT within minutes. The relay keeps answering for it from its memory for grace_secs, so a mail server sees a connection failure, a temporary error it retries, and does not bounce the mail. An owner that announces nothing (an empty services section) is forgotten at once.

Serving a node through relays

A node announces what it serves with a services section:

services:
  ports:
    - port: 25
      target: 127.0.0.1:2525
      proxy_protocol: v2
    - port: 22
      target: 127.0.0.1:22
  txt:
    - name: _acme-challenge
      values: ["<token>"]
  • ports: each port of the node’s relayed address, and the local address the node dials for a connection to it. The node registers each as the TCP relay service dns-relay/<port>, and its relay dials nothing else, so a relay can reach only what the node mapped. The node dials the target from its own loopback, so the service sees every connection come from the node itself. With proxy_protocol: v2 (default off), the relay sends a PROXY protocol v2 header naming the client and the relayed address before the client’s bytes, for a service that reads one (Postfix’s smtpd_upstream_proxy_protocol, HAProxy, the mailbox example) and so logs, rates and filters by the real client. The header is unauthenticated: see SECURITY.md.
  • txt: TXT records under the node’s name; name is relative to it, and empty for the name itself. An ACME DNS-01 challenge is published this way, so the node can hold a certificate for <did>.<zone> and end TLS itself.
  • heartbeat_interval_secs (default 30) and ttl_secs (default 90): how often the node publishes its descriptor, and how long after its last heartbeat a relay still reads it. The descriptor is a signed 10-minute lease that each heartbeat re-appends unchanged (#918); a change of ports or records signs a new one. The interval must be below the TTL and below 300 seconds, and the TTL at most 600 seconds.

The node is then <did>.<zone> in every zone a relay serves, with the DID that rings run prints. With port 22 mapped to the node’s SSH server, ssh user@<did>.<zone> reaches it.

Example: a mailbox

examples/mailbox is me@<did>.<zone> as a program: an SMTP receiver for its own address in every zone, a mailbox store (INBOX, Sent, Drafts, Trash, Junk and Archive, with flags and UIDs), an IMAP4rev1 server for the user’s mail client (IDLE, UIDPLUS, MOVE, SPECIAL-USE), and SMTP submission that delivers to each recipient’s mail exchanger, so one node’s mailbox writes to another’s through the relays.

cargo run -p rings-mailbox-example -- --did <the node's DID>

It listens on loopback only: the receiver on 127.0.0.1:2525 (map it as port 25 in the node’s services; with proxy_protocol: v2 there, start the mailbox with --receiver-proxy-protocol so its Received headers name the sending server), submission on 127.0.0.1:1587 and IMAP on 127.0.0.1:1143, with user me and the password it writes to ~/.rings/mailbox/password. Point a mail client at the last two. The example ends no TLS; expose IMAP through a relay only behind a service that does.

The same mailbox can instead receive at <did>@<domain> through mail gateways, which needs no relay and no served port: see Mail Gateway.

Mail Gateway

A mail gateway lets any Rings node receive internet mail at did@domain, a browser node included (#1117). The node does not need to listen on a socket, run an SMTP server, or be online when the mail is sent. The gateway is a stateless SMTP receiver. It seals each mail to its recipient’s key and writes it to an ordinary DHT data topic. The recipient’s node fetches the topic, opens the mail and files it.

  mail server ─MX─▶ gateway ─seal, Extend(mail:<did>, 100 min)─▶ DHT ◀─poll─ recipient node
                       ▲                                                        │
                       └──────────── μ(did) on mail_descriptor:<did> ◀──────────┘ heartbeat

Unlike the DNS relay, the gateway does not forward a connection to the recipient, so the recipient needs no IPv6 address, no served port and no running mailbox server. The gateway keeps nothing either: each RCPT is one DHT read, and each DATA is one write per recipient.

Addressing

  • The address is did@domain. The local part is the recipient’s DID, 0x and 40 hex digits (42 octets, within the 64 of RFC 5321), compared in lower case.
  • domain is any domain whose MX names a gateway, for example p2p.rings.rs MX 10 mx.example.net. Any number of gateways can serve a domain, on ordinary IPv4 or IPv6 addresses, and a sending server’s MX fallback skips one that is down.
  • Every such domain reaches the same mailbox: d@a and d@b are one address.

Opting in: the mail descriptor

A node takes mail only while it publishes a mail descriptor μ(d) on its topic mail_descriptor:<did>. The descriptor is a signed registry lease, like the DNS relay’s service descriptor (#918), re-published on every heartbeat. Its signature carries the delegation that authorizes the node’s session key.

Mail is sealed to that session key, the delegatee’s secp256k1 public key ek, because a wallet account (EIP-191, BIP-137) cannot decrypt with its account key. The descriptor carries no copy of ek. The gateway recovers it from the descriptor’s signature, so ek is always the key that signed it.

In Rust, a node opts in through its builder:

#![allow(unused)]
fn main() {
use rings_node::mail::registration::MailOffer;

let processor = ProcessorBuilder::from_config(&config)?
    // ... stores ...
    .receive_mail(MailOffer::default()) // a heartbeat every 30 s, live for 90 s
    .build()?;
}

The schedule obeys the registry timing law. The heartbeat interval must be below the TTL and below 300 seconds, and the TTL at most 600 seconds. A node that stops taking mail stops its heartbeat. Within the TTL, gateways then defer mail to it with 451, and senders retry until it is back.

The gateway

CommandRule
RCPT TO:<l@any domain>250 if l is a DID with a live descriptor, which the session keeps for DATA; 451 if it has none or the DHT cannot tell; 550 if l is not a DID
DATA mfor each distinct recipient DID, concurrently: seal the traced message, write it; 250 once an owner of every write reported holding it
552 above 32 chunks (SIZE advertises the bound, about 1 MiB); 451 if every owner of a write refused it, no owner reported within 60 seconds, or a write failed
  • A DID with no live descriptor gets 451, not 550. A gateway that keeps nothing cannot tell a recipient that is away from one that never took mail. The sender’s retries reach a recipient that comes back, and only a local part that is not a DID is refused.
  • 250 means that an owner reported storing the mail (a store report, #866). One owner is enough, since a recipient’s fetch can then reach the mail. Waiting for every owner would let one slow or refusing owner turn a held mail into a retry. Read-your-writes (#948, #982) answers the writer’s reads from its own cache, so a read-back is no such report.
  • A 451 loses nothing. The sender retries, and a copy that an owner did hold is a duplicate that the recipient files once.
  • The report wait, MAIL_STORE_REPORT_WAIT, is 60 seconds. That is far above an overlay round trip and well inside the ten minutes an SMTP client waits for the reply to its final dot (RFC 5321 §4.5.3.2.6).
  • A mail that its recipient does not fetch within its lifetime, 100 minutes, expires and is lost. This is the intended semantics: the gateway keeps no queue.
  • STARTTLS uses the gateway’s own certificate. It is opportunistic, as between mail servers.
  • The gateway prepends Return-Path and Received to the message before sealing it, as a final receiver does. The recipient files the message as it opens.

Run a gateway as its own node, with that node’s configuration (not beside rings run):

cargo run -p rings-mailbox-example --bin rings-mail-gateway -- \
  --config ~/.rings/config.yaml --host mx.example.net \
  --tls-cert /etc/ssl/mx.pem --tls-key /etc/ssl/mx.key

It listens on [::]:25 by default (--smtp). Point the MX of each domain at --host.

Storage

topic(d)   = "mail:" ‖ d                              EntryKind::Data
c          = AeadCiphertext(m)                         ElGamal-wrapped ChaCha20-Poly1305 key,
                                                       aad = "rings-node:mail:" ‖ d
chunk_i    = slice_i(c)                                each element ≤ ENTRY_PAYLOAD_MAX_BYTES
manifest   = (mail_id = H(c), [H(chunk_0) … H(chunk_{n−1})])
write      = Extend(topic(d), [manifest, chunk_0 … chunk_{n−1}], lifetime = MAX_TTL_MS)

A mail is one Extend of all its elements (ScopedStorage::storage_extend_reported). The write requests the data kind’s longest lifetime, MAX_TTL_MS, so every element lives 100 minutes. All the elements share one version, travel in one message to each owner, and retire together. A plain append would request the default 10 minutes and need one message per element.

Laws the recipient keeps:

  • Integrity. It reads a chunk only through a manifest that lists the chunk element’s digest, in that manifest’s order. A forged chunk, or a manifest listing one, never alters a mail. The manifest is not signed: gateways are interchangeable, and the content address below already fixes what a manifest can claim.
  • All or nothing. The AEAD tag covers the whole of c. A missing or altered chunk discards the whole mail, and the next poll tries it again.
  • Content address. It ignores a manifest whose chunks do not concatenate to the preimage of its mail_id. A forger therefore cannot mark a genuine mail unreadable.
  • No removal. It never removes its chunks; each element retires at its horizon. A data carrier refuses removes past DATA_TOMBSTONE_MAX_LEN, about 10 a minute per topic.
  • Deduplication. Encryption is randomized, so a resent mail is a new ciphertext. The recipient deduplicates by the decrypted Message-ID, kept for five days (RFC 5321 retries).

Bounds: at most 32 chunks a mail, about 1 MiB. A topic keeps ENTRY_DATA_MAX_LEN = 1024 elements, dropping the oldest first, so 32 mails of the largest size fit within one lifetime.

The recipient

The recipient library (rings_node::mail::recipient) builds for native and browser nodes.

  • Polling. It reads its topic when it starts, then every interval: 60 seconds by default, at most 50 minutes, half the horizon.
  • Storage. It keeps its records in any key-value store: a file store natively, IndexedDB in a browser. It files mail into any MailInbox (KvInbox over a key-value store, or the mailbox example’s INBOX).
  • At least once. It files a mail before it records it, so a crash between the two files the mail again on restart. A crash never loses a mail; at worst it files it twice.
  • One key. Mail opens only under the node’s current delegatee key. Mail sealed to a key the node no longer holds is lost, like mail not fetched within its lifetime. A browser node takes a new key at every load, so mail that a gateway accepted under the old key, within the descriptor’s freshness window around a reload, never opens.
#![allow(unused)]
fn main() {
use rings_node::mail::recipient::KvInbox;
use rings_node::mail::recipient::Recipient;
use rings_node::mail::recipient::DEFAULT_MAIL_POLL_INTERVAL;

let inbox = KvInbox::new(mail_store);
let recipient = Recipient::start(processor, inbox, records, DEFAULT_MAIL_POLL_INTERVAL)?;
recipient.run(stop).await; // or `recipient.poll().await?` on a schedule of your own
}

Example: a mailbox through gateways

The mailbox example receives through gateways when it runs a node itself, instead of rings run:

cargo run -p rings-mailbox-example -- --rings-config ~/.rings/config.yaml

It publishes the node’s mail descriptor, polls the node’s mail topic, and files every mail in its INBOX, which a mail client reads over IMAP as before. Its recipient records live in ~/.rings/mailbox/recipient.

Open questions

  • Topic flooding. Anyone can write mail:<did>. Its 1024 elements are dropped oldest first, so several writers together can evict a recipient’s mail. A per-writer bound needs a core change.
  • Gateway throughput. A gateway’s writes are bounded by its per-origin storage quota at each owner. The topics of different recipients lie at different owners, so the load spreads.
  • Sending from did@domain. This needs an outbound relay with SPF, DKIM and reverse DNS for the domain, and port 25 egress. It is a later phase.
  • Browser bindings. The recipient library builds for the browser, but the JavaScript provider does not expose it yet.

Build for Wasm

We offer several ways to use Wasm Rings, including:

  1. Using Rings SDK
  2. Using Rings Browser Extension
  3. Build your custom-rings

Rings SDK

To fetch the SDK from npm, just:

npm i @ringsnetwork/rings-node

If you want to use the latest version of Rings Network, you can also directly incorporate the latest version or a specific branch of Rings Network into your Node.js project by modifying the package.json file. For example:

# package.json
"@ringsnetwork/rings-node": "https://github.com/RingsNetwork/rings.git#master",

As a developer, if you want to build local offline dependencies, you can do the following.

```text
"@ringsnetwork/rings-node": "git+file:../rings#master",

Note: The reason for using a git path instead of a local path is that npm, after a certain version, no longer executes the prepare script in local path packages, and the package relies on the prepare script to help with wasm compilation.

Rings Browser Extension

The browser extension is the repository’s frontend packaged as a Chrome Manifest V3 extension; Browser Node and Extension covers the hosted console and the packaging steps.

Custom Build

To build Rings Network for WebAssembly, run the following commands:

cargo build --release --target wasm32-unknown-unknown --no-default-features --features browser
wasm-bindgen --out-dir pkg --target web ./target/wasm32-unknown-unknown/release/rings_node.wasm

Or build with wasm-pack

wasm-pack build --scope ringsnetwork -t web --no-default-features --features browser --features console_error_panic_hook

For core-only development, cargo wasm and cargo test-wasm select rings-core with its wasm feature and no default features. Both aliases work from the repository root or crates/core. The root .cargo/config.toml also supplies the WASM test runner and timeout to both locations.

Browser Node and Extension

The frontend at rings.rs is the same node compiled to WebAssembly and wrapped in a Rust/Yew application. It runs as a web app, and the same build packages as a Chrome extension. Its source is the repository’s frontend workspace.

The hosted console

rings.rs/#node is a browser node with a console around it. From top to bottom:

  1. Settings (#node/settings): the network id, ICE servers, stabilization interval, the name of the browser storage the node persists to, the seed URL the node joins through (https://node.rings.rs, the public seed, by default; a native node’s external API such as http://127.0.0.1:50001 is the other suggestion), and the WebView’s onion policy. Settings are kept in the browser’s local storage.
  2. Account: the identity the node signs with. WebCrypto P-256 generates a key in the browser; MetaMask signs with EIP-191; Phantom signs with Ed25519. The node’s DID is derived from the chosen account.
  3. Start: builds the provider from the settings and account and starts listening.
  4. Connect: through a seed node’s HTTP endpoint (the same public handshake rings connect node uses), or by hand with an SDP offer and answer pasted between two browsers.
  5. Topology: the connected peers drawn on the Chord ring, with the node’s own position.
  6. Workbench (#node/workbench): the Onion Proxy panel builds privacy-layer routes through onion relays and exits and sends HTTPS requests over them; the Custom panel registers a namespace and sends and receives messages under it over the plain overlay, which routes and encrypts but does not hide the endpoints.
  7. WebView: once the local onion gateway is ready, /webview browses target sites through onion loops, answered by a service worker the shell registers.

The shell is hash-routed (#node, #node/settings, #node/workbench, #guide), so every screen has a URL.

Run it locally

Install Trunk and the wasm32-unknown-unknown target, then:

cd frontend
trunk serve --release true

Trunk prints the URL it serves. The frontend is its own Cargo workspace, so its checks run from that directory:

cargo check --target wasm32-unknown-unknown
cargo clippy --target wasm32-unknown-unknown --all-targets -- -D warnings
cargo test --release --target wasm32-unknown-unknown

Package the extension

The extension is the same application rewritten into a Chrome Manifest V3 package: a side panel and options page over a retained offscreen node, a wallet bridge that asks Chrome to inject a short-lived signing request into the active http/https tab for MetaMask and Phantom, and the Onion WebView. From the repository root:

npm ci --ignore-scripts
npm run package:frontend-extension

The package is written to frontend/dist-extension/. Load that directory from chrome://extensions with developer mode on; after each rebuild, click Reload there.

Two boundaries of the package are deliberate. The wallet bridge needs a normal wallet-enabled page open as the active tab, because that is where the wallet provider is injected; there is no centralized bridge site. The WebView renders proxied documents with a CSP sandbox and no allow-same-origin, so target script stays in an opaque origin; static and server-rendered pages with rewritten subresources are supported, and page-driven fetch or XHR to the gateway is not. The frontend README records both in full, with the test suites that pin them.

Embed the node in your own page

For your own application, skip the console and use the npm package directly; Build for Wasm covers the package and a custom build, and Connect Rings Network the ways a browser node joins.

Connect Rings Network

Rings is a peer-to-peer network based on WebRTC transport, and as such, you have multiple ways to establish connections:

Peer-to-Peer:

You can establish a direct connection by exchanging Session Description Protocol (SDP). SDP is a format used to describe media session characteristics in a format that can be exchanged between peers. Read more:

exchange-sdp.md

Via Entry Point

Rings nodes are functionally equivalent, but they are divided into two categories based on the execution environment: Node nodes and Browser nodes. Node nodes are allowed to enable an external service called EntryPoint.

The Entry Point is an HTTP service primarily used to assist in the initialization and network connection of nodes within a firewall. As long as you can connect to one of the nodes, you can join the Rings Network.

If you have installed a Node node, you can use the following command to utilize the Entry Point:

rings connect node <entry_point url>

If you are using a Browser node, you can utilize the Entry Point by calling the following function:

client.connect_peer_via_http(url)

Future reading:

handshake.md

Embed via C FFI

rings-node exports a small C ABI so a host that cannot call Rust directly — Python, Swift, Kotlin, or any language with a C foreign-function interface — runs a full node in its own process. The ABI is declared in crates/node/include/rings.h, generated by cbindgen from the ffi feature. It is deliberately narrow: one constructor, one request entry point that carries the whole JSON-RPC API, and the ownership calls those two need.

Build the library

cargo build -p rings-node --features ffi

The library lands in target/debug/ as librings_node.dylib (macOS), librings_node.so (Linux), or rings_node.dll (Windows); add --release for target/release/.

The ABI

FunctionRole
rings_node_init_logging(level)Installs the logger once; level is Debug, Info, Warn, Error, or Trace.
rings_node_new_provider_with_callback(network_id, ice_server, stabilize_interval, account, account_type, storage_path, signer)Creates a provider keeping its stores under storage_path, and returns its handle, or NULL when creation fails.
rings_node_listen(provider)Starts message listening and Chord stabilization.
rings_node_request(provider, method, params)Calls one method of the JSON-RPC API with a JSON params string; returns the reply as a string, or NULL when validation or execution fails.
rings_node_string_free(value)Releases a string returned by rings_node_request.
rings_node_provider_destroy(provider)Stops the listener and every task of the node, closes its connections, flushes its measurements, and releases every store under its storage_path, so a provider created again on that path in this process opens them; then destroys the provider, and the handle is invalid afterwards.

The constructor takes the same values config.yaml gives a native node: the network_id, an ICE server list in [turn|stun]://<host>:<port>;... form, the stabilization interval in seconds, the account as a hex string, and its account_type (eip191, ed25519, or bip137). The signer is a callback void signer(const char *proof, char *out) that writes exactly 65 signature bytes over proof into out; the node calls it to sign its session and never sees the private key.

storage_path is a directory the provider owns. Under it the provider opens the same stores a native node opens beside its data store, each at its authority: data (the DHT data store), transaction-replay (the replay windows, authoritative), relay-inbox (held mail, retained), dht-removals (the data topics’ uncollected removals, retained), onion-entry-guards (a durable cache), and measure with provisional-evidence. Pass the same path on every run of the same node: the replay windows must survive a restart, or the node would accept replayed transactions and re-sign used sequences (#1022). A fresh path is a fresh node state.

The provider owns its stores exclusively while it lives. Each store takes an exclusive lock on its own directory at open (a .lock file, flock on unix and LockFileEx on Windows) and holds it until the provider is destroyed (#1039). A second provider, or a native daemon, given a store this provider holds fails to open it, so rings_node_new_provider_with_callback returns NULL instead of interleaving its writes with this one’s. The lock is per store, so providers with distinct roots never conflict. A file system that offers no such lock fails the open.

Lifecycle

rings_node_init_logging(Info);

struct ProviderHandle *provider = rings_node_new_provider_with_callback(
    1, "stun://stun.l.google.com:19302", 15, account, "eip191", "/var/lib/myapp/rings", signer);
rings_node_listen(provider);

char *reply = rings_node_request(provider, "connectPeerViaHttp",
                                 "{\"url\": \"https://node.rings.rs\"}");
/* ... read the JSON reply ... */
rings_node_string_free(reply);

rings_node_provider_destroy(provider);

The ownership rules are the ones the header states, and every wrapper in the repository follows them:

  • A handle is released exactly once with rings_node_provider_destroy, never used afterwards, and never destroyed concurrently with another call on it.
  • Every non-NULL string from rings_node_request is released exactly once with rings_node_string_free; passing NULL is a no-op.
  • The signer callback stays alive for as long as the provider does: the host retains it.

Wrappers

examples/ffi holds three wrappers over this ABI with the same desktop flow — node identity, offer/answer exchange, peer connection polling, end-to-end handshake and messaging, and event polling:

  • rings.py: a cffi binding that parses rings.h and loads the library for the host platform. Its tests run against a built library:

    python -m pip install web3 cffi pytest
    RINGS_FFI_REQUIRE_LIBRARY=1 pytest examples/ffi/tests
    
  • swift/: a RingsRuntime(libraryPath:) wrapper that loads the library dynamically, with a smoke test that also verifies every required symbol when given the real library:

    bash examples/ffi/swift/test.sh target/debug/librings_node.dylib
    
  • kotlin/: the same flow over JNA.

They stop at the portable C ABI; mobile packaging (iOS NetworkExtension, Android VpnService) is outside it.

Architecture

The Rings Network architecture is streamlined into six distinct layers.

Runtime Layer

The design goal of Rings Network is to enable nodes to run in any environment, including browsers, mobile devices, Linux, Mac, Windows, etc. To achieve this, we have adopted a cross platform compile approach to build the code. For non-browser environments, we use pure Rust implementation that is independent of any system APIs, making our native implementation system agnostic. For browser environments, we compile the pure Rust implementation to WebAssembly (Wasm) and use web-sys, js-sys, and wasm-bindgen to glue it together, making our nodes fully functional in the browser.

Transport Layer

The Transport implementation in Rings Network is based on WebRTC, making Rings Network browser-native.

The browser-native approach means that users do not need to install any additional software or plugins to participate in the network. Furthermore, the use of WebRTC and WebAssembly enables the network to achieve low latency and high throughput, making it suitable for real-time communication and data transfer.

Assuming Node A and Node B want to create a WebRTC connection, they would need to exchange a minimum of three messages with each other:

ICE Scheme:

  1. Peer A: { create offer, set it as local description } -> Send Offer to Peer B
  2. Peer B: { set receiveed offer as remote description create answer set it as local description Send Answer to Peer A }
  3. Peer A: { Set receiveed answer as remote description }

Network Layer

The Rings Network is a structured peer-to-peer network that incorporates a distributed hash table (DHT) to facilitate efficient and scalable lookups. The Chord algorithm is utilized to implement the lookup function within the DHT, thereby enabling effective routing of messages and storage of key-value pairs in a peer-to-peer setting. The use of a DHT, incorporating the Chord algorithm, guarantees high availability in the Rings Network, which is critical for handling the substantial number of nodes and requests typically present in large-scale peer-to-peer networks.

Privacy Layer

The network layer is the communication layer: it routes by DID and, once two peers have exchanged account public keys through the E2E handshake, encrypts a payload to its destination. A DID is the 160-bit digest of a public key, so a lookup yields an identifier to route to and not a key to encrypt to, and every hop sees the origin DID by signature and the destination DID by routing. The communication layer therefore minimizes what it leaks and does not provide privacy.

Privacy is provided by the privacy layer, the onion loops in crates/node/src/onion: client-sealed loops of fixed-width Sphinx cells over direct edges, constant-rate link emission with real cells substituted for cover, paid admission, and fixed cell size classes. Each position peels one layer and learns only its predecessor and its successor; the exit sees the application payload but not the client, and its reply returns through the loop to the client. The contract of each layer, including what loops do not hide, is drawn in the security model.

Protocol Layer

In the protocol layer, the central design concept revolves around the utilization of a Decentralized Identifier (DID), which constitutes a finite ring in abstract algebra. The DID is a identifier that enables the construction of a mathematical structure that encompasses the characteristics of both a group and a field. It is comprised of a set of elements with two binary operations, addition and multiplication, which satisfy a set of axioms such as associativity, commutativity, and distributivity. The ring is deemed finite due to its having a finite number of elements. Finite rings are widely employed in various domains of mathematics and computer science, including cryptography and coding theory.

Application Layer

The nucleus of Rings Network is similar to the Actor Model, and it requires that each message type possess a Handler Trait. This allows for the separation of processing system messages, network messages, internal messages, and application-layer messages.

How handshake works

Rings Network has chosen to use WebRTC as the transport layer, which facilitates the implementation of a purely peer-to-peer (P2P) network. This means that with the help of the WebRTC protocol and WebAssembly (Wasm), Rings nodes can run fully functional within web browsers.

The WebRTC protocol is platform-agnostic and leverages Interactive Connectivity Establishment (ICE) for network traversal. It enables peers to establish direct connections by exchanging Session Description Protocol (SDP) for the purpose of handshake and negotiation.

Rings Network supports two ways for establishing connections: Relay and Direct.

  • Relay: This involves establishing connections through a third-party node, such as a Rings node that has an endpoint service enabled. Using a relay offers convenience in establishing connections.
  • Direct: This refers to establishing peer-to-peer connections by directly exchanging SDP information. Direct connections are more decentralized in nature.

Both approaches have their advantages. Relay connections are more convenient, while direct connections provide a more decentralized approach.

To understand more about how handshake works, you may like to read:

exchange-sdp.md

Exchange SDP

We will explain how the handshake works by establishing a connection between two nodes manually. Each node gets its own directory, delegatee key and configuration, and listens on its own pair of JSON-RPC ports:

NodeInternal API (internal_api_port)External API (external_api_addr)
node150000127.0.0.1:50001
node250002127.0.0.1:50003

The two nodes must not share any listener address: the default external_api_addr is 127.0.0.1:50001, so the second node moves both of its listeners. See the API for what each listener serves.

Prepare two nodes

Create a throwaway delegatee key for each node:

rings new-delegation -s ./node1/delegatee_key
rings new-delegation -s ./node2/delegatee_key

Then write the two configurations. Each lists every key Config requires; every key it omits takes its default (see config.yaml).

# node1/config.yaml

network_id: 1
delegatee_key: ./node1/delegatee_key
internal_api_port: 50000
external_api_addr: 127.0.0.1:50001
endpoint_url: http://127.0.0.1:50000
ice_servers: stun://stun.l.google.com:19302
stabilize_interval: 3
data_storage:
  path: ./node1/data
  capacity: 200000000
measure_storage:
  path: ./node1/measure
  capacity: 200000000
# node2/config.yaml

network_id: 1
delegatee_key: ./node2/delegatee_key
internal_api_port: 50002
external_api_addr: 127.0.0.1:50003
endpoint_url: http://127.0.0.1:50002
ice_servers: stun://stun.l.google.com:19302
stabilize_interval: 3
data_storage:
  path: ./node2/data
  capacity: 200000000
measure_storage:
  path: ./node2/measure
  capacity: 200000000

Alternatively, rings init --location ./node2/config.yaml --delegatee-key ./node2/delegatee_key replaces both steps for a node: it writes a fresh delegatee key from a random ECDSA key along with a complete configuration, so run it instead of rings new-delegation, not after it. The generated file already points delegatee_key at that key; only the two listeners, endpoint_url and the two storage paths need editing.

Handshake !

Launch two nodes:

Run both from the directory that holds node1/ and node2/, since the relative key and storage paths resolve against the working directory:

rings run -c node1/config.yaml
rings run -c node2/config.yaml

Each node creates an owner-only api-token next to its configuration on first start, then prints the token file, its DID and its internal and external JSON-RPC endpoints:

# node1
API authentication token file: node1/api-token
Did: <did1>
JSON-RPC endpoint: http://127.0.0.1:50000
JSON-RPC endpoint: http://127.0.0.1:50001

# node2
API authentication token file: node2/api-token
Did: <did2>
JSON-RPC endpoint: http://127.0.0.1:50002
JSON-RPC endpoint: http://127.0.0.1:50003

createOffer and acceptAnswer are internal methods and need the node’s Bearer token; answerOffer is public on the external listener, which is where a remote peer reaches it.

Create Offer

Then we ask Node1 to create an offer to Node2 by:

curl -X POST \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $(cat node1/api-token)" \
--data '{"jsonrpc": "2.0", "id": 1, "method": "createOffer", "params": {"did": "<did2>"}}' \
"http://127.0.0.1:50000"

The output is the signed offer, which carries the SDP and its ICE candidates, encoded in base58:

{"jsonrpc":"2.0","result":{"offer":"<b58 encoded offer>"},"id":1}

Accept Offer and Create Answer

Then we pass the offer to Node2’s external listener, which needs no token:

curl -X POST \
-H "Content-Type: application/json" \
--data '{"jsonrpc": "2.0",
        "id": 1, "method": "answerOffer",
        "params": {"offer": "<b58 encoded offer>"}}' \
"http://127.0.0.1:50003"

Here we will get answer responses by Node 2:

{"jsonrpc":"2.0","result":{"answer":"<b58 encoded answer>"},"id":1}

When Node 2 already holds its own pending overlay offer to Node 1, the offer handed to answerOffer prevails: Node 2 releases its own and answers. A node of 0.34.2 or earlier returns a counter-offer instead when its offer prevails on the DID tie-break (the smaller DID’s offer prevails). The encoded payload then carries a ConnectNodeSend rather than a ConnectNodeReport: pass it to Node 1’s answerOffer, and the answer it returns to Node 2’s acceptAnswer. The edge is then Node 2’s handshake, and Node 1’s own offer is released.

Accept Answer

Finally, we send the answer to Node 1 to finish handshake:

curl -X POST \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $(cat node1/api-token)" \
--data '{"jsonrpc": "2.0",
         "id": 1, "method":
         "acceptAnswer",
         "params": {"answer": "<b58 encoded answer>"}}' \
"http://127.0.0.1:50000"

It will respond with an empty result, and the connection opens asynchronously:

{"jsonrpc":"2.0","result":{},"id":1}

listPeers on either node then shows the other one:

curl -X POST \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $(cat node1/api-token)" \
--data '{"jsonrpc": "2.0", "id": 1, "method": "listPeers", "params": {}}' \
"http://127.0.0.1:50000"

{"jsonrpc":"2.0","result":{"peers":[{"did":"<did2>","state":"Connected"}]},"id":1}

The rings CLI performs the same three calls as rings connect offer, rings connect answer and rings connect accept; see Operate a Node from the CLI.

Conclusion

So, as you can see here, the handshake process of the Rings Network can be summarized in 3 steps:

  • 1. Creating an offer and sending it to the other party;
  • 2. Accepting the offer and replying with an answer;
  • 3. Accepting the answer and completing the handshake.

Transport flow control

Every connection carries four lanes, each pinned to one ordered data channel, and every lane is credit flow controlled (#924). Receive-side admission is lossless: no honest frame is refused for want of credit or mailbox capacity. Overload reaches the sender as backpressure instead. One hold of core is bounded on its own and does drop: a frame that finds the pre-admission hold full (it arrived before this end admitted the connection) is dropped, uncharged. Two link-level verdicts drop a frame too, uncharged and never for want of capacity: a frame whose delegation reference misses (for an honest sender only past two delivery bounds of transit under a full table), and a frame whose origin delegation expired in flight (see Delegation references on a link).

Credit

A receiver holds at most LANE_CREDIT_WINDOW (16) frames of a lane, so at most INBOUND_PEER_FRAME_CAPACITY (64) frames of at most MAX_DATA_CHANNEL_MESSAGE_SIZE bytes per connection. Every lane has a floor it is always granted: the priority lane its whole window, each data lane one frame. Beyond its floor a data lane borrows from the node’s frame pool, up to the window (see below). The receiver advertises credit, as a cumulative Credit(limit, generation) signal, as the protocol takes frames over, and at once when the pool lends a lane more. A sender holds a custom frame until its lane has credit. A received signal is applied on arrival, never behind received data, and is not itself charged. On the sending side a signal goes out on its own lane, so it may wait behind up to a window of this end’s committed frames on that lane.

Credit counts frames, not bytes, so it caps each lane’s throughput toward one neighbour at a window per round trip: about 160 small messages per second at a 100 ms round trip (53 at 300 ms), or about 10 MiB/s of 64 KiB frames (3.3 MiB/s). Byte-denominated credit, sent ahead of queued data, would lift both (#937).

The laws, model checked with stateright over every interleaving, with credit, shrink and return signals reordered and duplicated (rings-transport, core::credit):

  • an honest sender never exceeds the advertised credit;
  • a lane’s occupancy, and its counted liability (held, in flight, or credited and unused), never exceed its window, and its share never exceeds the pool;
  • an honest sender is never blocked forever while the receiver keeps consuming, makes every advertisement due, and resends every signal whose send failed;
  • one lane’s backlog never blocks another lane.

A frame beyond the advertised credit is a protocol violation: it is refused, reported to the callback as an invalid frame, and logged.

What a node holds of received frames is bounded hard, whatever the number of connections (#934; load-bounds paper, Law 3):

  • Floors and the pool. A connection’s lanes always hold their floors: 16 frames on the priority lane and one on each data lane. Beyond its floor, a data lane is lent credit from the node’s frame pool (NodeFramePool, 256 frames, 16 MiB): the admission of a data lane’s frame is its demand, served first come, first served, up to the lane’s window and within its edge’s fair claim, ⌈256 / n⌉ frames with n the edges holding or waiting for pooled credit. When an edge below its share, ⌊256 / n⌋, finds the pool full, every edge above its share is shrunk to it. So a busy edge keeps at least min(45, ⌊256 / n⌋) pooled frames, its three data lanes’ whole windows while at most five edges are busy, however busy the others are. With connection admission keeping at most 2 × (160 + successors + 1) connections (328 with the default 3 successors), a node holds at most 328 × 19 + 256 = 6 488 frames, about 405 MiB of 64 KiB frames, against about 1.3 GiB before.
  • Reclaim. A data lane that holds a share of the pool and admitted no frame for LANE_IDLE_PERIOD (1 s) is shrunk to its floor: the receiver sends Shrink(limit, generation) on the lane, the sender answers Returned(bound, generation), the frames it committed or will commit at most, and the share goes back to the pool. Credits name their generation, so one of an older generation, still in transit, is ignored. On a link whose round trip exceeds 1 s, a lane paused for one round trip is shrunk and its next burst waits one round trip for pooled credit again; no frame is refused.
  • The priority lane never borrows. Its floor is its whole window, so a peer’s control traffic, and with it every link’s liveness, never waits for what other peers make the node hold. The receiver refuses as invalid a frame whose content belongs to another lane than the one it arrived on (a chunked message by the lane every chunk shared), so the priority lane’s credit carries DHT control alone. Measured over the test suite, one edge had at most 10 priority frames outstanding under asynchronous delivery and 16 in the sync storms, so a smaller floor would make control wait; bursts above 16 wait for credit as they did before. Counting frames prices a control frame at the 64 KiB a data frame may take; byte-denominated credit would remove that (#937).

Received frames and the mailbox are separate memories: a frame counts here until it is handed to the mailbox, which happens only once its mailbox charge is granted, so inbound memory is at most the frame bound plus the mailbox’s 256 MiB.

A wait for credit is backpressure, not a verdict on the peer: it ends when credit arrives or when the connection generation ends (it fails or closes), and the wait itself never retires a link. A receiver may legitimately consume slowly (its inbound mailbox is busy with other peers’ work), and whether a peer is alive is liveness’s to judge. Liveness reads no credit state; it judges every admitted peer by probes alone (swarm::transport::liveness):

  • every PEER_LIVENESS_PERIOD_MS (15 seconds) after its previous probe, a peer is due a probe, whatever its traffic or credit, and at most one probe per peer is outstanding;
  • a probe’s deadline, PEER_LIVENESS_TIMEOUT_MS (45 seconds), counts from its issue, so a probe the peer will not let this end send, for want of the control lane’s credit, waits in the peer’s own queue and goes unanswered;
  • only the answer to the outstanding probe clears it: the peer’s own traffic proves it can send, not that it takes this end’s control traffic, so a peer that withholds credit cannot escape eviction by sending;
  • the probe rides the control lane, so a peer slow only on a data lane answers it and is kept;
  • a probe takes no shared outbound capacity (one outstanding per peer bounds it), so other transfers waiting on the peer cannot refuse it, and no probe waits on its peer, so one starved peer delays no other peer’s probe. Its transfer waits for credit until the probe’s own answer deadline, not the 25-second first-frame deadline of other protocol sends;
  • a probe that could not reach the queue (no ready connection, or a step cut short) stays outstanding, with its deadline still counted from its first issue, and is sent again on the next pass, never withdrawn, so a peer gains nothing by keeping its connection unready;
  • a probe’s answer takes no shared capacity either while its next hop holds no other answer in flight (one per next hop); a further answer to the same hop falls back to the shared capacity, as any protocol send.

The outbound worker holds a lane’s credit only while a transfer of that lane awaits it, so no credit lingers at the sender after its transfer went away. A peer that does not progress this end’s control traffic is therefore evicted within the period plus the deadline, and its eviction fails every wait on its credit. A composed model (liveness::test_composed_model) checks these laws over credit, lanes, the application inbox and a third peer, and refutes each design they replace.

A wait that pins one of the sender’s budgets is bounded by what that budget is for: a link-control frame, which holds a link-control permit, waits at most one delivery bound, and is then dropped, since the next frame that misses or teaches its delegation repeats it; a scheduled transfer is bounded by its own send deadline.

A remote-created data channel is admitted only by the lane its label names, and only once per lane.

Backpressure

  • The core inbound mailbox makes an arrival wait rather than refuse it, while the arrival keeps its credit. Waiting arrivals are admitted in resource order (fair_admission::ResourceOrderedQueue): first come first served per exhausted resource (a peer’s budget, a lane’s share, the shared pool) and per peer, lane and transport carrier. A frame within its lane’s reservation, such as DHT control, therefore never waits behind another lane’s borrower, and one peer at its budget never holds back another peer. An arrival waiting only for room in its lane’s reservation (or in its slot’s floor of the open-message table) holds that room, so a later, smaller frame does not pass it there (#938). An event judges only the waiters its freed resources may unblock, through an indexed waitlist. The order is model checked: no overtaking on an exhausted resource, stream order, no admissible arrival left waiting, and every wait resolved.
  • A chunked message is charged once, at its first chunk [0, n] of d₀ bytes, for the n·d₀ its buffer and its completed copy take (#932; load-bounds paper, Law 4). That charge sits on the lane of the message’s class and in the open-message table, by the connection slot that delivered it: each slot has a floor of 256 KiB, and larger messages share a pool of 128 MiB in fair claims, so open messages hold at most 210 MiB of the 256 MiB mailbox and a small message never waits behind another peer’s large one. The charge waits as an arrival like any frame’s; the message’s later chunks take nothing, and they wait only for their own carrier’s earlier frames, so they never wait behind capacity their own message holds. The completed message leaves the table and carries its charge into dispatch: no reassembled message is ever refused or dropped for capacity.
  • An open message must keep up a rate floor of 10 B/ms (ρ, 60 MB over the 100-minute maximum TTL) over the time its sender holds unused credit on the carrier. Credit the receiver holds back is not counted, so a throttled sender’s clock pauses. A message that falls more than one lane window behind is expired as the sender’s failure and releases its charge: a first chunk sent alone is expired within about 107 s, not at its TTL. The receiver cannot tell a withholding sender from one whose scheduler is busy with higher-priority classes, so a large message on a link that leaves its class less than ρ is expired too, and is the sender’s to send again.
  • Core’s outbound scheduler serves only lanes that hold credit, and times a send only for the transport’s acceptance, never for the receiver’s consumption.
  • An inbound lane’s progress depends only on local resources (swarm::transport::egress). Every send made in the protocol context (a handler’s forward, report, query or notification, a connection offer it starts, a read repair, a relay hold, a liveness probe or its answer, and maintenance’s queries) goes through a protocol egress, the only senders handlers and maintenance can reach; the waiting discipline is constructible only at the Swarm API. A protocol send takes the connection’s readiness and its outbound capacity now (lacking either, it is dropped as local backpressure), returns once queued, and so frees the inbound event and lane that carried its cause. The next peer’s backpressure never reaches upstream links, and neither does a liveness verdict.
  • A connection a handler starts or answers is reserved and created at once, and its offer or answer, whose ICE gathering waits on STUN or TURN, is negotiated and sent apart from the event.
  • A protocol payload’s first frame’s admission, credit included, is awaited apart from the event, at most the first-frame deadline (25 seconds; a liveness probe, its own answer deadline), and a payload that waits longer is dropped, as a best-effort relay may drop; the origin’s own tracked send or retry covers it. A send through the Swarm API, the application boundary, waits for its first frame’s admission, so its caller learns whether it left.

Application inbox

Core never calls, awaits or waits on application code (#935). The application reads its data and peer facts from one bounded inbox (ApplicationInbox::next, or try_next without waiting) at its own pace, and no inbound lane ever waits for it to read:

  • Application delivery is lossy by design. A live application message that does not fit the full inbox is dropped, counted (ApplicationInbox::dropped) and logged at warn, as a best-effort network drops it, and nothing tells its sender. A drop of a message from an admitted peer is told to the application itself, as SwarmEvent::MessageDropped (one per peer while it is unread), so a protocol whose state rides that link, such as an onion session, can end that state instead of losing a cell silently (#969). A protocol that needs a reliable stream detects loss end to end: the TCP relay numbers its segments and resets a stream whose gap outlives REORDER_DEADLINE (four delivery bounds: a superseded path with at most four hops left to cross) or the replay window, closing the local socket abortively, and the E2E stream decryptor fails a stream that made no progress for its stall deadline (#939). The bound, APPLICATION_INBOX_WEIGHT (64 MiB), weighs each message by its own size plus the byte length of its data, one rule for every kind, so empty messages are bounded too.
  • Before the application claims the inbox, live messages are discarded and peer facts are kept, so a late reader still learns the peers already admitted. Once the inbox is dropped, everything is discarded.
  • Messages and peer facts share one queue, in the order core produced them. An unread PeerAdmitted is withdrawn by its PeerRetired, together with the drops after it, and a drop is told once while one waits, so a peer has at most four facts queued ([MessageDropped] [PeerRetired] [PeerAdmitted] [MessageDropped]).
  • Mail stored for this node in its relay inbox is offered only through a reservation of room in the inbox, and retired from storage once the application has taken it from the inbox. Taking ends core’s custody: what the application does with the message afterwards is its own (the node’s Backend, below, may drop it beyond its namespace queues). Mail that finds no room or no reader, or whose origin’s quota is spent for now, stays stored for a later pass; an origin with a spent quota does not hold back the others. Delivery is at least once within a run: a restart between the taking and the retirement loses the mail, as the replay window refuses its redelivery (#936).

The node’s Backend reads the inbox and dispatches each registered namespace on its own queue, bounded by 256 envelopes and by 16 MiB of encoded envelopes (an empty queue admits any one envelope), in arrival order, so one slow protocol holds back neither another namespace nor the peer facts. An envelope that cannot be decoded, names no registered namespace, finds its queue full or its namespace unserved is dropped, logged at warn and counted by reason (Provider::dispatch_drop_counters, FFI dispatchDropCounters). Protocols are registered before the inbox is claimed: from the claim on, an envelope of a namespace not yet registered is dropped. Facts and E2E messages reach the BackendObserver at once, so they may overtake custom messages still queued for a namespace. The FFI provider’s E2E event buffer is likewise bounded (4096 events) and drops further events, counted (takeE2eEvents reports dropped).

Every such wait is logged at warn, with the peer and lane: a sender waiting for credit, and an arrival waiting for the mailbox.

DHT - Network Layer


description: The Chord Algorithm

DHT

The Rings Network leverages the Chord algorithm for its DHT implementation. The Chord algorithm is a protocol for lookup in a peer-to-peer distributed system that allows nodes in the network to find the location of data. It enables effective routing of messages and storage of key-value pairs in a peer-to-peer setting, guaranteeing high availability in the Rings Network.

In the Rings Network, the network is organized as a ring topology, where each node is linked to two other nodes. This design maximizes the functionality of the Chord algorithm for quick and effective participant lookup and location.

DHT

A DHT is a class of decentralized distributed systems that provide a lookup service similar to a hash table; key-value pairs are stored in a DHT, and any participating node can efficiently retrieve the value associated with a given key. The main advantage of a DHT is that nodes can join or leave the network with minimal disruption.

Chord

Chord is a protocol and algorithm for a peer-to-peer distributed hash table. It specifies how keys are assigned to nodes, and how a node can discover the value for a given key by first locating the node responsible for that key.

Chord organizes nodes into a circular ID space, often visualized as a ring. Each node in the network has a unique identifier, and each data item identified by a key is assigned to the node whose identifier is closest to the key (according to a certain distance metric). When a node needs to find the value associated with a key, it uses the Chord protocol to locate the node responsible for that key. This is done using a “finger table” that each node maintains - a sort of routing table with pointers to other nodes in the network. This structure allows for efficient routing of queries to the appropriate node.

Chord provides a fast, distributed lookup method that scales logarithmically with the number of nodes. That is, even as the network grows large, the number of steps required to find the node storing a particular key remains relatively small. This makes it very efficient for large-scale distributed systems, where resources might be spread across many nodes and those nodes may be constantly joining or leaving the network.

Correct Chord

Correct Chord is derived from Pamela Zave’s work on Chord, and it encompasses two core principles:

  • Chord must be initialized with a ring containing a minimum of r +1 nodes, where r is the length of each node’s list of successors. In fact, to be proven correct, a Chord network must maintain a “stable base” of r + 1 nodes that remain members of the network throughout its lifetime.
  • The Chord Paper defined the maintenance and use of finger tables, which improve lookup speed by providing pointers that cross the ring like chords of a circle. Because finger tables are an optimization and they are built from successors and predecessors, correctness does not depend on them.

Rings Network builds upon Correct Chord and incorporates several modifications, including support for multiple successors and improved stabilization algorithms, among other enhancements.

Delivery toward a node

Finding the owner of a key and delivering a message to a node are different questions, and Rings answers them with different rules. The owner lookup (find_successor) answers head when the key lies between a node and its successor head, and crossing the key is correct there: the owner is the first node at or after it. A node is not a position, so a message for a node must never pass it.

Delivery (dht::delivery) works as follows:

  1. Greedy. Each hop forwards to the known peer (successor or finger) that lies between itself and the destination and is nearest the destination. The clockwise distance strictly decreases, so no route can cycle. A hop linked to the destination delivers directly.
  2. One handoff. When no linked known peer lies on that arc, the hop hands the message once to the first linked known node after itself, which lies past the destination, and marks the carrier. A node with a sparse view, such as a leaf linked only to its guard or a joiner linked only to its bootstrap, hands off on its first hop, and the receiver’s fuller view routes on greedily. A second crossing is refused and ends the route with a typed error (RelayDestinationUnreachable), so repeated crossings can never circle the ring.

A message for a node that no view reaches therefore fails fast, within at most 2|V| hops, instead of circling the ring until its hop budget runs out; it is delivered once the ring has converged. Greedy hops choose only peers this node is linked to, because the successor list may name peers a stabilization report introduced before any connection to them exists.

A node without a predecessor is the one case that cannot wait for convergence: a joiner needs the answers to its own join lookup and connection offer to converge at all. Whenever a node has no predecessor, its requests carry a signed reply_via naming its nearest linked successor, which is its bootstrap during a join. Successor answers (FindSuccessorReport) then travel to that peer, which hands them over its direct link. Every other report goes straight to the origin, so reply_via reflects at most one bounded answer per request. A connection answer needs neither: it retraces the path its offer recorded, as the next paragraph describes.

Connect signaling does not wait for convergence either (#947). A ConnectNodeSend records the relays it crosses, outside every signature and bounded by the hop budget, and its answer retraces them, each hop a link the offer itself just crossed, instead of being routed afresh over a view that may have just dead-ended. When two nodes offer to each other at once, an offer still being created yields, since nothing has crossed yet. Of two sent offers the smaller DID’s prevails: the larger DID abandons its own and answers, and the smaller DID refuses the larger’s and sends its own offer back along the refused offer’s path, under the transaction it first left under. The answer echoes that transaction and applies only to the offer that left under it. The edge is therefore established whenever either overlay offer is delivered.

Data-topic storage

A data topic is a replicated set of elements (a state-based CRDT): each element carries the dot of the write that issued it, and replicas and hand-offs merge by join, so a replica’s value does not depend on the order writes and hand-offs arrive in. A reader’s fetch cache merges too: it joins every reply it receives (#864), so a late reply from a stale replica cannot replace a fresher one.

A write returns once it has been applied locally or has left for each of its replicas, not once a remote replica has applied it. Which node owns a replica follows each node’s view of the ring, and that view changes as peers join and as stabilization runs. An owner hands its replica to a new owner only in its next storage repair round, so a lookup made just after a write can reach a new owner that does not hold it yet. The writer itself does not depend on that: it applies its own write to its fetch cache, as every owner applies it, so it reads its own writes at once (#982). It also stamps each write after every write it stamped before and every version it read for the key, at most the 3 s clock tolerance ahead of its clock (#1033, #1075), so its successive writes to a topic are listed in the order it issued them, even two in one millisecond or across a step back of its clock. Any other node reads the write once its lookup reaches an owner that holds it. That guarantee is about the writer’s own view only, not proof that the write was replicated: an owner can still refuse or lose it after the write returned. So a writer must never use its own read-back to check that something was published, such as a registry being ready (#866); only another node’s read shows that an owner holds the write.

  • Every element expires individually. An element expires at the earlier of two instants: 100 minutes after its dot was issued (the element horizon H, the longest retention one write may request), and its topic’s retention bound, the latest bound any write joined into the topic requested (a plain write, such as publishMessageToTopic or registerService, requests 10 minutes; an extend, storage_extend in core, requests the lifetime its writer names, up to H, for several elements in one write, as the mail gateway does). Writing the value again issues a fresh dot and a fresh bound and is what keeps it: a sole writer rewrites each value within 10 minutes, and every writer within H, even while other writes keep the topic alive. The online-node, onion-exit and DNS-relay registries re-append their descriptor every heartbeat (30 s by default): a signed 10-minute lease whose bytes stay fixed, so a heartbeat raises its dot and writes no tombstone (#918). Once the topic’s bound has elapsed, it serves no element; it is kept only while it holds a tombstone or overwrite that is not yet collected, and it answers lookups as absent, from a replica and from a reader’s fetch cache alike, so a reader asks the next replica. Its tombstones still spread by hand-off, by republish, and by read-repair from the reader’s cache, which repairs a missed replica from the carrier it holds even past its bound.
  • A removal wins over every earlier write of its value. A tombstone names the value and the newest dot of it the storage owner holds, and covers every dot of that value up to that one, including dots already forgotten under a later write. “Earlier” is the dot order, not real time: a rewrite of the value by a writer whose clock runs behind the removed write’s writer (by at most σ) is issued below the tombstone and stays removed. A removal carries no dot of its own, so each owner removes the dot it holds when the removal arrives, and a removal delivered after the same client’s later rewrite removes that rewrite too.
  • Removals are collected by the clock, not by a reset. A tombstone is collected H + σ after the dot it covers, where σ (3 s) bounds clock skew between nodes, and an overwrite’s reset H + σ after it was issued. By then every copy of every write they shadowed has expired on every node, so no stale replica can bring the value back, and a topic that holds either stays stored until it is collected. Tombstones live in a store of their own that never evicts (#920): the data store’s byte budget drops the values of a topic, never its tombstones, and each tombstone is charged to the connection slot of the connection that delivered it, or to the node for its own writes and repairs (#907): each slot has a floor of 128 uncollected, the rest share a pool of 16384 in fair claims, and the node keeps 7168 of its own. A hand-off of the range a node now owns, from the node its view names as the range’s previous owner, draws on a custody share of 16384 instead, so a busy range is never capped by one neighbour’s slot. No number of writers or connections crowds out a slot below its floor. There is no compaction operation: a reset stamped by one owner would erase every concurrent write that owner had not yet received.
  • Bounds. A topic holds at most 1024 elements (the oldest are dropped when the cap binds) and at most 1024 tombstones not yet collected (#921). The tombstone bound refuses, it never drops: a removal, a replication, or a repair whose result would hold more is refused and changes nothing, since a dropped tombstone could still cover a live write somewhere, and a delta carrying more is not admitted. This admits about 10 removals a minute per topic, sustained; an application that removes faster shards its topic (each value to one of k topics by its hash). A full topic, removals included, encodes in at most 33.83 MB, which fits one transport message. Registry heartbeats remove nothing, so a registrant leaves at most 13 descriptors (one per lease) within the horizon and no tombstone; the cap drops superseded leases first, since no heartbeat refreshes their dots.

An overwrite (the browser provider’s storage_store, or storage_store on a node) still replaces a topic: it drops every element and tombstone older than itself, and its own elements expire like any other.

Relay inbox

A message for a peer that has no connection is held for it: the node responsible for the peer’s position (the first node at or after it) signs the message and writes it into the peer’s inbox, a relay carrier at the key peer + 1 in a store of its own. Relay mail is kept only in a retained relay store (#1021), so a node built or run without one (a browser whose IndexedDB cannot open, a library node built without one) declares, in every connection handshake and in its online-node descriptor (relay_custody), that it holds no relay mail. Such a node still joins the ring, routes, sends and receives online messages, and owns ordinary DHT data; only relay custody moves past it (#1037).

A held message expires 7 days (MAX_RELAY_INBOX_TTL_MS) after the instant its holder signed, however long newer holds keep its inbox alive (#907): every read, join and sync drops it, and a recipient never delivers it. The rule reads the signed hold instant alone, so every replica drops the same messages and a member cannot extend one by writing it again.

The holder of an inbox key is the first node at or after the key’s owner, along the successor path, that holds relay mail:

holder(k) = first n in [owner(k), succ(owner(k)), succ²(owner(k)), …] with holds(n)

One pure function, dht::relay_holder::relay_holder, decides it on each node’s view, and every relay path uses it: the hold, the recipient’s removal of what it delivered, the hand-off join and its acknowledged cleanup, the repair pass that lists what to hand off, and the recipient’s drain. So a writer and a reader whose views cover the same nodes always name the same holder.

The carrier is replicated to the key’s members (#777, durable-records Section X): the first r nodes from the key’s owner that hold relay mail, the holder first, r configurable and 8 by default (RelayReplication). The members form a chain in ring order, and a held message is lost only if every member holding it is lost within one repair delay (Propositions 10 and 11), so a member’s tab closing or its private session ending costs nothing while another member survives.

  • Age gate. A node with a relay store declares that it holds relay mail only once it has been online for 2 minutes (at least four stabilization periods). Until then it routes and fetches its own mail, but is no member, so a node that leaves within its first minutes never pays a member’s copies. When its gate opens it declares the change to its connected peers (DeclareRelayCustody); later peers read it in the handshake.
  • Chain replication. The holder takes a hold and forwards it byte for byte to its next member (ReplicateRelayHold), which forwards it on to the chain’s end. Acknowledgements return from the end, each member acknowledging the member it came from once the rest of the chain did, and the holder acknowledges the writer last: an acknowledged hold is held by every member, r of them, or every holder of a ring with fewer. A forward sent again, on the writer’s resend, carries the hold’s digest alone.
  • Next member. A member’s next member is the first holder after it. It is in the member’s successor list, or past it: then the member asks the owner of the key just past its list (FindRelayHolder), which names the first holder at or after it, or the last node it places to ask past again, and the member caches the answer until its successor list changes.
  • Chain repair. Every carrier a member keeps has the same chain neighbours, its previous holder p₁ and its next holder n₁, but at the chain’s ends, so each repair pass sends one digest set to each (RelayCarrierDigests), and a neighbour asks only for the carriers it lacks or holds otherwise (RelayCarriersWanted): at most two repair messages per node and pass, whatever the carriers it keeps. A node that keeps a carrier and is no member any more hands it to the members and deletes its copy only after a member acknowledges holding it.
  • Event-driven repair. The loss of a chain neighbour, by a closed connection or a failed liveness check, wakes a task beside the maintenance loop that runs the chain exchange at once, outside the repair schedule’s admission window, which guards bulk storage sync and not these two digest messages. So a lost member is replaced within its detection time plus r hops; the periodic pass is the backstop.
  • Chain liveness. A member probes its two chain neighbours every 2.5 s, on the same probe clock as every peer, and suspects one whose probe is 7.5 s unanswered (#1070). A suspect is no chain member until it answers on time twice in a row, and both the suspicion and the clearing run the chain exchange at once; suspicion evicts nothing, eviction stays at the 45 s liveness deadline. So a silent loss is detected within 10 s, and a slow but live neighbour costs a chain shift and back, never a copy: a neighbour whose answers hover around 7.5 s changes the chains at most twice per 12.5 s. The probes cost each node four messages per 2.5 s, whatever r.
  • Who is a member. A node decides membership and its rank on its own view: it is a member of k iff k lies in (succ(p_r), succ(self)], p_r the r-th previous holder behind it. A copy is admitted only from its previous or its next member.
  • Fetch and removal. A recipient that holds relay mail reads its inbox from its own store; one that does not fetches it from its members, the holder first and then down the chain to the next it can reach (FetchRelayInbox, answered only to the recipient itself), and removes what it delivered at the member it read it from. No report restricts relay custody: a lagging member’s copy, or a new holder’s before its hand-off lands, may miss an element that still exists (#1059).

A view covers the node’s predecessor, the node, and its successor list. A holder also needs the keys owned on the storage-less runs behind it, whose owners lie behind its predecessor: every node passes to its successor head, each repair pass, the list of its r nearest previous holders (itself and the nearer ones its predecessor passed it when it holds relay mail, otherwise what its own predecessor passed it), and takes that list from its predecessor alone. The list crosses each run one link per pass, so every holder learns where the runs behind its previous holders start, however long they are. A list that comes round the ring names every holder, and the view then searches round the ring for members.

  • No holder, no hold. When no node from the key’s owner to the end of the writer’s successor list holds relay mail, the writer refuses the hold itself with RelayHolderUnavailable. A node that receives a relay carrier its own view does not make it hold refuses it with RelayCarrierNotHeldHere rather than pass it on, so two views that disagree cannot bounce it.
  • Kept until acknowledged. A writer keeps every hold it sends until the holder it sent it to acknowledges taking it (RelayCarrierHeld), and sends every hold still kept again on each repair pass, to the holder its view then names, until a holder takes it or the message’s sender proof expires (10 minutes by default). A resend is byte-identical, so a holder that took it already only acknowledges it again. A refusal (RelayCarrierRefused) only lets the writer send sooner, so a lost acknowledgement or refusal costs a resend, never the mail. Both name the hold by its digest and count only from the node it was sent to. A writer keeps at most 64 holds unacknowledged and refuses another to its local sender (RelayHoldsUnacknowledgedFull) rather than forget an earlier one.
  • Convergence. A peer’s declaration arrives in its handshake, before it is admitted, so a view places every neighbour with its declaration. The previous-holder lists settle within r(L + 1) repair passes after a membership change, L the longest storage-less run. Until then a hold lands where repair moves it on, or is refused and sent again, so no mail is lost in the window, including when several relay-capable nodes join one long run within it (#1045).
  • Who may hold. The holder admits a hold only from the node responsible for the destination as its view places it: its successor head for a destination it owns, itself for a destination in (predecessor, self], and otherwise a storage-less node of the run at or after the destination.
  • Membership changes. The holder moves only when the first relay-capable node after the owner changes. A storage-less node joining or leaving between the key and the holder moves nothing. A storage-less node joining as the key’s owner moves custody forward to the next holder; a relay-capable node joining inside a run takes custody back from the holder after it. Each move is an ownership hand-off acknowledged by the new holder before the old one deletes. A carrier whose owner the old holder cannot place is offered to both ends of its view and to the holder the key’s owner names: the owner always places its own keys, so the old holder asks it (FindRelayHolder, #1045). The new holder takes a carrier only for a key its own view makes it hold, from a node that holds relay mail: custody can jump between holders that do not know each other, as when a holder gives back the keys of a run a relay-capable node joined.

Finger-table convergence

Algorithmic basis and Rings policy

Chord defines finger slot i as the successor of local + 2^i. The batching lemma Rings relies on is Chord’s own: in the SIGCOMM 2001 paper, section 4.4, init_finger_table (Figure 6) checks whether finger[i].node is also the correct finger[i+1] entry, which holds exactly when no node lies in finger[i].interval; one lookup therefore answers every consecutive slot whose target is no farther than the returned successor, and the paper derives its O(log N) expected-lookup bound from that check. The same section’s advice to copy a neighbour’s finger table as initial hints is the origin of the inferred-hint model below. The ToN 2003 revision dropped both in favour of randomized fix_fingers. Rings-specific are only the convergence state machine around that lemma: UUID correlation, per-slot evidence epochs, cyclic slot selection, admission leases, exponential retry, jitter, and browser lifecycle handling. Pamela Zave’s Chord correctness work supports the separation used here: ring correctness depends on a valid successor structure; fingers are replaceable routing optimization.

Rings treats entries learned from admission, successor changes, and peer removal as routing hints, not as proof that a finger slot is current. A lookup for slot i verifies the consecutive range from i through the highest slot whose target is no farther than the returned successor. This reduces sparse-table convergence from one lookup per bit to one lookup per distinct successor range. Lookups are issued for unverified ranges in cyclic order, resuming after the range the previous attempt proved or failed, so a range whose successor never completes a handshake costs one attempt per rotation rather than blocking every range above it; periodic revalidation of proved ranges is deferred by pending work only while that work is succeeding. A topology change invalidates only slots whose hints changed. A node with no admitted successor keeps its ranges unverified but dormant: temporary isolation is not proof that the global membership set is empty, and the first successor admission activates the pending pass. The current successor’s authenticated stabilization report proves the local successor interval only when it echoes the current stabilization UUID and reports the local node as its predecessor; a superseded or reordered report is ignored, and there is no token-less path by which a report can change the successor list. A new stabilization round is not started while the head’s previous report is still being processed, and a handler releases its claim on every exit path, so a slow candidate handshake neither loses the report nor blocks the next round for longer than the handshake. Successor-list synchronization likewise accepts only one report whose reporter and UUID match an outstanding query to a current successor; unsolicited, duplicate, and superseded reports, and reports from a reporter that has left the successor list, are dropped before they can start connection admission. Both paths revalidate that claim before each candidate: a report keeps its remaining candidate budget while its reporter stays a successor (its own admissions extend the list without revoking it), and loses it once the reporter leaves. One candidate permit already issued before that may still finish its connection and admission; no later candidate from that report is permitted. Candidate lists are deduplicated and ordered by transport quality: successor-list sync admits at most the local successor capacity, while stabilization may additionally admit one predecessor. Those locally proven finger slots never send a lookup around the ring.

Each node keeps at most one finger operation outstanding, either awaiting a lookup report or retaining a timely proof while its returned peer completes transport admission. Reports echo a fresh 128-bit UUID request identifier allocated at the effect boundary. Lookup expiry is checked and the proof is retained before connection setup is awaited, so the shorter lookup deadline cannot expire during the longer handshake. The retained proof has its own admission lease of 210 seconds, longer than the 180-second handshake generation that will own it, so the lease is only a backstop for a proof that never gains a generation; if the handler is cancelled or transport admission never completes, expiry releases the slot and increases the retry backoff. Results from an expired request or from a request invalidated by a topology change cannot overwrite newer state, including after a process restart. A fleet’s first automatic attempt is spread over a node-lifecycle-randomized 10-second per-node phase window, so an identity cannot preselect its time bucket. Repeated stop/listen cycles on the same browser provider reuse that phase instead of rerolling it. When browser suspension leaves a runnable finger deadline at least 10 seconds overdue, the node spreads the stale attempt over a new 1–11 second delay rather than emitting on resume; a retained admission proof stays dormant until that lease expires, and a full provider reconstruction is a new lifecycle and selects new entropy. Listener generations on one browser provider are serialized, so a new listen waits for a stopped generation’s cooperative cleanup instead of running duplicate maintenance daemons. An in-flight lookup exposes its remaining timeout to the listener instead of an absolute timestamp from another clock origin, so repeated listener restarts still wake at the same physical expiry. Send or handshake cancellation, invalid reports, lookup timeouts, and admission-lease expiry increase a progress-sensitive retry floor through 2, 4, 8, 16, 32, and 60 seconds. A topology change invalidates superseded evidence without classifying normal churn as a network failure; each retry is additionally spread across a full jitter window of the same size. Only progress resets that failure level: an applied range proof, or a stabilization report that locally proves the range the active attempt was verifying. Other missed deadlines schedule one future attempt rather than catch-up bursts. Finger convergence may yield to at most two due topology/storage phases before its turn is reserved.

One due node emits one routed lookup rather than a broadcast. Its normal completion is one routed report. Discovering an unconnected result also runs connection admission, which sends a connect lookup and may query the new successor’s bounded successor list; those peers can in turn require connections. The sync-storm gate therefore executes the real effect and transport path and counts the complete causal submission trace instead of multiplying a fixed message-leg estimate. The per-message relay hop budget bounds each carrier, while the retry schedule bounds only the originating node’s finger emissions.

The model and integration witnesses intentionally separate propositions. The finite topology model checks correlation tokens, retry resource bounds, stale proof rejection, and single-use connection permits over an exhaustive action alphabet. The sync-storm fixtures run the production transport cascade and count effects that the finite model abstracts away. Together they document bounded resource behavior; they do not claim all-order convergence for every possible production network schedule.

These are per-node bounds: aggregate healthy bootstrap work still scales with the number of nodes. The phase window smooths a synchronized start but is not an N-independent destination rate limit. The destination’s bounded inbound mailbox limits retained concurrent work; its origin quota remains per origin and therefore must not be counted as a global many-origin convergence cap.

API

A native node serves JSON-RPC 2.0 over HTTP POST / on two listeners. This page is the one reference for its methods; the method set is rings_rpc::method::Method, and every params and result object below is the matching Request/Response struct of rings_rpc::protos::rings_node.

Making requests

curl -X POST \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $(cat ~/.rings/api-token)" \
  --data '{"jsonrpc": "2.0", "id": 1, "method": "nodeInfo", "params": {}}' \
  "http://127.0.0.1:50000"
  • Every request must use Content-Type: application/json.
  • params is one JSON object whose fields are those listed for the method; a method without parameters takes {}.
  • Gated methods require the listener’s credential as Authorization: Bearer <token>. rings init writes the token to an owner-only api-token file next to the YAML configuration, and the rings CLI reads it automatically.

Listeners and authorization

ListenerMethodsAuthorization
internal (internal_api_port, default 50000)every methodBearer required
external (external_api_addr, default 127.0.0.1:50001)nodeDid, answerOfferpublic
external (external_api_addr, default 127.0.0.1:50001)nodeInfo, lookupOnlineNodes, lookupOnionExitsBearer required

The external listener refuses every other method. The public methods are the two halves of the HTTP handshake: nodeDid reveals the DID the caller is about to address, and the offer that answerOffer admits already carries a signature bound to the peer DID. A batch is authorized by its strictest member, so a batch that mixes a public and a gated method requires the token. The node README covers browser origins, remote binding and TLS.

Errors

Errors follow JSON-RPC 2.0:

{
  "jsonrpc": "2.0",
  "id": 1,
  "error": { "code": 500, "message": "Invalid did: ..." }
}
  • -32700 parse error, -32600 invalid request, -32601 unknown method, -32602 invalid params (a missing field, a malformed DID, data that is not base64, a page limit out of range), -32603 internal error.
  • A failure inside the node carries the stable positive code of rings_node::error::Error::code(), whose hundreds name the category: 1xx remote RPC, 2xx connection and handshake, 3xx encoding, 5xx DID, method and permission, 6xx connect, send and DHT entries, 8xx data, storage and configuration, 9xx files, 10xx serialization, 11xx core, 15xx extensions and runtime, 16xx onion.
  • 210 is a handshake with a node of another wire version (see Wire version). Its data names both: {"ours": 1, "theirs": 2}, with theirs null for a node from before wire versioning. A client matches the code and the data, never the message.

Wire version

Every frame a node sends, and so every offer and answer of a handshake, opens with the node’s wire version, a number that goes up by one per release, relative to the last released wire version, when the release changes anything on the wire that a node of that version would reject or misread. It is independent of the crate version. Two nodes of different wire versions never connect: each refuses the other’s offer or answer before decoding it, with error 210 over JSON-RPC. A client learns a node’s wire version before joining it from the wire_version of its public nodeDid, and its crate version from the x-node-version header of every API response.

Methods

MethodParamsResult
nodeDid—did, wire_version
nodeInfo—version, swarm
connectPeerViaHttpurl, api_token?did
connectWithDiddid—
connectWithSeedpeers—
listPeers—peers
createOfferdidoffer
answerOfferofferanswer
acceptAnsweranswer—
disconnectdid—
sendBackendMessagedestination_did, namespace, data—
sendE2eHandshakedestination_didtx_id
sendE2eMessagedestination_did, recipient_public_key, data, max_plaintext_frame_len?stream_id
publishMessageToTopictopic, data—
fetchTopicMessagestopic, skipdata
registerServicename—
lookupServicenamedids
lookupOnlineNodesinclude_expired?nodes
lookupOnionExitsservice?, include_expired?exits
peerMeasurementdidmeasurement
listPeerMeasurementslimit?, cursor?measurements, next_cursor

A field marked ? may be omitted. A method whose result is — returns {} on success.

nodeDid

The DID of this node. Public on the external listener.

  • Params: {}.
  • Result:
    • did: the node’s DID as a hex string;
    • wire_version: the node’s wire version; absent from a node before wire versioning.
{"jsonrpc": "2.0", "id": 1, "method": "nodeDid", "params": {}}
{"jsonrpc": "2.0", "id": 1,
 "result": {"did": "0x11e807fcc88dd319270493fb2e822e388fe36ab0", "wire_version": 2}}

nodeInfo

The node’s version and an inspection of its swarm.

  • Params: {}.
  • Result:
    • version: the node version;
    • swarm: null, or
      • peers: as in listPeers;
      • dht: {did, successors, predecessor, finger_table_ranges: [{did, start, end}]};
      • persistence_storage, cache_storage: {items: [{key, value: {did, kind, data}}]}, each element of data the base64 of its bytes.

connectPeerViaHttp

Connect to the peer behind a JSON-RPC endpoint by the HTTP handshake: this node calls the remote nodeDid, sends it an offer with answerOffer, and accepts the answer. A peer whose nodeDid states another wire version, or none, is refused with error 210 before any offer.

  • Params:
    • url: the remote endpoint, normally its external listener;
    • api_token (optional): a Bearer token for a remote endpoint fronted by a proxy that demands one; absent by default, since the handshake is public.
  • Result: did, the DID of the peer the handshake reached.
{"jsonrpc": "2.0", "id": 1, "method": "connectPeerViaHttp",
 "params": {"url": "https://node.example:50001"}}

connectWithDid

Connect to a peer by DID, signalling the offer and answer through the DHT. The node must already be on the ring.

  • Params: did.
  • Result: {} once the offer is sent, or at once when the peer is already connected; the connection opens asynchronously.

connectWithSeed

Connect to a set of seed peers by the HTTP handshake. The set is validated before any entry is dialled, so an invalid or ambiguous document dials nothing, and a peer listed twice is dialled once. A peer already connected or being connected is skipped.

  • Params: peers, a list of {did, url, api_token?}; the handshake fails unless the endpoint url answers as did.
  • Result: {} when every owed dial succeeded.

listPeers

The peers this node has connections to.

  • Params: {}.
  • Result: peers, a list of {did, state}, where state is the connection state the swarm reports.

createOffer

Create an offer for a manual handshake with did. Deliver it to the peer out of band; the peer passes it to its answerOffer.

  • Params: did, the peer that will answer.
  • Result: offer, the encoded signed offer.

answerOffer

Answer an encoded offer. Public on the external listener.

  • Params: offer, as returned by createOffer. An offer of another wire version is refused with error 210.
  • Result: answer, the encoded answer. The offer prevails over this node’s own pending overlay offer to the offering peer, which is released. A node of 0.34.2 or earlier returns that offer instead, as a counter-offer, when it prevails on the DID tie-break: the caller answers it with its own answerOffer and returns that answer with this node’s acceptAnswer.

acceptAnswer

Accept an encoded answer to an offer this node created, which opens the connection.

  • Params: answer, as returned by the peer’s answerOffer.
  • Result: {}.

disconnect

Close this node’s connection to did.

  • Params: did.
  • Result: {}.

sendBackendMessage

Send an extension message to destination_did, routed to the protocol namespace there.

  • Params:
    • destination_did;
    • namespace: the extension protocol namespace;
    • data: the payload bytes, standard base64.
  • Result: {}.

sendE2eHandshake

Ask destination_did for its end-to-end encryption public key.

  • Params: destination_did.
  • Result: tx_id, the transaction id of the handshake request.

sendE2eMessage

Send bytes to destination_did encrypted end to end under recipient_public_key.

  • Params:
    • destination_did;
    • recipient_public_key: base58-check, or hex;
    • data: the plaintext bytes, standard base64;
    • max_plaintext_frame_len (optional): the plaintext frame length; 0 or absent is the core default.
  • Result: stream_id, the id of the encrypted stream.

publishMessageToTopic

Append a text element to the DHT data topic topic.

  • Params: topic, data (UTF-8 text).
  • Result: {}.

fetchTopicMessages

Read the elements of the DHT data topic topic.

  • Params: topic, skip (the number of leading elements to omit).
  • Result: data, the remaining elements as UTF-8 text; an element that is not UTF-8 is omitted, and a topic not found has no elements.

registerService

Register this node as a provider of the service name: append its DID to the topic name.

  • Params: name.
  • Result: {}.

lookupService

The providers registered for the service name.

  • Params: name.
  • Result: dids, the registered DIDs.

lookupOnlineNodes

The signed descriptors of the online-node registry.

  • Params: include_expired (optional, default false).
  • Result: nodes, a list of descriptors with did, public_key, delegatee_public_key, node_type (Native, Browser or Ffi), network_id, storage_redundancy, dht_virtual_nodes, capabilities ({onion_relay, holds_relay_mail}: the relay’s process epoch or null, and whether the node keeps a relay store and so holds relay mail), endpoint_hint, started_at_ms, heartbeat_at_ms, fresh_for_ms, expires_at_ms, version and signature. A descriptor is a signed lease (#918): expires_at_ms is the lease end and fresh_for_ms the freshness window, both signed, and heartbeat_at_ms is when the serving node last heard it (its registry element’s dot), which is not signed. A descriptor is live while the time is at most the lease end and at most heartbeat_at_ms + fresh_for_ms plus the 3 s skew tolerance; a lease ending more than 10 minutes and 3 s ahead, or a window over 10 minutes, is never live.

lookupOnionExits

The signed onion-exit descriptors.

  • Params:
    • service (optional): tcp or https; empty or absent means every service;
    • include_expired (optional, default false).
  • Result: exits, a list of descriptors with did, public_key, delegatee_public_key, process_epoch, node_type, network_id, service, policy ({allowed_targets, denied_targets, max_sessions, max_bytes_per_minute}), started_at_ms, heartbeat_at_ms, fresh_for_ms, expires_at_ms, version and signature, read as in lookupOnlineNodes.

peerMeasurement

This node’s local measurement of one peer.

  • Params: did.
  • Result: measurement, null when the peer was never measured, or
    • did;
    • counters: {connected, disconnected, sent, failed_to_send, received, failed_to_receive};
    • credit: null, or {bytes_sent_to_peer, bytes_received_from_peer, last_seen_seconds, score}, with score in [1, 10];
    • reliability: healthy, unknown or degraded.

listPeerMeasurements

One page of this node’s peer measurements, in DID order.

  • Params:
    • limit (optional): entries per page, from 1 to 1000; absent means 100;
    • cursor (optional): the next_cursor of the previous page.
  • Result: measurements, as in peerMeasurement, and next_cursor, null at the end.

Delegated Signing

Roles

Rings separates the identity authorizing a signing key from the key that signs individual messages:

  • The delegator is an external account identified by an account DID or verification key.
  • The delegatee is the holder of a generated signing key, identified by the DID derived from its public key.
  • A delegation is the signed proof that binds the delegatee DID to the delegator and a validity period.

The delegator signs the delegation proof. The delegatee key then signs messages while that delegation is valid. The delegator’s private key is not given to the Rings node.

A delegation is not a network identifier. The relay transport’s RelaySessionId identifies a TCP connection or UDP flow scoped to a peer, namespace, and initiator; it does not identify the delegator or delegatee.

External delegators

A delegator can be represented by a secp256k1, secp256r1, EIP-191, BIP-137, Ed25519, or BLS12-381 account verifier. The account algorithm and encoded entity are provided when constructing a delegation builder.

Rust API

DelegationBuilder creates a delegatee key and the corresponding signed Delegation. The caller signs unsigned_proof() with the delegator and supplies that signature with set_delegator_signature():

#![allow(unused)]
fn main() {
use rings_core::delegation::DelegationBuilder;

let builder = DelegationBuilder::new(delegator_entity, delegator_type);
let proof = builder.unsigned_proof();
let delegator_signature = delegator_signer.sign(proof.as_bytes())?;
let delegatee_key = builder
    .set_delegator_signature(delegator_signature)
    .build()?;

let delegation = delegatee_key.delegation();
let message_signature = delegatee_key.sign(message)?;
assert!(delegation.verify(message, message_signature).is_ok());
}

Delegation, DelegationBuilder, DelegationDigest, and DelegateeKey are exported to WebAssembly as well.

Destination-scoped transaction replay protection

Rings 0.24.0 gives a retained final destination an at-most-once dispatch guarantee for exact signed transactions. The guarantee is receiver-local. It does not provide reliable delivery, global ordering, exactly-once application effects, or replay recognition after replay state has been deleted.

Stream and transcript

Replay state is keyed by:

StreamKey = (network_id, origin_delegator_did, destination_did, class)

The origin is recovered from Transaction.verification.delegation.delegator_did(). It is not the delegatee DID and not an intermediate relay. Rotating a delegatee key therefore preserves the same delegator-to-destination stream, while two destinations advance independently.

class is the traffic class (DHT control, storage, E2E, application) of the message the transaction carries, derived from its signed data on both sides, so it adds no wire field (#898). The outbound scheduler keeps order within a class lane only, with at most OUTBOUND_LANE_WINDOW (8, below the 32-slot window) transactions of a lane in flight, and each class lane is pinned to one ordered data channel of the connection:

channel(lane) = pool[lane mod |pool|]
DHT control -> 0, storage -> 1, E2E -> 2, application -> 3

A class’s transactions therefore reach the receiver in the order the lane sent them, and a receive handler stalled on one channel holds only that channel’s class. One stream per class never rejects an honest sender’s transaction as stale, however the lanes are interleaved; a single stream shared by every class did, once one lane’s backlog fell behind another’s traffic, and so would one class spread over several channels, once a stalled channel let later sequences of the class overtake it by more than the window.

No frame is held on the way (#908): a frame whose delegation reference misses is dropped, never delivered late, so the frames of one lane reach admission in arrival order. An honest sender’s reference resolves whenever its transit stays under two delivery bounds, and a frame that waits in its lane at the receiver still resolves however long it waited, so a miss needs a late frame and a full table at once (see Delegation References).

This assumes a class’s transactions reach the scheduler in signing order, as they do from one sending task.

The per-class streams are stored one record per stream (see Persistence and bounds). A store of 0.32 or earlier, which holds one snapshot of streams shared by every class under rings-core:transaction-replay, is not converted: the operator deletes the replay store while the node is stopped before upgrading, which resets every replay window once. An upgraded sender’s per-class sequences are stale to a node that has not upgraded, so the upgrade is network-wide and mandatory.

Every transaction carries a mandatory u64 sequence. The transaction signature transcript binds the receiver-selected network_id through the signing domain and binds destination, tx_id, sequence, and data through the transaction hash. ts_ms and ttl_ms remain session authorization and message-liveness inputs; neither is used to order replay state.

Receiver state machine

One stream retains a fixed 32-slot window:

SequenceState {
    high: u64,
    accepted: [Option<TransactionDigest>; 32],
}

observe(state, sequence, digest) is deterministic and returns one typed verdict:

  • First establishes a baseline when no retained stream exists.
  • Advance moves the window to a higher sequence. A gap is allowed and is not evidence of omission.
  • Late fills an empty slot inside the retained window.
  • Replay rejects the same digest at an occupied sequence.
  • Fork rejects a different digest at an occupied sequence and reports both digests.
  • Stale rejects a sequence below the retained window.

The final destination applies this transition after both transaction and payload signatures verify and before handler effects or the application inbox. An intermediate Chord relay does not read or advance origin replay state. Chunk envelopes retain their existing bounded reassembly/tombstone replay defense and do not advance logical transaction replay state. Each envelope binds the already-reserved logical sequence without consuming another sequence; after reassembly, the original logical transaction is admitted only if that node is its final destination.

At that destination, replay classification and the runtime-local origin rate quota share one serialized admission boundary but remain separate state models. Replay, Fork, and Stale consume no quota; quota rejection leaves the durable replay window unchanged; and a replay-store failure rolls back the provisional quota reservation. The quota clock is read inside that boundary, so the instants a quota record receives follow the order in which admissions hold it and never regress. Quota state is never encoded in a ReplayRecord. The deterministic byte cost is the verified original transaction’s data.len(): normal messages pay it once, chunk envelopes pay nothing, and a successfully reassembled original pays it once before logical lane admission.

Persistence and bounds

The sender never exposes a signature over a sequence its durable record does not cover. It persists a lease, not each sequence (#1043): a reservation that outruns the stream’s lease writes a new one covering at least the next TRANSACTION_SEQUENCE_LEASE (64) sequences, and the reservations inside it are handed out from memory without a write. A stream reserving one sequence per transaction therefore writes once per 64 transactions. A restart resumes past the durable lease, so a crash may leave a gap, of at most 64 sequences for single reservations, but never reuses a sequence. The receiver takes the gap as one Advance: no transaction carries a skipped sequence, since none was signed. The laws, with their proof sketch over every crash point, are in crates/core/src/message/replay/lease.rs. A reservation that would wrap u64 fails closed.

One runtime serializes all writers to its allocator. Multiple devices or processes using the same account and destination must delegate signing to that one allocator; merely pointing independent runtimes at the same key-value store is not an atomic multi-writer protocol. If they do not coordinate, the destination reports their conflicting sequence as Fork. Restoring an identity backup without its allocator state has the same limitation and is unsupported.

The receiver persists every admitted transition before application validation or dispatch. A crash after persistence but before dispatch may lose the event. This is the deliberate persistence-before-dispatch boundary: it prevents duplicate dispatch but is not exactly-once execution because replay storage and application handlers do not share a transaction.

Link-local transactions are the exception (#1081). A transaction is link-local when all three hold: it is Application traffic, it arrived on the authenticated connection of its own origin, and its namespace was registered in advance as delegating its replay (a protocol’s delegates_replay, published with Swarm::set_replay_delegation). Core decides this from the envelope and the connection; it never calls the application. Such a transaction is admitted in a volatile window of its stream on that connection. The window is never written, and it is released with the connection, so it spans one link generation. Every other transaction, a relayed copy included, keeps the durable window. A namespace that declares delegated replay must act on each of its payloads at most once by its own replay key, across connections, generations, envelopes and restarts. The onion data plane does, with its replay store and process epoch, and it is the one namespace that declares it. Only the origin can present a link-local transaction again: its link is authenticated and encrypted, and the transaction names this node as its destination. So the durable window protected nothing there that the declaring namespace does not. The law is the Link-local transactions section of papers/durable-records.tex.

Sender and receiver state are stored as one record per stream:

rings-core:transaction-replay:stream:sender:<hex key>    ->  (key, last leased sequence)
rings-core:transaction-replay:stream:receiver:<hex key>  ->  (key, 32-slot window, relay custody,
                                                             introducer, spent instant)

A transition writes only its own stream’s record, at most TRANSACTION_REPLAY_RECORD_MAX_BYTES (5089 bytes: a 92-byte key, a 1066-byte window, a relay custody of up to 64 deliveries of 61 bytes and a 23-byte charge, with tag and length prefixes), so the cost of an admission does not grow with the number of streams retained. On load, every record must decode and sit under its own key, or the stream it is filed as fails closed (see Bad records). The native daemon opens the replay store as an authoritative file store (#909). On unix each write is flushed to stable storage before its rename and the directory after it (F_FULLFSYNC on macOS), so a crash leaves every record whole at its previous or its new value. Windows cannot flush a directory, so there every rename and removal of the store is write-through (MoveFileExW with MOVEFILE_WRITE_THROUGH, #915); on any other target a rename’s durability is the file system’s own. A record file that cannot be read, that does not decode whole, or that holds a whole record of another key is reported and never deleted. The file I/O runs on the tokio blocking pool, so a flush never stalls an asynchronous worker.

Browser providers open the replay store as an authoritative IndexedDB store, which commits every read-write transaction with durability: "strict" and reports a write only on its complete event (#912). The default hint does not survive a system crash in any engine: Chromium’s default is relaxed since version 121, and Gecko and WebKit run SQLite in WAL mode without a flush per commit. "strict" is each engine’s strongest commit; whether it also flushes a drive’s write cache is the engine’s choice. An IndexedDB transaction is atomic, so a crash leaves every record whole at its previous or its new value.

One lock serializes all replay transitions, but no record write runs under it: replay commits by group commit (#916). A transition locks, applies its effect to the in-memory tables, stages its stream’s record into the open batch, unlocks, and waits until that batch is durable. One writer at a time writes the batches, in order, outside the lock. The transitions that run while one batch is written gather into the next, and one store batch writes all of them:

  • On the native store, every record goes to its own temporary file, and each file is fsynced. One F_FULLFSYNC barrier on Apple targets then makes them all durable together. After that come the renames, and one directory flush covers all of them.
  • On IndexedDB, the batch is one strict read-write transaction.

The laws, stated in crates/core/src/message/replay/commit.rs and in the Group commit section of papers/durable-records.tex:

  • Release. No result is returned before its batch is durable. That holds for a refusal too, and for a reservation inside its lease that writes nothing.
  • Atomicity. Each record is whole at its previous or its new value after any crash.
  • Order. Each stream’s durable values follow lock order.
  • Fail closed. A failed batch fails, and undoes, every pending transition: its own, and those of the batch gathering on top of it. It is counted once as a persistence failure.
  • Latency. A transition waits for at most two batch writes: the one in flight, then its own.

Each transition runs detached from its caller on the runtime, so cancelling a caller abandons only its wait, never a staged effect. A cancelled caller’s transition therefore commits, exactly as if the caller had dropped the verdict: an admission enters the window and is charged its quota though nobody dispatches it (a retransmission is then a Replay, and the message is lost, as for any drop after admission), and a reservation consumes its sequences (a gap the receiver accepts). A crash after some records of a batch landed and before the batch was released is the same case.

test_durable_throughput_of_replay_admissions (ignored by default; run it with --release --ignored --nocapture) measures both latency and throughput. One admission at a time pays a whole batch write. Admissions that run concurrently share batches, so the rate grows with the transitions in flight. One run on an Apple M1 Max SSD (APFS) measured:

StoreAdmissions/sp50p99
authoritative file, one at a time39~24 ms~46 ms
authoritative file, 64 in flight922
disposable file, one at a time~4,000~0.2 ms~0.5 ms
memory, one at a time~67,000~11 µs~41 µs

Before group commit the authoritative store sustained 49–57 admissions/s whatever the concurrency. The benchmark admits through the production path (admit_with_quota), so each figure includes the detached task every transition runs on. Sender reservations write once per lease of 64 sequences, so a node’s replay writes are about one per admission, and they share the same batches.

Each table retains at most TRANSACTION_REPLAY_STREAM_CAPACITY = 4 x 4096 streams: every class stream of 4096 account-destination pairs, or more pairs that use fewer classes. Each receiver stream has exactly 32 hot digest slots. A full store is at most TRANSACTION_REPLAY_STORE_MAX_RECORDS (32,768) records and TRANSACTION_REPLAY_STORE_MAX_BYTES (28,692,288 bytes, counting the relay custody once for the whole store, since it holds at most 64 deliveries). The sender table holds this node’s own streams, and a new one fails closed at the bound. The receiver table is shared by edge, as the next section describes. There is no LRU eviction or sender-controlled reset. The native daemon keeps the records in a dedicated 40 MiB authoritative file store, which evicts nothing: a write beyond its budget fails with StorageBudgetExhausted, and so does an open under a lowered budget. Browser providers keep them in a dedicated authoritative IndexedDB store with a row capacity of TRANSACTION_REPLAY_STORE_MAX_RECORDS, which evicts nothing either: a new record beyond it fails with StorageBudgetExhausted, and so does an open under a lowered capacity (#923). Both are sized never to reach their limits, so the refusal is a bound, not an expected path. A custom SwarmBuilder or ProcessorBuilder must supply an authoritative ReplayStorage. Without one, the node refuses every reservation and admission with TransactionReplayStorageUnavailable, so it neither sends nor accepts a transaction (#1022): an in-memory store would lose every stream’s window at each restart, reopening replay on the receiver and re-signing used sequences on the sender.

Deleting or replacing the replay store deletes the guarantee for its streams. There is no safe incarnation/reset protocol in 0.24.0, so operators must retain the store across restarts and fail closed on storage errors. Counters expose Replay, Fork, Stale, persistence failures, unrestorable records and the calls refused on their streams.

Receiver streams by connection slot

An origin is a key pair, free to mint, so a bound per origin bounds nothing: before #907 anyone could fill the receiver table with one transaction from each of 16,384 fresh origins and refuse every new origin for good. The receiver table is now accounted by connection slot (the load-bounds paper, Law 2). A node has R = 2(160 + s + 1) slots, 328 at the default three successors; an admitted connection takes the cleanest free slot (the one whose items use the least of their floor) and frees it when it is retired. A new stream is charged to its introducer:

  • the slot of the handshake-authenticated connection that delivered its first transaction;
  • the inbox, for mail out of this node’s own relay inbox;
  • this node itself, for out-of-band connection signaling.

Each slot has a floor of 32 new streams. Slots past their floors share a pool of 3328, each up to its fair claim ⌈3328 / n⌉, where n counts the over-floor slots. The inbox has a share of 768 and this node a share of 1024, so a flood of mail never refuses the node’s own signaling. At the default three successors the table holds at most 328 x 32 + 3328 + 1024 + 768 = 15,616 streams, and 64 more per extra successor: the shares stay within the table’s hard capacity of 16,384 up to 15 successors, and past that the hard capacity refuses first. A new stream past its introducer’s share fails with TransactionReplayStreamCapacityExceeded, naming the introducer and the bound. A party with many origins and k slots can be refused only on those k slots.

A stream stays charged to its slot after its connection leaves, so rotating connections never grows the table past its bound. The cost is that a connection admitted into a slot inherits what the slot still holds. Because a newcomer takes the cleanest free slot, a party that rotates connections reaches it only by charging every free slot, which leaves the newcomer with only its fair claim until those streams are spent (the paper’s rotation residual, #1094). A connection that keeps its slot is never affected.

A stream leaves the table once it is spent: every transaction it admitted can no longer be delivered, by a clock up to σ behind.

  • A control, storage or E2E stream is spent σ after its last proof expires, about 10 minutes after its last default-lifetime transaction.
  • An application stream is spent another MAX_RELAY_INBOX_TTL_MS + σ later (7 days), since its mail may still arrive out of a relay inbox, judged as of its hold instant.

The next admission expires every relay custody entry whose transaction is spent, then retires every spent stream and removes its record. A custody entry is spent no later than its stream, so a spent stream holds no custody by then.

A relay custody entry (sequence, digest, spent instant) leaves custody only when the application takes its mail, or once its transaction is spent and no carrier can deliver it again. A drain pass that reads no carrier, or a carrier without the element, removes nothing: the read may come from a holder that a membership change just named, before its hand-off landed, and dropping custody on its word would refuse the hand-off’s redelivery after a restart as a replay (#1059).

The cost is liveness. An entry whose mail was admitted, not taken, and then lost at every member of its chain (a chain that churned out without a hand-off) stays until its transaction is spent, up to the proof’s lifetime plus 7 days after its admission. The bound of 64 entries is shared by the whole store, so with 64 such entries every new relay delivery to the node, from every origin, waits (RelayCustodyFull, the pass ends and the mail stays stored) until the earliest of them is spent. A relay inbox that stays undelivered while mail is held for the node points to this stall. It ends by itself at that instant; removing the transaction-replay store ends it at once, at the cost of the replay guarantee (see Bad records for that reset). Forgetting a spent stream reopens no replay, so a refusal ends once a stream of the refused edge is spent. Each receiver record carries its introducer and spent instant, so a restart recovers the accounting.

Bad records

The first replay operation restores the store from one scan and caches the result; no later call reads the store again. Every record is written under the record key of the stream it carries, and a record restores its stream iff it decodes and holds a valid window. Any other record is kept and joins the set U of unrestorable records (#910): torn, corrupt, unreadable (any read error but the absence of the entry, such as a permission or an I/O error), holding another key’s record (which only something outside the store can put there), or holding an invalid window. A record the native store marked present and no longer holds joins U too, as missing (#915). Its name is the storage’s record name: the native file name, or the browser row key.

unavailable(table, key)  ⟺  record_name(record_key(table, key)) ∈ U
  • An unavailable stream refuses every reservation and admission with TransactionReplayStreamUnavailable { key, record }. No replay is admitted from, and no sequence is reused by, a stream whose record is torn, corrupt or unreadable.
  • Every other stream already in the store restores and runs exactly as it would without the bad record. A new stream is affected only by the bound: each unrestorable record holds a slot of both tables’ stream bounds.
  • Restore costs one read of the whole store, once. Each later call reads nothing and writes one record.
  • Each unrestorable record is logged once at load and counted in unrestorable_record. Each refused call is counted in unavailable_stream.

A scan fails as a whole only when the store itself cannot be listed, for example when its index lock is poisoned. Such a scan restores nothing: it is counted as a persistence failure, the call fails closed, and the next call scans again.

The law covers the records the store witnesses (#915). The native store keeps an empty presence marker <name>.p beside every record it made durable: it creates the marker only after the record’s rename is flushed, removes it before the record, and marks any unmarked record at open (a crash between a rename and its marker). A marker whose record is gone is a record removed, renamed or lost outside the store, and its stream fails closed as missing instead of restarting from First. A file renamed to upper case is never a record name: on a case-sensitive volume its stream is missing, and on a case-insensitive one (default APFS) the store still reads the file under its own name. The browser store needs no marker: an IndexedDB database loses no proper subset of its rows, and the store evicts nothing (#923).

A store lost whole (the native transaction-replay directory, or the browser database or origin) is indistinguishable from a store never written, since its markers go with it. That is the reset of every stream described under Clearing one stream.

Clearing one stream

An operator clears a failed stream, and only that stream, by removing its record with the node stopped and then starting the node:

  1. Take the record name from the refusal (record) or from the load’s log line (record, in replay record does not restore; its stream fails closed until it is cleared).
  2. Stop the node. On native, stop the daemon. In a browser, close every tab of the origin that runs the node, then open one tab of the origin with the node not started.
  3. Delete the record:
    • On native, delete the file of that name and its presence marker <name>.p in the transaction-replay directory beside the data store. Deleting the file alone leaves the stream failed closed, as missing.
    • In a browser, open DevTools, go to Application, then IndexedDB, and select the database <storage name>/transaction-replay; for the default provider this is rings-node/transaction-replay. In its object store of the same name, delete the row whose key is the record name.
  4. Start the node. The stream starts again from First.

This resets that stream’s replay window alone, and no other stream’s state changes.

  • Cleared receiver stream. An unexpired transaction of the stream may be admitted once more.

  • Cleared sender stream. The stream restarts at sequence zero. Its destination rejects those sequences until they pass the destination’s retained high watermark:

    • below the window, as Stale;
    • inside the window, as Fork, with a signed TransactionForkEvidence against this node, because the same sequences now carry new transactions.

    The messages carried by the rejected sequences are lost.

Hard cutover

0.24.0 was a network-wide protocol cutover: the sequence field became mandatory and the transaction signing domain and payload marker changed with it. 0.28.0 is the next one: the signing domain is rings-core:message-verification:transaction, the payload wire encoding begins with the RINGS-PAYLOAD marker, and the replay store key is rings-core:transaction-replay (see Delegation References). Payloads without the current marker are rejected before deserialization. There is no dual decoder, negotiation, feature flag, downgrade path, or legacy fallback, and no version behind any of these names: the protocol is not versioned before 1.0. Mixed-version overlays are unsupported. Per-class streams (#898) are the next cutover: the store becomes one record per stream under rings-core:transaction-replay:stream:, and every node, seeds included, upgrades together. Edge accounting (#907) changes the receiver record, which gains its charge: the operator removes the transaction-replay store while the node is stopped (the documented reset), since a record without a charge does not restore.

Non-guarantees

  • No cross-account or global order.
  • No claim that a sequence gap identifies loss or a dishonest relay.
  • No retransmission, acknowledgement, or head-of-line blocking protocol for transactions (the link’s leases and disclaimers are about delegation references, not about transactions).
  • No exactly-once application side effects.
  • No recognition of messages older than a deliberately deleted replay store.
  • No reputation or slashing consequence for fork evidence.

Delegation references on a link

Every MessagePayload carries two proofs: the origin’s, over the transaction, and the current hop’s, over the carrier. Both are verified at every hop, not only at the final destination. Each proof is a delegatee-key signature plus the Delegation that delegates that key to an account: about 150 bytes of delegation next to a 65-byte signature. The delegations are the same for the lifetime of a connection, so since 0.28.0 a link sends each of them once and references it afterwards.

Wire form

A payload frame is the MessagePayload with each delegation slot replaced by a reference:

DelegationRef    = Inline(Delegation) | Digest(DelegationDigest)
DelegationDigest = the trailing 20 bytes of keccak256(encode(Delegation))

The digest is a content address of the whole delegation (delegatee_did, delegator, ts_ms, ttl_ms, delegator_signature), not the delegatee key’s address. A delegatee key can carry more than one delegation: an account can re-authorize a new lifetime for it, and any account can sign a delegation for a key it does not hold, because a delegation needs no proof of possession. delegatee_did therefore does not determine which delegator a message is attributed to; the content address does. A resolved frame is exactly the payload that would have travelled inline, so the verification verdict, origin attribution, and the transaction digest used for replay and fork evidence do not depend on how a slot was encoded. Only the work does: a referenced slot resolves from the lessor table, whose every entry was leased from a frame that verified in full, so its delegator’s signature is not checked again; its expiry and the delegatee’s signature are (#1089).

Neither signature covers the delegation slot (both sign the transaction hash), so a forwarding hop re-encodes the origin slot for its own next link without touching the origin’s signature.

A link is one admitted connection generation between two nodes. Each direction keeps one bounded table:

EndTableCapacityPopulated by
senderdelegations it sent inline, and the lease the peer granted each64its own frames; leases from the peer
receiverdelegations this link carried inline, each under a lease it granted128frames that verified

The sender’s table is a least-recently-referenced cache: what it forgets travels inline again. The receiver’s table is not a cache. It never reclaims a lease before its grace ends (see Lease), and 8 of its 128 entries are reserved for the link peer’s own (hop) delegations, so relayed origins cannot push the peer’s own session inline. A table full of leases still within their grace refuses to lease a new delegation, and the sender keeps sending it inline, which is always admissible.

Consequences of the scope:

  • Only the peer at the other end of a connection can populate a receiver table, and only with delegations of its own frames that verified. Nothing an unrelated party says is cached.
  • A relay keeps the origin delegations of the traffic it forwards in the table of each outgoing link. A destination that never met the origin gets the origin’s delegation inline from its last hop until it leases it. No node ever asks the origin for anything.
  • Both tables are scoped to the connection generation: the receiver’s lives in the callback of one connection, the sender’s is emptied by the first frame of a newer generation (and survives a replacement of the outbound worker under an unchanged generation). A restarted node and its peer start from empty tables together; there is no state to resynchronise, and a control frame is emitted only on the generation it was judged on, so a late one never leases or disclaims on a generation whose tables never saw it.
  • Only frames on the link teach the link’s table, and what a verified frame teaches is leased and confirmed to the peer before the frame itself is gated for admission. A frame from any other peer, or on a callback bound to no handshake, is judged self-contained: a reference in it is refused, and it teaches nothing.

Nothing assumes that frames arrive in order, or at all. WebRTC data channels happen to be reliable and ordered; the protocol does not use that, so a transport built on QUIC datagrams or plain UDP would carry it unchanged.

The sender switches a delegation from inline to reference only under a lease the receiver granted:

sender                                      receiver
  frame, delegation s inline        ───────▶   verifies; leases s
                                 ◀───────   Known(digest(s), r)
  frame, Digest(s)               ───────▶   resolves from its table

Until the lease arrives, every frame carries s inline, and the receiver confirms every inline arrival that verified and that it holds. A lost inline frame, a lost lease, or any reordering costs inline frames, never a stall.

Lease

Write Δ (the margin) and G (the grace) for one delivery bound each: 25 s in production.

The receiver leases s only once a frame carrying it inline verified and s is resident in its table. It sends Known(digest(s), r), where r = until − now and until = min(exp(s), now + 5 min) on the receiver’s clock. r is a remaining duration, not an instant, so no clock is ever compared with another:

  • The sender, receiving Known(digest(s), r) at t_S on its own clock, records until_S = t_S + r, keeping the largest such value it received. In real time, whatever the two clocks read, until_S exceeds the receiver’s until by exactly the transit c of the Known it came from; a stale Known counts from its own, older grant and never lengthens a newer one.
  • The sender references s only while now + Δ < until_S. Otherwise it sends s inline.
  • The receiver never reclaims a lease before until + G, and until never decreases.

A reference of transit t therefore resolves whenever c + t ≤ Δ + G, the confirmation and the reference each within one delivery bound, on every interleaving and every loss.

Residence. Whether a reference resolves depends only on whether the delegation is resident in the receiver’s table, never on when the frame arrived or how long it waited. A lease that ended, grace included, is reclaimed lazily: only when its slot is needed for a new lease. Until then a reference to it still resolves, so a frame that waits in its lane at the receiver resolves however long it waited, unless the table filled up meanwhile. An unused lease becomes reclaimable 5 min plus G after its last grant or renewal, which bounds what one link pins.

Renewal. Only a frame that verified renews a lease. When a verified frame references a delegation with less than half a lease left, the receiver renews it, if that lengthens it, and sends the new Known. A verified frame causes at most one lease or renewal per distinct delegation it carries: a peer’s own frame, whose origin is its hop, carries one.

Miss

A reference the receiver does not hold is a miss. On an honest link it can only come from a confirmation and a reference whose transits together exceed Δ + G, under a full table, or a fault at this end, so it is not charged:

receiver                                   sender
  frame references d, d not held
  drop the frame, uncharged; Unknown(d)  ───────▶   d travels inline again

Nothing is held. A dishonest sender gains nothing by missing: its own frame is dropped. Since no frame waits on a repair, the frames of one lane reach the logical stage in their arrival order (#908; see Transaction Replay Protection). A stale Unknown that overtakes a newer Known costs inline frames until the next lease, never a miss.

Link-control frames (Known, Unknown) are unsigned and idempotent. They are meaningful only on the authenticated connection generation they arrive on, a lease is granted only for a delegation that verified, and a digest names a value rather than asserting one; a duplicate changes nothing. They travel on the DHT-control lane, in a task of their own (refused, never run inline, when no runtime can carry one), never awaited by the transport lane whose frame caused them. A send waits for its lane’s credit at most one delivery bound, and is then dropped: a lost lease is granted again by the next inline frame or renewing reference, a lost disclaimer is sent again by the next miss. Each inbound frame causes at most two of them, one per distinct delegation slot, and at most 128 are in flight to one peer at a time, twice the raw frames that peer may have in flight at this end’s transport; a send beyond that budget is dropped and repeated by the next frame that misses or teaches the same delegation. So what this end spends on a peer’s link control is bounded by the frames it accepts from that peer. The sender’s table and this budget survive a replacement of the outbound worker under an unchanged generation.

Expiry

The sender’s table holds no expired delegation, and a receiver’s lease never outlives its delegation. The delegation itself may outlive its lease at the receiver: it stays resident through the grace G, and after that until its slot is needed (the residence law). Residence grants nothing, though: verification refuses an expired delegation, so a frame that resolves to one is refused like any other expired frame. A fresh delegation must travel inline and verify like any other; expiry never resurrects an old one.

The sender refuses to send a frame with a delegation that expires within Δ, so no frame needs a delegation past its expiry in flight. The refusal is the local error DelegationExpiresWithinMargin: nothing reached the peer, so it is not charged to the next hop.

An origin delegation can still expire in flight, after the hop that carried the frame checked it. Such a frame is dropped without charging that hop, since the hop answers only for its own delegation, but only when all of these hold:

  • the origin is not the hop;
  • the origin delegation expired at most G before arrival;
  • the hop’s proof verifies now;
  • the origin’s proof verifies at the instant its delegation expired.

Any other failure, a forged or otherwise invalid frame included, is charged as before.

Self-contained contexts

MessagePayload::to_wire and from_wire remain the self-contained encoding, both delegations inline: handshake offers and answers exchanged out of band, the payload cut into chunks (it is decoded after reassembly, outside the order of the link), and payloads held in a relay inbox. A frame that references a delegation is refused there with DelegationReferenceUnresolved. So is a frame on a connection from a peer other than the one its handshake is bound to: it arrived on no link, and is judged as self-contained.

Hard cutover

0.28.0 is a network-wide protocol cutover. Payload frames begin with the RINGS-PAYLOAD marker and link-control frames with RINGS-LINK; a frame with neither, including every frame of 0.27.x and earlier, is rejected before deserialization. There is no dual decoder, negotiation, or downgrade path.

#908 changes the link-control tags again: Request and Announce are removed, and Known carries the remaining duration of a lease (remaining_ms) instead of an instant on the receiver’s clock. A link-control frame of the previous encoding is not decoded; both ends of a link upgrade together.

The protocol is not versioned before 1.0. Markers, signing domains, AEAD namespaces, and storage keys carry no version suffix: each of them names one domain, and a change to a domain is a total cutover, not a new version beside an old one. The version suffixes earlier releases had put on these names are removed in 0.28.0, so every signing domain (rings-core:message-verification:transaction, rings-core:message-verification:payload, the inbox, receipt, descriptor and onion domains) and the transaction-replay storage key change with it; a replay window persisted by 0.27.x is not read, which loses nothing, since no 0.27.x frame is accepted either.

Provisional DRanking Service Receipts

Rings currently implements one narrow DRanking input: a versioned, countersigned receipt for an authenticated liveness probe. It is a collection and persistence vertical slice. It is not the finalized epoch ledger, trust algebra, committee protocol, or routing policy described by the full DRanking design.

Probe flow

  1. The beneficiary sends ProbeRequest with a random nonce and a current five-minute epoch.
  2. The provider returns ProbeOffer. The offer embeds the exact signed request transaction, an exact provider-signed completion transaction, the canonical claim, and the provider’s claim attestation.
  3. The beneficiary verifies the transcript against its one outstanding request, signs the same claim under the beneficiary role domain, and returns ProbeAcknowledgement.
  4. The provider verifies the complete receipt under live rules and admits it to its bounded local evidence store.

The canonical claim binds network_id, service kind, provider account, beneficiary account, epoch, nonce, units, request digest, and completion digest. Provider and beneficiary signatures use different domains. Roles are account DIDs recovered from delegation proofs, so a delegatee-key rotation does not change the account role.

The canonical marker and the signing domains name this field layout, so a signature over it cannot be interpreted as another receipt protocol; they carry no version, since the protocol is not versioned before 1.0. The release is still a hard cutover: there is no legacy probe/report decoder, negotiation, or dual format.

Verification boundaries

Cryptographic verification is portable: a later reader can verify the two account roles, the canonical claim, and that each delegated proof was valid at its signing time. Live verification is receiver-local: it additionally requires live delegated proofs and accepts only the current or an adjacent provisional epoch at observation time. Persisted evidence records that observation time, but replaying an old receipt never recreates live-observation status.

The freshness key is (network_id, beneficiary_account, epoch, nonce). The same receipt digest is a duplicate; a different receipt for an occupied freshness key is a conflict. Receipt bytes may be evicted, but their fixed-size replay markers remain independently bounded and durable while the key can still be admitted. If the global or per-beneficiary marker bound is full, a new key fails closed instead of evicting an active marker. A monotonic replay floor prevents clock regression from reopening a pruned epoch.

Executable model and refinement

Two native Stateright models define the complete state relation for this provisional slice:

  • crates/core/src/message/service_receipt/model.rs models the request, offer, acknowledgement, pending request, three replay gates, four independent delegated-session lifetimes, proof lifetimes, epoch, retained evidence, and independent freshness occupancy. Its actions cover send/deliver, time advance, loss, duplication, reordering, hard-crash restart, and receipt eviction; its admission outcomes cover commit, replay-capacity rejection, and persistence failure.
  • crates/measure/src/evidence/model.rs models admission, exact duplicates, freshness conflicts, pair-local and global pressure, deterministic eviction, invalid record rejection, pagination, replay-marker saturation, clock regression, and hard-crash restart under explicit record, byte, and marker bounds.

The checked safety properties are: admitted evidence was live at its observation instant; only a complete transcript can be admitted; no admitted digest is admitted twice; receipt and replay-state bounds always hold; eviction cannot remove duplicate protection for a still-admissible key; restart preserves every committed marker; capacity and regressed-clock paths fail closed; and provisional evidence cannot change credit or routing inputs. Reachability properties witness the live happy path and every admission outcome. No delivery fairness is assumed, so the happy path is reachable rather than inevitable under loss. Production-refinement checks replay the model states through real session signatures and through ProvisionalEvidenceStore; the shared native/Wasm tests cover canonical bytes, claim-field binding, transaction binding, and the live-session boundary.

The finite model bounds time, message multiplicity, candidates, and one crash restart. Cryptographic unforgeability remains an explicit assumption: model checking does not replace verification of the real signing and canonical-encoding implementations, which is why those are separate refinement tests. Delivery actions take prior acceptance of each outer transport envelope as a precondition; the model still checks the embedded request and completion independently from the two claim-role attestations.

Evidence admission is synchronous with the separate evidence snapshot: storage failure rolls the transition back, and success means the receipt plus replay marker survive a process crash. Deleting or replacing that configured store explicitly starts a new collector history and is outside the crash model. The durable storage write is the admission linearization point; evidence queries and other admissions share its serialization lock. Cancellation of an incomplete call may conservatively retain the marker, but cannot admit the key twice.

Storage and non-claims

The node keeps provisional evidence in a component separate from local peer measurements. Native nodes use a separate file-backed snapshot and browser nodes use a separate IndexedDB store. Global and per-account-pair record and byte limits are hard bounds. Oldest evidence is evicted deterministically without removing its active replay marker. Replay markers have separate hard global and per-beneficiary bounds; saturation rejects new keys. APIs expose only bounded digest-ordered pages and aggregate counters without DID labels.

Opening the browser evidence store is part of provider construction. If IndexedDB cannot be opened, construction fails instead of substituting volatile memory, because a successful memory-only admission would not refine the documented process-crash model. Likewise, embedding constructors that do not supply a durable evidence backend keep probe liveness available but fail closed rather than returning a successful receipt admission.

This evidence does not update CreditRecord, affect order_peers_by_quality, prove relay or storage service, prevent self-dealing, or provide Sybil resistance. A later finalized DRanking ledger must define a distinct receipt format and signing domain instead of treating provisional receipts as finalized trust input.

Local measurements and browser storage

rings-measure owns authenticated useful-byte credits and recent reliability windows. Core’s Measure boundary requires explicit record and atomic record_batch implementations. There is no counter-only fallback: callers supply an Authentication and a MeasurementEvent, including useful bytes. A failed batch must leave the ledger unchanged. Node’s PeriodicMeasure applies the whole batch under its runtime lock, then wakes coalesced persistence.

Each retained PeerMeasurement includes a CreditRecord. Absence of a peer is represented by the outer optional measurement, not by missing credit within an existing peer. The protobuf RPC message field retains its generated presence wrapper and is populated for every projected peer. Queries still include retained disconnected peers; bounded pagination, peer_measurements(), credit scores, and measurement RPCs remain available. Reliability classification requires an explicit ReliabilityPolicy; thresholds alone do not specify minimum positive evidence.

Authentication and logical completion remain the recording boundaries. A locally selected DID may only contribute a failed-send observation to an already retained peer; it cannot create credit or refresh authenticated last-seen time. Chunking must produce one terminal logical outcome with exact useful bytes. Provisional service receipts use their separate evidence store and do not alter credit or reliability.

IndexedDB access and eviction

An IndexedDB row stores key, data, and access_stamp. Recency is a store-wide logical clock, not wall-clock time: each database keeps one access counter in a companion object store, and every put and every successful get takes the next counter value as the row’s stamp and advances the counter in the same read-write transaction as the row write, awaiting commit before returning. Splitting the touch from the read could overwrite a concurrent value; removing the touch would change least-recently-used eviction. A missing-key read is not an access and also waits for transaction completion.

The law: k accesses receive k distinct, strictly increasing stamps, whatever the browser’s timer resolution; no timer is read. Eviction removes the rows with the smallest stamps through the access_stamp index, so two rows never tie. clear keeps the counter, so a stamp is never reused within one database. The counter stops with an error at Number.MAX_SAFE_INTEGER rather than repeat a stamp.

Schema version 2 introduced the counter; the first open of a database writes the counter record 0. A version-1 database (0.31 and earlier), whose rows carry wall-clock last_visit_time and no stamp, is not migrated: delete the browser’s IndexedDB data for the site before upgrading from such a release.

The constructor requires an explicit database name and nonzero capacity. Raw transactions are private to the adapter. Browser tests use unique database names and exercise actual IndexedDB reads, writes, errors, access-clock touches, capacity eviction, and reopening. No browser test reads or waits on wall-clock time.

config.yaml

rings init writes the node configuration to ~/.rings/config.yaml (or the path given to --location) with every section stated explicitly, so the generated file is the one place an operator edits. This is the file it writes, with $HOME expanded to /home/operator:

network_id: 1
delegatee_key: /home/operator/.rings/delegatee_key
internal_api_port: 50000
external_api_addr: 127.0.0.1:50001
endpoint_url: http://127.0.0.1:50000
allow_remote_external_api: false
ice_servers: stun://stun.l.google.com:19302
stabilize_interval: 15
online_node_heartbeat_interval_secs: 30
online_node_ttl_secs: 90
online_node_type: Native
advertise_presence: true
advertise_onion_relay: false
advertise_onion_exit: false
onion_exit_heartbeat_interval_secs: 30
onion_exit_ttl_secs: 90
onion_exit_services:
- tcp
- https
onion_exit_policy:
  allowed_targets: []
  denied_targets: []
  max_sessions: 0
  max_bytes_per_minute: 0
onion_http_proxy_header_timeout_secs: 10
onion_http_proxy_max_connections: 1024
gateway:
  enabled: false
  plan:
    addresses:
    - 100.64.0.1/32
    included_routes: []
    mtu: 1280
  max_flows: 1024
  flow_idle_timeout: 300
  tcp_buffer_bytes: 65536
  interface_name: null
  route_ledger_path: /home/operator/.rings/gateway-routes.json
  unix_helper_socket: /home/operator/.rings/gateway-helper.sock
  wintun_dll_path: null
  status_refresh_secs: 2
bootstrap:
  peers: []
dht_virtual_nodes: 160
origin_quota:
  dht_control:
    message_rate_per_second: 8
    message_burst: 32
    byte_rate_per_second: 4194304
    byte_burst: 67108864
    max_records: 1024
  storage:
    message_rate_per_second: 8
    message_burst: 32
    byte_rate_per_second: 4194304
    byte_burst: 67108864
    max_records: 1024
  e2e:
    message_rate_per_second: 8
    message_burst: 32
    byte_rate_per_second: 4194304
    byte_burst: 67108864
    max_records: 1024
  application:
    message_rate_per_second: 8
    message_burst: 32
    byte_rate_per_second: 4194304
    byte_burst: 67108864
    max_records: 1024
data_storage:
  path: /home/operator/.rings/data
  capacity: 200000000
measure_storage:
  path: /home/operator/.rings/measure
  capacity: 200000000

Every field has the meaning below. Fields absent from a file take the value shown above, except where noted.

Identity and network

  • network_id: the Rings overlay this node joins. Signatures are bound to it, so nodes on different overlays do not verify each other’s messages.

  • delegatee_key: path of the delegatee signing key file that rings init writes next to the config. The delegatee key is derived from your ECDSA key and signs every message on your behalf; keep the file private. This field is path-only: a raw session dump and the removed ecdsa_key and session_manager fields are rejected rather than interpreted as compatibility fallbacks.

  • ice_servers: STUN or TURN servers used to establish WebRTC connections, separated by ;. STUN is ordinary discovery; TURN is optional and never a gateway prerequisite.

    ice_servers: turn://user:[email protected]:3478;stun://stun.l.google.com:19302
    
  • stabilize_interval: seconds between Chord stabilization rounds.

  • dht_virtual_nodes: virtual DHT positions this node owns for storage placement; 0 disables virtual positions.

  • origin_quota: runtime-local final-destination rate limits. Each of dht_control, storage, e2e, and application sets message rate/burst, byte rate/burst, and the maximum retained origin records for that lane. Every value must be non-zero. The key uses the verified origin account and final destination, not the delegation or immediate relay. Token balances are deliberately reset on restart; durable replay state is separate. A protocol may declare delegated admission when it is registered: for its own traffic from the authenticated neighbour that originated it, the application message limit is skipped, while the application byte limits and record bound still apply, each such message is charged at least 16 KiB (DELEGATED_MIN_CHARGE), and it shares the byte bucket with that neighbour’s other application traffic. A protocol may also declare delegated replay (#1081): its traffic from that same neighbour is then admitted in a volatile replay window of the connection, and the protocol guards its own replay. Delegation is not configured here.

  • external_ip, webrtc_udp_port_min, webrtc_udp_port_max: optional reachability hints, an externally visible address and a UDP port range for ICE. The two port bounds must be given together.

Control API

  • internal_api_port: loopback JSON-RPC listener for the rings CLI and local tooling; every route requires the Bearer token. The existing /status route also provides the compact operator observability projection.
  • external_api_addr: JSON-RPC listener peers dial for the HTTP handshake. nodeDid and answerOffer are public; status and registry reads, including the compact observability projection, require the token.
  • endpoint_url: the internal endpoint the CLI connects to.
  • api_token_path: optional path of the Bearer token file; by default api-token next to the config file. Relative paths are resolved next to the config file.
  • api_allowed_origins: exact browser origins permitted to call the authenticated API; empty denies every browser origin.
  • allow_remote_external_api: required to bind external_api_addr to a non-loopback address.

Presence and onion registries

  • online_node_heartbeat_interval_secs, online_node_ttl_secs, online_node_type, advertise_presence: how this node publishes its online-node descriptor. A descriptor is a signed lease of 10 minutes whose bytes stay fixed, so each heartbeat re-appends the same descriptor and writes no removal (#918); the TTL is how long after the last heartbeat a reader still lists it. A heartbeat interval must be below the TTL, and below half the lease, 300 seconds, so a lease spans more than two heartbeats; the TTL must be at most the lease, since no reader lists a longer freshness window. The same bounds apply to onion_exit_heartbeat_interval_secs and onion_exit_ttl_secs. A node refuses to start otherwise. A descriptor that someone else re-appends after its node stopped is listed at most until its lease ends.
  • advertise_onion_relay: advertise onion relay capability. The capability carries the node’s process epoch, drawn afresh at every start, so layers sealed for an earlier process are rejected after a restart. It requires advertise_presence.
  • advertise_onion_exit, onion_exit_heartbeat_interval_secs, onion_exit_ttl_secs, onion_exit_services, onion_exit_policy: whether and how this node serves as an onion exit. Every exit is also a relay: advertise_onion_exit requires advertise_onion_relay, and each exit descriptor carries the same process epoch as the relay capability. Clients select an exit only while both agree. A browser node interprets https only, so a browser exit offering tcp is rejected when it starts. Service names form a closed set: tcp (byte streams, every tunnel and TLS included) and https (one HTTPS request/response fetch; never a tunnel). Any other name, including relay (advertised through advertise_onion_relay instead), is rejected, so restrict an exit’s reach with the policy targets rather than with a custom service name: an exit that should only carry HTTPS registers tcp with the allowed target *:443. A policy target is host:port, *:port (any host on that port) or *:* (any target). An empty allowed_targets list is a closed policy that admits no target, so advertising an exit requires at least one allowed target; deny entries override allows; a 0 limit is unspecified. The policy takes only these four keys: any other, including the removed max_circuits and max_streams_per_circuit, is refused as an unknown field. A * is recognised only as a whole host (*:port, *:*); any other * in a host is refused. Deny entries match the requested authority, not the addresses it resolves to: an IP literal or another name for a denied host is not denied by a name entry, so deny by the name the client requests, and rely on the exit’s public-address floor for private and loopback ranges.

HTTP CONNECT proxy

  • onion_http_proxy_addr: optional local HTTP CONNECT listener that routes client TCP streams through onion exits; absent means no proxy. The CONNECT exit service is always tcp, so the former onion_http_proxy_service key (and its ONION_HTTP_PROXY_SERVICE variable) is removed: a config naming it is refused as an unknown field. A refused CONNECT answers 502 Bad Gateway with no reason.
  • onion_http_proxy_header_timeout_secs, onion_http_proxy_max_connections: limits of that proxy. A CONNECT tunnel is a byte stream, so it always uses a tcp exit; there is no service to choose.

The route length is not configurable. Every onion route is a loop that leaves and returns through the client’s entry guard, with two relays around the exit on each side: guard → relay → exit → relay → guard. It needs four distinct live relays besides the client, one of which registers the exit service; with fewer, route selection fails instead of shortening the path.

Gateway

The gateway section configures the native TUN gateway described in Native Gateway. Presence of the section is not consent to start a TUN device:

gateway starts ⟺ section present ∧ (enabled = true ∨ rings run --gateway)
  • enabled: whether plain rings run starts the gateway. Absent means false.
  • plan.addresses: IPv4 /32 host addresses of the virtual interface. The generated 100.64.0.1/32 is drawn from the RFC 6598 shared address space, so it collides with neither LAN nor VPN ranges.
  • plan.included_routes: the only destination prefixes routed into the gateway. Empty captures nothing; a default route or a set covering all of IPv4 is rejected.
  • plan.mtu: interface MTU. The generated 1280 is the IPv6 minimum link MTU, which every underlay carries.
  • max_flows, flow_idle_timeout, tcp_buffer_bytes: concurrent TCP flow limit, per-flow idle timeout in seconds, and per-flow buffer size.
  • interface_name, wintun_dll_path: Windows interface name and Wintun DLL path; null selects the defaults. On Unix the foreground helper’s --interface is authoritative.
  • route_ledger_path: durable route journal, used directly on Windows; on Unix the helper’s --ledger is authoritative.
  • unix_helper_socket: control socket of the gateway-config-unix foreground helper on Linux and macOS; must match the helper’s --socket.
  • status_refresh_secs: refresh interval of onion-exit availability in /gateway/status. Captured flows are byte tunnels, so they always use a tcp exit, and route length follows the onion loop shape (see HTTP CONNECT proxy).

Any other key in the section is rejected rather than ignored, so a misspelt key cannot fall back to its default.

Services and DNS relay

Two optional sections, absent from the generated file, tie the node to DNS relays (#1049); see DNS Relay for both:

  • services: the ports the node serves at <did>.<zone>, each mapped to a local address and optionally preceded by a PROXY protocol v2 header (proxy_protocol: v2), and the TXT records it publishes under its name. The node signs and publishes them every heartbeat_interval_secs; an empty section withdraws its name.
  • dns_relay: a DNS relay this node runs: its zones, IPv6 prefix, ingress ports and state directory.

Any other key in either section is rejected rather than ignored.

Bootstrap

The bootstrap section lists peers that rings run keeps reachable for the life of the process. rings connect node and rings connect seed connect once; a managed target is redialed through its HTTP endpoint whenever the node can no longer reach it through the overlay, so a long-running node that loses its transport to a seed rejoins without a restart.

  • peers: managed targets, each { did, url } with an optional api_token, the same shape as an entry of a seed document. Every did must parse, every url must be a public HTTP(S) endpoint, and the node’s own DID is skipped; an entry repeated verbatim is merged, while a DID listed with conflicting entries, or an endpoint listed under two DIDs, is rejected as ambiguous. A violation stops rings run before it listens. rings run --bootstrap-seed <url> appends the peers of a seed document (a file:// or http(s):// URL, read once at startup) to this list for one run.

How a managed target is supervised:

  • Reachability is a routed successor lookup for the target’s DID. A target whose admission was announced, or one that some peer can route to, is left alone, so a target is never forced into a direct edge merely because it is not a finger.
  • An unreachable target is redialed five times, each attempt at least two seconds after the previous one settled, then every five minutes plus up to thirty seconds of jitter, until the peer is admitted again.
  • A redial counts as successful only once the swarm admits the peer. An endpoint answering as a different DID is refused before any offer is created. A redial refused because a handshake to the target is already in flight, whichever side started it, is not an error, but consumes a turn of the burst like a failed dial.
  • A target leaving the local DHT, whatever caused the loss, triggers an immediate reassessment, and a later loss restarts the burst.
  • At most one handshake per target is in flight, targets retry independently, and shutdown cancels any handshake in progress.

Storage

  • data_storage: path and capacity in bytes of the DHT data store.
  • measure_storage: path and capacity in bytes of the peer measurement store.

The daemon keeps four more stores in directories beside the data store, with fixed budgets: transaction-replay (the replay windows, authoritative), relay-inbox (the relay carriers this node holds or owns, retained: flushed and never evicted, 64 MiB, #1021), dht-removals (the removals of the data topics this node stores, retained: flushed and never evicted until they are collected, 24 MiB, #920, #907) and onion-entry-guards (a durable cache). Back them up and remove them together with the data store; removing relay-inbox drops the mail held there, and removing dht-removals makes this node a fresh replica of every topic it stores.

Files written before a field existed still load: an omitted field takes its default, and an omitted gateway section loads as no gateway at all.

Decentralized services

A service on Rings has two independent parts:

  1. Discovery. A provider registers its DID under a service name in the DHT, and a client looks the name up to get the providers’ DIDs.
  2. Transport. The client reaches a provider by DID through an extension protocol: a namespaced message, or a TCP/UDP tunnel through the built-in relay.

Registering a name binds no endpoint: the DHT entry holds DIDs only, and what a provider serves is whatever its extension protocols do. There is no service backend in config.yaml; the node configuration rejects unknown keys, so an old backend: section fails to load.

Discovery

rings service register <name>
rings service lookup <name>

register appends this node’s DID to the data topic name; lookup prints the topic’s elements, one per line. The topic is an ordinary data topic, the one publishMessageToTopic and fetchTopicMessages also address, so a lookup returns every text element written to it. A registration is a plain write and expires after 10 minutes: a provider runs register again within that period to stay listed.

The same calls over JSON-RPC (API):

curl -X POST \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $(cat ~/.rings/api-token)" \
  --data '{"jsonrpc": "2.0", "id": 1, "method": "registerService", "params": {"name": "echo"}}' \
  "http://127.0.0.1:50000"

curl -X POST \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $(cat ~/.rings/api-token)" \
  --data '{"jsonrpc": "2.0", "id": 1, "method": "lookupService", "params": {"name": "echo"}}' \
  "http://127.0.0.1:50000"

lookupService returns {"dids": [...]}.

Transport

Namespaced messages

rings send message <did> <namespace> <data>

delivers data to the protocol registered under namespace on did (the sendBackendMessage method). The provider handles it in an extension protocol it registered: a Rust protocol in a node that embeds rings-node, or a JS handler provider.on(namespace, initialState, handler) in the browser.

TCP and UDP relay

rings run installs the relay on the namespaces tcp and udp, but registers no relay service: the relay maps a service name to a local socket address only through RelayHandle in an embedding Rust program, and neither the CLI, the JSON-RPC API nor the configuration can add one.

#![allow(unused)]
fn main() {
// Provider node: expose a local socket under a relay service name, and advertise it.
let relay = RelayHandle::install(&provider.extensions())?;
relay.register_tcp_service("echo".into(), "127.0.0.1:7000".parse()?).await?;
processor.register_service("echo").await?;

// Client node: find a provider, then tunnel a local listener to its relay service.
let relay = RelayHandle::install(&provider.extensions())?;
let request = LookupServiceRequest { name: "echo".into() };
let dids = rpc_client.lookup_service(&request).await?.dids;
let peer: Did = dids[0].parse()?;
relay.open_tcp_tunnel("127.0.0.1:9000".parse()?, peer, "echo".into()).await?;
}

A connection to the client’s 127.0.0.1:9000 then reaches the provider’s 127.0.0.1:7000. register_udp_service and open_udp_tunnel do the same for datagrams. The relay is direct: both nodes know each other’s DID, and every hop on the overlay route sees both endpoints. For traffic that hides its endpoints, use an onion exit (Native Gateway).

Examples

Decentralized Service Examples

Decentralized Service Examples

For a local-service example, use the TCP/UDP relay demos. They register a named service and carry bytes between two Rings peers:

cargo run -p rings-relay-example --example rings-tcp-relay-example
cargo run -p rings-relay-example --example rings-udp-relay-example

For HTTP access through an Onion exit, see the Native Gateway and Browser Node and Extension guides. The relay demos and Onion gateway have different routing and privacy properties; a direct relay to a named service does not provide Onion routing.

llms.txt

The repository keeps an llms.txt at its root, published at rings.rs/llms.txt, following the llms.txt convention: a plain-text map of the project for coding agents. It says what Rings is, which document is authoritative for what, how the crates are layered, how each target is built and tested, and which conventions a change must keep. Point an agent at it before it builds on Rings or changes it.

The file is maintained with the code — where it and the code disagree, the code wins and the file is corrected in the same change. This is the current file:

# Rings Network

> Rings is a browser-native, structured peer-to-peer network. Browser tabs and native daemons join one Chord DHT overlay, address each other by DID, and exchange messages over direct WebRTC datachannels; application protocols are namespace-scoped pure state machines run by an interpreter shell. Rust workspace, AGPL-3.0-only (commercial licensing available), https://github.com/RingsNetwork/rings.

This file follows https://llmstxt.org/. It is for agents that evaluate Rings, build on it, or change it: which document is authoritative for what, how the crates are layered, how each target is built and tested, and which conventions a change must keep. Where this file and the code disagree, the code wins; correct this file in the same change.

## What Rings is

- Overlay (communication layer): a Chord DHT (successor list, finger table, stabilization) keyed by DID. `network_id` isolates independent overlays. Its contract is routing and, after the E2E handshake, confidentiality to a DID; every hop sees the origin DID by signature and the destination DID by routing, so it has no unlinkability goal. It minimizes leakage; it does not provide privacy.
- Privacy layer: onion loops in `crates/node/src/onion`, client-sealed loops of fixed-width Sphinx cells over direct edges, with constant-rate link emission that substitutes real cells for cover, paid admission, fixed cell size classes, and route selection from the onion-relay and onion-exit registries. Each position learns only its predecessor and successor; the client is hidden from the exit but not from its first hop, and membership and each link's active/idle phase are not hidden. The two contracts are drawn in SECURITY.md under Layer Contracts.
- Transport: WebRTC datachannels, native and browser (web_sys), with STUN/ICE/SDP for NAT traversal. No server sits in the data path; seed nodes only help a peer find its first connection.
- Identity: a DID derived from a public key. Signature schemes: secp256k1 (EIP-191, bip137), secp256r1 (P-256), ed25519, BLS; the browser console signs with WebCrypto P-256, MetaMask, or Phantom. DID authentication proves control of a key; making identities scarce is a separate property, planned as epoch-randomized ring positions (#780). SECURITY.md is authoritative for every security claim.
- Protocol runtime: `Protocol::step` is pure (state, event -> state, effects); an `Interpret` shell performs the effects through a namespace-scoped capability. Built-in protocols: relay (TCP/UDP tunnels over the overlay) and echo. Applications register their own by namespace: `provider.register_protocol(..)` in Rust, `provider.on(namespace, initialState, handler)` in JavaScript.
- Targets: the native daemon (`rings` CLI with a JSON-RPC API over HTTP), the browser/Wasm node (npm `@ringsnetwork/rings-node`; the rings.rs frontend and its Chrome extension), and a C FFI (`crates/node/include/rings.h`).

## Status

- Shipped: the overlay, native and browser nodes, the onion privacy layer (TCP/HTTPS exits, TUN gateway, onion WebView), replay protection and per-origin rate limits, a public seed (https://node.rings.rs), and the hosted console (https://rings.rs/#node).
- Versions: pre-1.0 the wire protocol is unversioned, and a release that changes it is a network-wide upgrade. 1.0 freezes the protocol once connectivity and churn handling are stable; DRanking is 2.0.
- Next: churn handling (#773-#779), onion loops (#834), then epoch-randomized ring positions (#780); see ROADMAP.md.
- To evaluate Rings, read README.md, SECURITY.md, and ROADMAP.md, in that order.

## Documentation

- [Documentation book](https://rings.rs/docs/): the reference; source in `docs/src`, built with mdBook and published from master.
- [Guide](https://rings.rs/#guide): the abridged version: one card per runtime and the first commands; the book is authoritative.
- [README](https://github.com/RingsNetwork/rings/blob/master/README.md): overview, architecture table, examples, whitepaper citation.
- [SECURITY.md](https://github.com/RingsNetwork/rings/blob/master/SECURITY.md): threat model, assumptions, deployment models, non-goals, the communication-layer and privacy-layer contracts, feature boundaries, and how to report a vulnerability.
- [ROADMAP.md](https://github.com/RingsNetwork/rings/blob/master/ROADMAP.md): shipped foundations and the network-layer and privacy-layer tracks.
- [Whitepaper](https://github.com/RingsNetwork/rings/blob/master/papers/rings.pdf): the protocol paper; LaTeX source beside it.
- [JSON-RPC API](https://rings.rs/docs/jsonrpc.html): every method of the node API with curl examples.
- [config.yaml](https://rings.rs/docs/advanced-topic/config.yaml.html): the complete generated node configuration, section by section.
- [Operate a Node from the CLI](https://rings.rs/docs/cli.html): every `rings` subcommand.
- [Embed via C FFI](https://rings.rs/docs/ffi.html): the C ABI, its lifecycle and ownership rules, and the Python, Swift, and Kotlin wrappers.
- [Browser Node and Extension](https://rings.rs/docs/browser-node.html): the hosted console, running the frontend locally, and packaging the extension.

## Source map

- `crates/core`: Chord DHT, swarm, DID and elliptic-curve identity, message types, DHT storage bounds. Protocol only: application registries and descriptors do not belong here.
- `crates/transport`: the WebRTC transports (`native-webrtc`, `web-sys-webrtc`) and the `dummy` transport that topology tests run on.
- `crates/node`: the node. Native daemon and CLI (`bin/rings.rs`, `src/native`), browser provider (`src/provider/browser`), FFI provider (`src/provider/ffi.rs`, feature `ffi`), extension runtime and built-in protocols (`src/extension`), the privacy layer: onion loops, constant-rate link emission, paid admission, exit registry (`src/onion`), JSON-RPC handlers (`src/rpc_impl.rs`), seeds, peer quotas, registries.
- `crates/rpc`: RPC types and the JSON-RPC client. Its protos are plain data mirrors of node types, never behaviour.
- `crates/codec`: the serde wire codec. `crates/derive`: proc macros, including `wasm_export`.
- `crates/measure`: pure local peer credit and reliability algorithms. `crates/network-policy`: the public-network admission policy for egress targets. `crates/gateway`: the native TUN gateway that carries selected TCP destinations over onion loops (vendored netlink under `vendor/`). `crates/webview`: onion-backed WebView gateway primitives.
- `frontend/`: the Yew/Wasm web app and Chrome MV3 extension, its own Cargo workspace; TypeScript glue in `extension-assets/` and `scripts/` is compiled, never checked in as JavaScript.
- `examples/`: `mailbox` (SMTP in, IMAP out, through a DNS relay or a mail gateway, and the mail gateway `rings-mail-gateway`), `native` and `relay` (workspace members), `dweb` (its own Yew workspace), `ffi` (Python, Swift, Kotlin wrappers and tests), `proof-demo`.
- `docs/`: the mdBook (`book.toml`, `src/`, `theme/`). `papers/`: the whitepaper.

## Build and test

- Toolchain: `rust-toolchain.toml` pins the stable compiler. The workspace denies warnings, so install commands name the pin (`cargo +<pin> install --locked rings-node`); a pin bump updates them in README.md, `crates/node/README.md`, `docs/src/install-a-native-node.md`, and `frontend/src/guide.rs`; formatting requires nightly rustfmt (`cargo +nightly fmt`), and stable rustfmt produces a different layout that fails CI. TOML is formatted with taplo; spelling is checked with typos.
- Native: `cargo build`; `cargo test --release --all --all-targets`; install the CLI with `cargo install --path crates/node`, then `rings init` and `rings run`.
- Wasm: `cargo clippy -p rings-node --features browser --no-default-features --target wasm32-unknown-unknown`; the npm package is built by `wasm-pack build crates/node --release --scope ringsnetwork -t web --no-default-features --features browser_default` (`npm run wasm_pack`); browser tests use wasm-bindgen-test in a headless Chrome.
- Frontend (from `frontend/`): `cargo check --target wasm32-unknown-unknown`; `cargo clippy --target wasm32-unknown-unknown --all-targets -- -D warnings`; `cargo test --release --target wasm32-unknown-unknown`; `trunk serve --release true` to run it. Extension: `npm ci --ignore-scripts && npm run package:frontend-extension` from the root.
- FFI: `cargo build -p rings-node --features ffi`; `RINGS_FFI_REQUIRE_LIBRARY=1 pytest examples/ffi/tests`.
- Docs: `mdbook build docs`; `mdbook serve docs` to preview; a chapter listed in `SUMMARY.md` without a file fails the build.
- Decode-boundary smoke: set `RINGS_DECODE_BOUNDARY_SEED` and `RINGS_DECODE_BOUNDARY_CASES`, then run the `decode_boundary` test filters for `rings-core`, `rings-transport`, and `rings-node`; CI generates these values instead of committing corpora.
- Gates a change must pass (`.github/workflows/qaci.yml`): clippy with `-D warnings` on the native, wasm, and ffi feature sets; `cargo +nightly fmt --check`; rustdoc with `-D warnings`; `taplo format --check`; typos; cargo-deny; Miri on core invariants; the native, wasm, frontend, dweb, gateway, and generated decode-boundary test suites; the docs build.

## Conventions

- Layering: `core` is protocol only; application registries and descriptors live in `node`; `rpc` protos are plain DTO mirrors. Check every new type's vocabulary against the crate it lands in.
- Pure core, imperative shell: keep state transitions pure and explicit, represent effects as data, and confine IO to the interpreter that runs them.
- Lints are declared in the workspace `[lints]`: `unwrap_used`, `expect_used`, `panic`, `todo`, `unimplemented`, and `indexing_slicing` are denied on production paths. Never add `#[allow(..)]` for a quality lint; fix the cause (a struct for too many arguments, `Box` for a large enum variant).
- Imports: one item per `use` line, no glob imports. Prefer method forms (`deref()`, `as_ref()`, `as_slice()`, `clone()`) to `&` and `*` in expressions.
- Tests are deterministic: wait for a concrete event (a generation counter, a channel message, a state predicate), never for a duration; run topology tests on the `dummy` transport.
- Comments and docs explain why, not what. When a behaviour or boundary changes, sweep every comment and document that states it in the same commit.
- Commit messages, pull requests, and reviews are written in English. The workspace version in `Cargo.toml` is bumped with user-visible changes.
- License: AGPL-3.0-only for every crate, the frontend, and the npm package (`LICENSE`, the `license` fields, `deny.toml`). Derived works and network services built on Rings must publish their source; a commercial license for use outside those terms is available from Rings Network on request. Never add code under a license the AGPL cannot absorb.

## Optional

- [crates.io: rings-node](https://crates.io/crates/rings-node), [docs.rs](https://docs.rs/rings-node), [npm: @ringsnetwork/rings-node](https://www.npmjs.com/package/@ringsnetwork/rings-node).
- [Releases](https://github.com/RingsNetwork/rings/releases): prebuilt `rings` for macOS (x86_64, aarch64) and Linux (x86_64 musl), and the wasm package.
- Public seed node: https://node.rings.rs. Its external handshake (`nodeDid`, `answerOffer`) is public; status and control methods need its Bearer token.
- Official X (Twitter) account: https://x.com/RingsNetworkio.