This document explains how Qortal Hub integrates the Reticulum Network Stack (RNS) for decentralised, end-to-end encrypted peer-to-peer communication.
Reticulum is a cryptography-based networking stack designed for reliable communication over any medium. In Qortal Hub it serves as the transport layer for:
- Presence — signed, encrypted envelopes broadcast to overlay peers so users see each other as online.
- Direct voice calls — peer-to-peer audio with room IDs of the form
dmv:<sha256_prefix>. - Group voice calls — multi-party audio with topology-aware forwarding (see
docs/group-audio-calls.md). - Q-Chat file transfers — encrypted file offers and deliveries with a 2-hour TTL.
Renderer (React)
│ window.electronAPI.reticulum*()
│ window.groupCall.*()
▼
Electron main process
│ reticulum-daemon.ts — spawns and manages rnsd
│ reticulum-bridge.ts — commands the Python bridge
│ reticulum-mesh.ts — hub-to-hub mesh coordinator
│ group-call.ts — group call manager
▼
Python bridge (presence_bridge.py) ← fd3/fd4 binary IPC
▼
rnsd (Reticulum Network Stack daemon)
▼
Network (LAN AutoInterface / hub TCPClient+BackboneInterface)
Manages the rnsd process (module RNS.Utilities.rnsd).
Python resolution order:
- PyInstaller one-file binary under
resources/reticulum/(packaged builds). - venv under
resources/reticulum-runtime/venv/(dev/optional). - System Python with
rnsinstalled (pip install rns) — no env var needed. - System Python when
QORTAL_RETICULUM_SYSTEM=1is set.
Set QORTAL_RETICULUM_NO_SYSTEM=1 to opt out of (3) and (4) in dev.
Key paths and ports:
| Item | Value |
|---|---|
| Config directory | appData/qortal-hub/reticulum — shared by all local app instances |
| Daemon base port | 37428 + N where N is the instance index |
| Control base port | 37429 + N |
| Instance name | qortal-hub-shared |
| Discovery announce interval | 5 minutes |
| Daemon stop timeout | 10 seconds |
Multi-instance behaviour: The daemon is shared across all open Hub windows. A JSON instance registry tracks active app PIDs. On quit, the daemon is only stopped when no other instance is still active (ref-counted).
Process priority: On startup, the daemon PID is re-niced to -7 by default. Override with QORTAL_RETICULUM_PRIORITY_NICE=<value> or disable with QORTAL_RETICULUM_PRIORITY_NICE=off.
Reachability states reported by the daemon:
| State | Meaning |
|---|---|
unknown |
Status not yet determined |
lan-only |
Reachable only on the local network |
hub-connected |
Connected to at least one bootstrap hub |
disconnected |
No RNS connectivity |
A Python subprocess (presence_bridge.py) launched after the daemon is ready.
- Communicates with the Electron main process via extra stdio file descriptors:
- fd 3 — Electron → Python (commands + outbound audio batches)
- fd 4 — Python → Electron (events + inbound audio batches)
- Commands use a JSON request/response model with frame IDs for correlation.
- Audio frames use a separate binary format (see Audio IPC below).
Bridge states: stopped → starting → ready / degraded
Key bridge actions:
| Action | Purpose |
|---|---|
publish_presence / forward_presence |
Broadcast signed presence envelopes |
send_group_call / fanout_group_call |
Group call signaling |
open_group_audio_link |
Establish a Reticulum audio link to a peer |
send_group_audio_link_heartbeat |
Keep audio links alive |
accept_qchat_file_resource |
Accept an incoming file offer |
send_qchat_file_resource |
Send a file offer to a peer |
authorize_qchat_file_resource |
Authorise file delivery |
Q-App backend traffic is isolated from Hub control traffic and from its own realtime data path:
qapp-rpc-*handles bounded request/response work.qapp-realtime-*handles gameplay and other realtime sends.qapp-lifecycle-*handles connection setup and teardown.
Each class is deterministically sharded by Q-App owner and backend destination. Once an RPC link is established, waiting for its response is callback-driven; it does not occupy a scheduler worker. The Electron manager additionally caps pending RPCs and logical connections both per Q-App owner and globally so one Q-App—or many malfunctioning owners—cannot exhaust shared bridge capacity.
Q-App stream sends use raw partial writes, not Python BufferedWriter: the
Reticulum raw writer can return zero while its channel window is full, which
can make buffered flush() spin indefinitely. Sends have a ten-second deadline
and run in a bounded pool (eight workers, 64 pending jobs), isolated from the
Q-App scheduler. A failed or stalled write detaches its physical Link and
schedules the existing reconnect flow. Every new stream gets a new write lock;
late writes and reader failures remain tied to the old Link. Queue pressure
alone does not reset a healthy Link. Failed sends return an error and release
their pending acknowledgement reservation instead of reporting success.
Desktop Q-Apps run in isolated guest web contents. The shell registers each
guest with Electron main using a per-guest attach token bound to a trusted tab,
service, and name. The guest page has no Node API. Its preload forwards the
existing Q-App request messages to the owning shell view and retains reply
ports. Each app's browser storage has a separate persistent partition. Main
blocks navigation to another app or origin while keeping the guest's owner.
Full navigation (including refresh) starts resource cleanup on
did-start-navigation before the new document can create connections.
Same-document navigation retains connections. Tab removal and shell
shutdown/crash also clean registered resources. Browser and mobile builds keep
the iframe bridge.
Reticulum, private channels, MoQ, and file saves snapshot their old resources
at the same time; slow shutdown in one manager must not delay another manager's
snapshot until after the new page connects. Never trigger owner-wide cleanup
from the guest's load event: new-page scripts can already have connected.
Reticulum setup checks ownership again after native setup returns and rejects
connections cancelled in flight instead of returning a dead connected handle.
Handles hub-to-hub mesh networking, separate from the TLS P2P layer (p2p-network.ts).
Interface types:
| Platform | Listen interface | Outbound hub interface |
|---|---|---|
| Linux | BackboneInterface (port 4243) |
BackboneInterface |
| Windows / macOS | TCPServerInterface (port 4243) |
TCPClientInterface |
Community mesh discovery uses RNS AutoInterface with autoconnect_discovered_interfaces capped at 8 peers.
Default bootstrap hubs:
| Name | Host | Port |
|---|---|---|
| Backbone Client Qortal Hub | phantom.mobilefabrik.com |
4400 |
| Crowetic Reticulum Hub | reticulum.qortal.link |
4444 |
| Crowetic Reticulum Hub 2 | reticulum2.qortal.link |
4444 |
UPnP: The coordinator attempts to map the mesh listen port via UPnP and records the discovered WAN IP in reachable_on so remote peers can reach this node. A manually configured meshReachableOnHost always takes precedence over the UPnP-discovered value.
Persistent state is stored in appData/qortal-hub/reticulum-mesh-state.json.
Binary message format used on fd 3/fd 4 for audio frames:
Header:
magic: "QAUD" (4 bytes, ASCII)
version: 1 (1 byte)
bodyLen: uint32 (4 bytes)
Body (one or more frames):
frame_count
linkIdLen | linkId
roomIdLen | roomId
peerPresenceHashLen | peerPresenceHash
peerCallHashLen | peerCallHash
payloadLen | payload
- The bridge batches multiple frames per message to reduce syscall overhead.
- Fairness and pressure control prevent any single peer leg from starving others.
- Buffer objects carry timing metadata via
Symbol.for('qortal.*')symbols.
For the full audio path (capture → Opus → encrypt → send → receive → decrypt → playout) see docs/group-audio-calls.md.
Qortal Hub uses two separate Reticulum identities with different lifetimes and scopes.
Source: presence_bridge.py → ensure_identity()
Stored at: userData/reticulum/presence-bridge.identity
On first startup the Python bridge checks whether the file exists:
- File found → loaded with
RNS.Identity.from_file()— the same identity is reused every time. - File missing → a new
RNS.Identity()is generated and immediately written to disk.
The file path is passed from the Electron main process via the QORTAL_RETICULUM_IDENTITY_PATH environment variable (set in reticulum-bridge.ts → spawnAndHandshake()). The resulting public key is what reticulumGetLocalIdentityPublicKeyBase64() returns and is embedded as the rk field in GC_JOIN messages for voice call authentication.
Conclusion: unique per installation, generated once, persisted across restarts.
Source: reticulum-mesh-store.ts → getBundledMeshNetworkIdentityPath()
Stored at: userData/reticulum/mesh-network.identity (copied from app bundle)
This file is shipped inside every Qortal Hub app bundle (resources/reticulum/mesh-network.identity) and is identical for all installations. On first use ensureMeshNetworkIdentityIfNeeded() copies it into userData. It is referenced as network_identity in the managed Reticulum config so that all Hub instances join the same authenticated mesh segment and can discover each other's private gateways.
Conclusion: shared by all Hub users, not generated — bundled with the app.
| Identity | Scope | Generated | Stored |
|---|---|---|---|
| Local bridge identity | Per installation | Once, on first startup | userData/reticulum/presence-bridge.identity |
| Mesh network identity | All Hub installations | Never (bundled) | userData/reticulum/mesh-network.identity (copied from bundle) |
recoverReticulumStateForAppLaunch()— cleans up any orphaned daemon from a previous crash.startReticulumForAppLaunch()— spawns the daemon and waits up to 10 seconds for it to be ready.ensureReticulumManagersStarted()— starts the presence manager, call manager, and group call manager.
planReticulumAppQuit()— checks the instance registry;shouldStopSharedDaemonis true only when this is the last active instance.stopReticulumBridge()— terminates the Python bridge process.stopReticulumMeshCoordinator()— tears down mesh UPnP mappings and stops mesh state.- Daemon process is stopped only if
shouldStopSharedDaemonis true.
Exposed via window.electronAPI (context bridge in electron/src/preload.ts):
| Method | Description |
|---|---|
reticulumGetStatus() |
One-shot snapshot: daemon PID, mode, bridge state, reachability, hub interface counts |
onReticulumStatus(cb) |
Subscribe to live status updates; returns an unsubscribe function |
reticulumGetOverlayPeers() |
List of active Reticulum links with presence hashes and connection timestamps |
reticulumGetMeshStatus() |
Mesh enable flag, listen port, UPnP state, reachability hosts |
reticulumEnsureMeshNetworkIdentity() |
Create the mesh identity file if it does not exist yet |
reticulumGetLocalDestinationHash() |
This instance's 32-char hex RNS destination hash |
reticulumGetLocalIdentityPublicKeyBase64() |
RNS.Identity public key (used in GC_JOIN messages) |
- The renderer calls
window.groupCall.gcallProxySignPresenceMessage()to sign a presence envelope with the wallet key. - The Electron main process forwards the signed envelope to the Python bridge via
publish_presence. - The bridge broadcasts it over Reticulum links to connected peers.
- Incoming presence envelopes are forwarded back to the renderer via the
reticulum:presenceUpdateIPC event. - Subscriptions are batched on a 16 ms interval in the preload layer to avoid flooding the renderer.
Files are offered and delivered over Reticulum links with the following constraints:
| Constraint | Value |
|---|---|
| Offer TTL | 2 hours |
| Completed-send cache grace | 7 days |
| Signature max age | 24 hours |
| Signature max future skew | 2 minutes |
| Bridge attach retry interval | 3 seconds |
| Variable | Effect |
|---|---|
QORTAL_RETICULUM_SYSTEM=1 |
Force use of system Python even in packaged builds |
QORTAL_RETICULUM_NO_SYSTEM=1 |
Disable system Python fallback in dev |
QORTAL_RETICULUM_PRIORITY_NICE=<n> |
Override rnsd process nice value (default -7) |
QORTAL_RETICULUM_PRIORITY_NICE=off |
Disable nice adjustment entirely |
| File | Purpose |
|---|---|
electron/src/reticulum-daemon.ts |
rnsd process lifecycle, config generation, instance registry |
electron/src/reticulum-bridge.ts |
Python bridge process, command/event protocol, audio IPC |
electron/src/reticulum-launch.ts |
App-launch readiness wait |
electron/src/reticulum-mesh.ts |
Hub-to-hub mesh coordinator, UPnP |
electron/src/reticulum-mesh-store.ts |
Persistent mesh state (listen port, UPnP, reachable host) |
electron/src/reticulum-mesh-constants.ts |
Default listen port (4243), max outbound peers (8) |
electron/src/reticulum-audio-ipc.ts |
Binary audio frame encoding/decoding (QAUD format) |
electron/src/reticulum-audio-link-fallback-policy.ts |
Link quality decisions and fallback policy |
electron/src/reticulum-bridge-rebind.ts |
Bridge consumer rebinding for multi-instance scenarios |
electron/src/group-call-wire-reticulum.ts |
Group call wire protocol over Reticulum |
electron/src/preload.ts |
Context bridge — window.electronAPI.reticulum* methods |
electron/resources/presence_bridge.py |
Python bridge process (presence, calls, file transfers) |