← RFC

Reticulum (RNS) Routing Analysis — what's worth borrowing

Scope: Reticulum's addressing, announce/path model, and transport routing, evaluated for what Loomwave should adopt and what does not fit LoRa's bandwidth. Citations are to /workspace/reference/reticulum/RNS/ at the checked-out commit. (Per the project brief, the reticulum-network-stack/RNS/ path is read here as reticulum/RNS/.)


1. The model in one paragraph

Reticulum is address-based, announce-driven, next-hop transport routing. Every destination has a cryptographic address (a truncated hash of its identity + name). Destinations periodically announce; transport nodes record, from each announce, the next hop and interface toward that destination and the hop count, then re-broadcast the announce under strict rate control. To send to a destination, a node looks up the path table, and if it is more than one hop away, rewrites the packet into a 2-address "transport" header (next-hop transport_id + destination hash) and hands it to the recorded interface. This is a clean separation of discovery (announces, flooded with rate control) from forwarding (table-driven next-hop), and it is the most directly relevant prior art for Loomwave's routing layer.

The catch: Reticulum was designed for heterogeneous links with ≥500-byte MTU, not for a single shared 255-byte LoRa channel. The model is excellent; several constants and packet sizes are too heavy and must be re-budgeted (§7).


2. Addressing — cryptographic, self-certifying

Addresses are truncated SHA-256 hashes; there is no central allocation.

# Reticulum.py:145
TRUNCATED_HASHLENGTH = 128                      # bits -> 16-byte addresses
HEADER_MINSIZE   = 2+1+(TRUNCATED_HASHLENGTH//8)*1   # = 19 bytes (HEADER_1)
HEADER_MAXSIZE   = 2+1+(TRUNCATED_HASHLENGTH//8)*2   # = 35 bytes (HEADER_2, transport)
# Destination.py:116  hash()
name_hash = Identity.full_hash(expand_name(...))[:NAME_HASH_LENGTH//8]   # 10 bytes
addr_hash_material = name_hash + identity.hash                            # +16 bytes
return Identity.full_hash(addr_hash_material)[:TRUNCATED_HASHLENGTH//8]   # 16-byte address

The identity itself is full_hash(public_key)[:16] (Identity.truncated_hash, Identity.py:383), where the public key is 64 bytes = 32-byte X25519 (encryption) + 32-byte Ed25519 (signing) (KEYSIZE = 256*2, Identity.py:59).

Why this matters for Loomwave: the address is a commitment to a public key, so any node can verify that a claimed sender owns its address — no PKI, no key servers. This is the property we want for authenticated, infrastructure-free operation. The 16-byte address is larger than we can afford on every LoRa packet, but the scheme (truncated hash of an Ed25519/X25519 identity) is adoptable with a shorter truncation (see security-options.md).


3. The announce — a signed, self-validating path seed

Destination.announce() (Destination.py:243) builds:

announce_data = public_key(64) | name_hash(10) | random_hash(10) | ratchet(0|32) | signature(64) | app_data(var)
signed_data   = dest_hash(16) | public_key(64) | name_hash(10) | random_hash(10) | ratchet | app_data
signature     = Ed25519_sign(identity, signed_data)        # SIGLENGTH = 64 bytes

random_hash = 5 random bytes + 5 timestamp bytes (freshness + duplicate detection).

Validation is fully self-contained (Identity.validate_announce(), Identity.py:532): 1. Recover the public key from the announce. 2. Verify the Ed25519 signature over signed_data. 3. Recompute expected_hash = full_hash(name_hash + truncated_hash(public_key))[:16] and require it equals the announce's destination hash.

If both pass, the identity/key is remembered (Identity.remember) and the path is learned. A forged announce cannot pass step 3 without breaking the hash, and cannot pass step 2 without the private key. This is the gold standard: discovery traffic is cryptographically unforgeable, with zero prior shared state.


4. Path table — next-hop, hops, expiry

The path table is a dict keyed by destination hash (Transport.py:114, path_table = {}), with entries indexed by named offsets (Transport.py:3547):

IDX_PT_TIMESTAMP=0  IDX_PT_NEXT_HOP=1  IDX_PT_HOPS=2  IDX_PT_EXPIRES=3
IDX_PT_RANDBLOBS=4  IDX_PT_RVCD_IF=5   IDX_PT_PACKET=6

When an announce is accepted (Transport.py:1833), Transport records: - next_hop = the address of the node we received the announce from (the previous transport hop), - hops = packet.hops (the hop counter incremented at each forwarding node), - receiving_interface = where to send replies, - expires = now + PATHFINDER_E (default 7 days, Transport.py:71; shorter for access-point / roaming interfaces), - random_blobs = recent announce nonces (dedup; capped at MAX_RANDOM_BLOBS).

Crucially, the table stores only the next hop and a hop count, not the full path. This is distance-vector, not source-routing (the MeshCore contrast): the header stays small (one next-hop address) regardless of path length, at the cost of each transit node needing its own table entry.


5. Announce propagation and rate control — the part to copy carefully

Reticulum re-broadcasts announces to spread reachability, but it is aggressively rate limited — this is exactly the discipline Meshtastic and MeshCore lack.

This converts "announces flood the network" into "announces fill a bounded, prioritised 2% of channel, newest/closest first." Loomwave should adopt the ANNOUNCE_CAP concept directly as the governor on its discovery plane.


6. Forwarding — header rewrite to a 2-address transport packet

When sending to a known multi-hop destination (Transport.outbound, Transport.py:1121):

if path_entry[IDX_PT_HOPS] > 1 and packet.header_type == HEADER_1:
    new_flags = (HEADER_2 << 6) | (TRANSPORT << 4) | (packet.flags & 0x0F)
    new_raw  = struct.pack("!B", new_flags) + packet.raw[1:2]   # flags + hops
    new_raw += path_entry[IDX_PT_NEXT_HOP]                       # 16B next-hop transport_id
    new_raw += packet.raw[2:]                                    # original dest_hash + ciphertext
    Transport.transmit(path_entry[IDX_PT_RVCD_IF], new_raw)

Packet header types (Packet.py:66): HEADER_1 (direct, 1 address) and HEADER_2 (transport, 2 addresses). HEADER_2 layout (Packet.py:255):

[flags(1)][hops(1)][transport_id(16)][dest_hash(16)][context(1)][ciphertext]

So the in-transit overhead is 35 bytes of header plus ciphertext. Each transit node matches transport_id against itself, looks up the destination in its own path table, and re-emits toward the next hop — a textbook distance-vector forward. hops is incremented per hop for loop bounding and path-length comparison.


Link establishes an ephemeral encrypted session over the routed substrate (Link.py:233, handshake(), Link.py:353):

The bandwidth reality check. Reticulum's MTU is 500 bytes (Reticulum.py:93) and its MDU = MTU − HEADER_MAXSIZE − IFAC = 500 − 35 − … (Reticulum.py:152). A single LoRa frame is at most ~255 bytes, and at the default LongFast a 237-byte frame is ~2.0 s of airtime. Consequently:

Reticulum element Size LoRa verdict
Address (per packet) 16 B Too heavy at 1–2/packet; truncate to 4–8 B for Loomwave
HEADER_2 (transport) 35 B ~14% of a max LoRa frame in header alone
Announce (no app_data) 64+10+10+64 = 148 B (+32 ratchet = 180 B) Fits one frame but ~0.9–1.5 s airtime each
Token overhead 48 B Heavy per-message; a 16-byte tag is the LoRa-appropriate target
Ed25519 sig 64 B Use only where unforgeability is essential (adverts), not per data packet

The MDU still fits a LoRa frame, but the header + crypto overhead (35 + 48 = 83 bytes) would consume a third of a 255-byte frame before any payload. Loomwave must keep the architecture (self-certifying addresses, signed announces, capped propagation, next-hop table) while shrinking every field to a LoRa budget (see security-options.md and routing-options.md).


8. What to borrow / what to leave

Borrow (directly): 1. Self-certifying addresses = truncated hash of an Ed25519/X25519 identity. Verifiable ownership with no infrastructure. (Truncate harder than 16 B for LoRa.) 2. Signed, self-validating announces (validate_announce) as the discovery primitive — unforgeable, stateless to verify. This is strictly better than Meshtastic's unsigned node info and on par with MeshCore's signed adverts, but with the address↔key binding proof. 3. Distance-vector next-hop path table (path_table, next-hop + hops + expiry + iface). Keeps headers small regardless of path length — the key advantage over MeshCore source routing for multi-hop. INFRA nodes are the natural "transport nodes." 4. ANNOUNCE_CAP — a hard, prioritised fraction of channel for control (2%, fewer-hops first) plus per-destination announce rate limiting. This is the governor that keeps discovery from becoming a storm; it directly informs Loomwave's control-overhead budget. 5. Header-rewrite forwarding (HEADER_1 ↔ HEADER_2) so the same packet can be either locally addressed or carry an explicit next hop — clean and small. 6. Ratchet-based forward secrecy as an optional enhancement on long-lived links.

Leave / re-budget: - 500-byte MTU and 16-byte addresses — incompatible with a 255-byte LoRa frame; truncate addresses and cap payloads to the LoRa MDU. - 48-byte token overhead per message — replace with a single 8–16-byte AEAD tag for data. - 7-day path expiry — far too long for churn-prone LoRa meshes; expiry must track scenario (minutes for events, hours for neighbourhood, see routing-options.md). - Per-packet Ed25519 signatures — reserve 64-byte signatures for adverts/announces; authenticate data with a short symmetric MAC. - Reticulum's assumption of always-on transport nodes with spare bandwidth — on LoRa the transport (INFRA/REPEATER) nodes are the scarce resource; announce cadence and path expiry must be set by channel budget, not wall-clock convenience.


Source index (files cited)

Topic File:symbol
Address length / header sizes Reticulum.py:145-152
Destination hash Destination.py:hash:116
Identity hash / key size Identity.py:truncated_hash:383, KEYSIZE:59
Announce build Destination.py:announce:243
Announce validate Identity.py:validate_announce:532
Path table fields Transport.py:3547, path_table:114
Announce intake / path learn Transport.py:1833
Rate control / cap Reticulum.py:ANNOUNCE_CAP:114, Transport.py:announce_rate_table:1838, PATHFINDER_*:63-71
Forwarding / header rewrite Transport.py:outbound:1121, Packet.py:66, :255
Link / handshake Link.py:233, handshake:353, :308
Token / crypto overhead Cryptography/Token.py:50, Identity.py:SIGLENGTH:81, RATCHETSIZE:64, TOKEN_OVERHEAD:77
MTU Reticulum.py:MTU:93, MDU:152