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/.)
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).
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).
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.
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.
Reticulum re-broadcasts announces to spread reachability, but it is aggressively rate limited — this is exactly the discipline Meshtastic and MeshCore lack.
retransmit_timeout = now + rand()·PATHFINDER_RW,
PATHFINDER_RW = 0.5 s (Transport.py:1873) — de-synchronises rebroadcasts to avoid
collision. Grace between retries PATHFINDER_G = 5 s, retries PATHFINDER_R = 1
(Transport.py:63).ANNOUNCE_CAP = 2 (Reticulum.py:114) — at most 2% of
an interface's bandwidth may be spent propagating announces; excess announces are
queued, and fewer-hop announces are prioritised so large fast networks don't drown
small slow ones. This is the single most important idea for LoRa: control traffic is given
a hard, explicit fraction of the channel.announce_rate_table (Transport.py:1838) tracks
each destination's announce interval; repeated violations beyond a grace count trigger a
blocked_until penalty. A misbehaving or flapping node is throttled, not allowed to storm.PATHFINDER_M = 128 (Transport.py:63) bounds propagation depth.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.
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):
self.prv.exchange(self.peer_pub)), then HKDF to derive
the session key. Link request payload = X25519_pub(32) | Ed25519_pub(32) | MTU_sig(≈3)
(Link.py:308).TOKEN_OVERHEAD = 48 bytes
(Cryptography/Token.py:50) = 16-byte IV + 32-byte HMAC; PKCS7 padding adds 1–16 more.SIGLENGTH = 64 bytes (Identity.py:81).RATCHETSIZE = 256 bits = 32 bytes (Identity.py:64),
rotated and advertised in announces.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).
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.
| 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 |