The Transport layer is the central coordinator of a Reticulum node. It manages interfaces, routes packets, maintains tables, and connects all the components we’ve discussed. This chapter explains how it all fits together.
The Transport instance is responsible for:
typedef struct Transport {
// Identity
uint8_t identity_hash[16];
Identity *identity;
// Interfaces
Interface **interfaces;
size_t interface_count;
// Tables
PathTable path_table;
LinkTable link_table;
AnnounceTable announce_table;
ReverseTable reverse_table;
RateLimiter rate_limiter;
// Local destinations
Destination **destinations;
size_t destination_count;
// Deduplication
uint8_t packet_hashes[1024][32];
size_t packet_hash_count;
// Configuration
bool transport_enabled;
TransportMode mode;
} Transport;
typedef enum {
TRANSPORT_MODE_BOUNDARY, // Default - route between interfaces
TRANSPORT_MODE_ACCESS_POINT, // Shorter path TTL
TRANSPORT_MODE_ROAMING, // Very short path TTL
} TransportMode;Transport modes affect how long path entries are cached:
| Mode | Path TTL | Use Case |
|---|---|---|
| BOUNDARY | 7 days | Fixed infrastructure nodes that route between networks |
| ACCESS_POINT | 2 days | Semi-mobile nodes (e.g., vehicle gateway) |
| ROAMING | 1 hour | Mobile nodes that frequently change location |
Shorter TTLs cause more frequent path rediscovery but handle mobility better.
When a packet arrives on an interface:
void transport_receive(Transport *t, Interface *iface,
const uint8_t *raw, size_t raw_len) {
// 1. Parse packet
Packet pkt;
if (!parse_packet(raw, raw_len, &pkt)) {
return; // Invalid packet
}
// 2. IFAC verification (if enabled)
if (iface->ifac_enabled) {
if (!verify_ifac(&pkt, iface->ifac_key)) {
return; // Auth failed
}
// Remove IFAC tag from packet
strip_ifac(&pkt);
}
// 3. Deduplication
uint8_t packet_hash[32];
compute_packet_hash(&pkt, packet_hash);
if (is_duplicate(t, packet_hash)) {
return; // Already seen
}
add_packet_hash(t, packet_hash);
// 4. Process by packet type
switch (pkt.packet_type) {
case PACKET_DATA:
process_data_packet(t, iface, &pkt, packet_hash);
break;
case PACKET_ANNOUNCE:
process_announce_packet(t, iface, &pkt);
break;
case PACKET_LINKREQUEST:
process_link_request(t, iface, &pkt);
break;
case PACKET_PROOF:
process_proof_packet(t, iface, &pkt);
break;
}
// 5. Consider forwarding
if (pkt.hops < MAX_HOPS && should_forward(t, &pkt, iface)) {
pkt.hops++;
forward_packet(t, &pkt, iface);
}
}When sending a packet:
bool transport_send(Transport *t, Packet *pkt) {
// 1. Determine outbound interface(s)
if (pkt->destination_type == DEST_LINK) {
// Link packets - use link table
return send_link_packet(t, pkt);
}
// 2. Check path table for route
PathEntry *path = path_table_lookup(&t->path_table, pkt->destination);
if (path != NULL && !path_expired(path)) {
// Known path - use it
if (path->hops > 0) {
// Multi-hop: add transport header
pkt->header_type = 1; // Header Type 2
memcpy(pkt->transport, path->next_hop, 16);
}
return interface_send(path->interface, pkt);
}
// 3. No path - broadcast on all interfaces
bool sent = false;
for (size_t i = 0; i < t->interface_count; i++) {
if (t->interfaces[i]->enabled && t->interfaces[i]->online) {
if (interface_send(t->interfaces[i], pkt)) {
sent = true;
}
}
}
return sent;
}Header Type 1 (single hop or broadcast):
+------+------+-------------+---------+---------+
|Header| Hops | Destination | Context | Payload |
+------+------+-------------+---------+---------+
Header Type 2 (routed through transport):
+------+------+-------------+-----------+---------+---------+
|Header| Hops | Destination | Transport | Context | Payload |
+------+------+-------------+-----------+---------+---------+
The Transport field contains the next-hop’s transport identity hash.
The forwarding logic handles two fundamentally different cases:
Header Type 2 (Routed) Packets: These packets have a specific next-hop address in the Transport field. When a node receives such a packet, it first checks if the Transport field matches its own identity hash—if not, the packet isn’t meant for this node to route and is ignored. If it matches, the node looks up the final destination in its path table to determine where to send the packet next. If the path table shows zero remaining hops, the destination is directly reachable, so the node converts the packet to Header Type 1 and sends it directly. Otherwise, it updates the Transport field with the next hop’s address and forwards.
Header Type 1 (Broadcast) Packets: These packets have no specific route and should be flooded to all interfaces except the one they arrived on. This is how announces propagate and how packets reach destinations when no path is known.
void forward_packet(Transport *t, Packet *pkt, Interface *received_from) {
if (pkt->header_type == 1) {
// Header Type 2 - routed packet
if (memcmp(pkt->transport, t->identity_hash, 16) != 0) {
// Not for us to route - ignore
return;
}
// We're the next hop - look up path
PathEntry *path = path_table_lookup(&t->path_table, pkt->destination);
if (path == NULL) {
return; // No path
}
if (path->hops == 0) {
// Direct delivery - convert to Header Type 1
pkt->header_type = 0;
interface_send(path->interface, pkt);
} else {
// More hops - update transport field
memcpy(pkt->transport, path->next_hop, 16);
interface_send(path->interface, pkt);
}
} else {
// Header Type 1 - broadcast
// Forward on all interfaces except the one it came from
for (size_t i = 0; i < t->interface_count; i++) {
Interface *iface = t->interfaces[i];
if (iface != received_from && iface->enabled && iface->online) {
interface_send(iface, pkt);
}
}
}
}int remaining_hops(Transport *t, const uint8_t dest[16]) {
PathEntry *path = path_table_lookup(&t->path_table, dest);
if (path == NULL) {
return -1; // Unknown
}
return path->hops;
}The link table tracks established links for routing link packets.
Why a separate table? Regular packets are routed using the path table, which maps destination hashes to next-hop interfaces. But link packets are addressed to Link IDs, not destination hashes. A Link ID is computed from the link establishment handshake and doesn’t appear in any announce—it’s only known to nodes that saw the link request pass through them.
The link table serves two purposes: 1. Forward routing: When a node originates a packet on a link, the link table tells it which interface and next hop to use 2. Reverse routing: When a link request passes through a node, the node records how to send replies (proofs, data) back to the initiator
Each entry stores both directions:
next_hop_transport/next_hop_interface for
outbound packets, and
received_interface/taken_hops for routing
proofs back to the initiator.
typedef struct {
uint8_t link_id[16]; // Key
uint64_t timestamp;
uint8_t next_hop_transport[16];
Interface *next_hop_interface;
int remaining_hops;
Interface *received_interface;
int taken_hops;
uint8_t destination_hash[16];
bool validated;
uint64_t proof_timeout;
} LinkTableEntry;
typedef struct {
LinkTableEntry entries[2048];
size_t count;
} LinkTable;bool send_link_packet(Transport *t, Packet *pkt) {
// Find link in table
LinkTableEntry *entry = link_table_lookup(&t->link_table, pkt->destination);
if (entry == NULL) {
// Unknown link - broadcast
for (size_t i = 0; i < t->interface_count; i++) {
interface_send(t->interfaces[i], pkt);
}
return true;
}
if (entry->remaining_hops == 0) {
// Direct delivery
return interface_send(entry->next_hop_interface, pkt);
} else {
// Routed
pkt->header_type = 1;
memcpy(pkt->transport, entry->next_hop_transport, 16);
return interface_send(entry->next_hop_interface, pkt);
}
}void populate_link_table(Transport *t, Packet *link_request,
Interface *received_from) {
// Compute link ID
uint8_t link_id[16];
compute_link_id(&link_request->header, link_request->destination,
link_request->context,
link_request->payload, link_request->payload_len,
link_id);
// Add to link table
LinkTableEntry *entry = link_table_add(&t->link_table, link_id);
entry->timestamp = time(NULL);
memset(entry->next_hop_transport, 0, 16); // Direct for now
entry->next_hop_interface = received_from;
entry->remaining_hops = 0;
entry->received_interface = received_from;
entry->taken_hops = link_request->hops;
memcpy(entry->destination_hash, link_request->destination, 16);
entry->validated = false;
entry->proof_timeout = time(NULL) + PROOF_TIMEOUT_PER_HOP * (link_request->hops + 1);
}The reverse table enables proofs and replies to reach their originators.
The problem it solves: When Alice sends a packet that gets forwarded through multiple hops to reach Bob, Bob may need to send a proof back to Alice. But Bob doesn’t necessarily know a route to Alice—he only received a packet from the final forwarding node, not directly from Alice.
The solution: Each forwarding node remembers where packets came from for a short time (8 minutes). When Bob’s proof travels back, each node looks up the original packet’s hash in its reverse table and sends the proof back toward wherever the original packet came from.
This creates a breadcrumb trail: the proof follows the original packet’s path in reverse, even though no explicit “route to Alice” was ever established. After 8 minutes, the breadcrumbs expire—this is enough time for any reasonable proof to arrive, but not so long that memory fills up with stale entries.
#define REVERSE_TIMEOUT 480 // 8 minutes
typedef struct {
uint8_t packet_hash[32]; // Key
Interface *received_from;
Interface *outbound_interface;
uint64_t timestamp;
} ReverseTableEntry;
typedef struct {
ReverseTableEntry entries[4096];
size_t count;
} ReverseTable;void record_reverse_path(Transport *t, const uint8_t packet_hash[32],
Interface *received_from,
Interface *outbound_interface) {
ReverseTableEntry *entry = reverse_table_add(&t->reverse_table, packet_hash);
entry->received_from = received_from;
entry->outbound_interface = outbound_interface;
entry->timestamp = time(NULL);
}void route_proof(Transport *t, Packet *proof_pkt,
const uint8_t original_packet_hash[32]) {
ReverseTableEntry *entry = reverse_table_lookup(&t->reverse_table,
original_packet_hash);
if (entry != NULL && !reverse_expired(entry)) {
// Route back the way it came
interface_send(entry->received_from, proof_pkt);
} else {
// No reverse path - broadcast
transport_send(t, proof_pkt);
}
}void transport_register_destination(Transport *t, Destination *dest) {
// Add to destination list
t->destinations = realloc(t->destinations,
(t->destination_count + 1) * sizeof(Destination*));
t->destinations[t->destination_count++] = dest;
// If desired, announce immediately
if (dest->auto_announce) {
send_announce(t, dest);
}
}
Destination* transport_find_destination(Transport *t, const uint8_t hash[16]) {
for (size_t i = 0; i < t->destination_count; i++) {
if (memcmp(t->destinations[i]->hash, hash, 16) == 0) {
return t->destinations[i];
}
}
return NULL;
}void deliver_locally(Transport *t, Packet *pkt, const uint8_t packet_hash[32]) {
Destination *dest = transport_find_destination(t, pkt->destination);
if (dest == NULL) {
return; // Not for us
}
// Decrypt if needed
uint8_t plaintext[PACKET_MTU];
size_t plain_len;
switch (dest->type) {
case DEST_SINGLE:
if (!decrypt_single(dest, pkt->payload, pkt->payload_len,
plaintext, &plain_len)) {
return; // Decryption failed
}
break;
case DEST_GROUP:
if (!decrypt_group(dest, pkt->payload, pkt->payload_len,
plaintext, &plain_len)) {
return;
}
break;
case DEST_PLAIN:
memcpy(plaintext, pkt->payload, pkt->payload_len);
plain_len = pkt->payload_len;
break;
default:
return;
}
// Invoke callback
if (dest->on_packet) {
dest->on_packet(dest, plaintext, plain_len, packet_hash);
}
}#define TABLES_CULL_INTERVAL 5 // seconds
void transport_job_loop(Transport *t) {
static uint64_t last_cull = 0;
uint64_t now = time(NULL);
// Cull expired entries
if (now - last_cull >= TABLES_CULL_INTERVAL) {
cull_path_table(&t->path_table);
cull_link_table(&t->link_table);
cull_reverse_table(&t->reverse_table);
cull_packet_hashes(t);
last_cull = now;
}
// Process pending announces
process_announce_retransmissions(t);
// Process pending link proofs
process_pending_link_proofs(t);
}void cull_path_table(PathTable *pt) {
uint64_t now = time(NULL);
size_t write_idx = 0;
for (size_t i = 0; i < pt->count; i++) {
if (now < pt->entries[i].expires) {
if (write_idx != i) {
pt->entries[write_idx] = pt->entries[i];
}
write_idx++;
}
}
pt->count = write_idx;
}
void cull_link_table(LinkTable *lt) {
uint64_t now = time(NULL);
uint64_t timeout = LINK_STALE_TIME * 1.25;
size_t write_idx = 0;
for (size_t i = 0; i < lt->count; i++) {
if (now - lt->entries[i].timestamp < timeout) {
if (write_idx != i) {
lt->entries[write_idx] = lt->entries[i];
}
write_idx++;
}
}
lt->count = write_idx;
}
void cull_reverse_table(ReverseTable *rt) {
uint64_t now = time(NULL);
size_t write_idx = 0;
for (size_t i = 0; i < rt->count; i++) {
if (now - rt->entries[i].timestamp < REVERSE_TIMEOUT) {
if (write_idx != i) {
rt->entries[write_idx] = rt->entries[i];
}
write_idx++;
}
}
rt->count = write_idx;
}void transport_add_interface(Transport *t, Interface *iface) {
t->interfaces = realloc(t->interfaces,
(t->interface_count + 1) * sizeof(Interface*));
t->interfaces[t->interface_count++] = iface;
// Set up receive callback
iface->on_receive = transport_receive_callback;
iface->transport = t;
// Start interface
interface_start(iface);
}typedef struct {
char *name;
bool enabled;
bool online;
uint64_t tx_bytes;
uint64_t rx_bytes;
uint64_t tx_packets;
uint64_t rx_packets;
} InterfaceStatus;
void get_interface_status(Interface *iface, InterfaceStatus *status) {
status->name = iface->name;
status->enabled = iface->enabled;
status->online = iface->online;
status->tx_bytes = iface->tx_bytes;
status->rx_bytes = iface->rx_bytes;
status->tx_packets = iface->tx_packets;
status->rx_packets = iface->rx_packets;
}Standard routing between interfaces: - Path TTL: 7 days - Full mesh participation - Routes packets for others
For gateways that serve clients: - Path TTL: 1 day (shorter) - Optimized for client-server patterns - Faster path expiration
For mobile nodes: - Path TTL: 6 hours (very short) - Frequent path updates - Handles network changes gracefully
uint64_t get_path_ttl(Transport *t, Interface *iface) {
switch (t->mode) {
case TRANSPORT_MODE_ACCESS_POINT:
return 86400; // 1 day
case TRANSPORT_MODE_ROAMING:
return 21600; // 6 hours
default:
return 604800; // 7 days
}
}Why deduplication is essential: In a mesh network where packets are broadcast and forwarded by multiple nodes, the same packet can arrive at a node multiple times via different paths. Without deduplication: - A node would process the same packet repeatedly, wasting CPU - Forwarding nodes would re-broadcast packets they’ve already forwarded, creating infinite loops - The network would quickly become saturated with duplicate traffic
How it works: Each node maintains a cache of recently-seen packet hashes. When a packet arrives, the node computes its SHA-256 hash and checks if that hash is in the cache. If found, the packet is silently dropped as a duplicate. If not found, the hash is added to the cache and processing continues.
Cache sizing: The cache holds 1024 hashes and uses a FIFO eviction policy—when full, the oldest hash is removed to make room. The 5-minute timeout is a fallback; in practice, the FIFO limit is usually hit first on active networks. The cache size balances memory usage against the risk of false negatives (processing duplicates because the hash was evicted).
#define MAX_PACKET_HASHES 1024
#define PACKET_HASH_TIMEOUT 300 // 5 minutes
typedef struct {
uint8_t hash[32];
uint64_t timestamp;
} PacketHashEntry;
bool is_duplicate(Transport *t, const uint8_t hash[32]) {
for (size_t i = 0; i < t->packet_hash_count; i++) {
if (memcmp(t->packet_hashes[i].hash, hash, 32) == 0) {
return true;
}
}
return false;
}
void add_packet_hash(Transport *t, const uint8_t hash[32]) {
if (t->packet_hash_count >= MAX_PACKET_HASHES) {
// Remove oldest
memmove(&t->packet_hashes[0], &t->packet_hashes[1],
(MAX_PACKET_HASHES - 1) * sizeof(PacketHashEntry));
t->packet_hash_count--;
}
memcpy(t->packet_hashes[t->packet_hash_count].hash, hash, 32);
t->packet_hashes[t->packet_hash_count].timestamp = time(NULL);
t->packet_hash_count++;
}void compute_packet_hash(Packet *pkt, uint8_t hash[32]) {
// Hash the raw packet data
sha256(pkt->raw, pkt->raw_len, hash);
}When an announce arrives, should it update the path table? This decision is critical for routing efficiency.
Reticulum uses a simple priority order:
bool should_update_path(PathTable *pt, const uint8_t dest[16],
int new_hops, uint64_t new_emission_time,
const uint8_t *new_random_blob) {
PathEntry *existing = path_table_lookup(pt, dest);
if (existing == NULL) {
return true; // No existing path - accept
}
if (path_expired(existing)) {
return true; // Existing path expired - accept
}
// Check for replay (same random blob)
if (is_replay(existing, new_random_blob)) {
return false; // Reject replay
}
if (new_hops < existing->hops) {
return true; // Fewer hops - accept
}
if (new_hops == existing->hops) {
// Equal hops: accept if newer emission time
return new_emission_time > existing->emission_time;
}
// More hops - only accept if:
// 1. Path marked unresponsive, OR
// 2. Significantly newer emission time
if (existing->unresponsive) {
return true;
}
return new_emission_time > existing->emission_time;
}| Factor | Considered? | Notes |
|---|---|---|
| Hop count | Yes | Primary criterion |
| Emission timestamp | Yes | Tiebreaker |
| Interface bitrate | No | Used for TX ordering, not routing |
| Link quality (RSSI/SNR) | No | Logged but not used for routing |
| Historical reliability | No | No path quality metrics |
| Geographic distance | No | Not available |
| Latency | No | Would require probing |
Interfaces are sorted by bitrate for transmission priority, not path selection:
// High-bitrate interfaces transmit announces first
void prioritize_interfaces(Transport *t) {
qsort(t->interfaces, t->interface_count, sizeof(Interface*),
compare_by_bitrate_desc);
}This affects announce propagation speed, not which path is chosen.
void transport_run(Transport *t) {
while (t->running) {
// 1. Poll all interfaces for incoming packets
for (size_t i = 0; i < t->interface_count; i++) {
interface_poll(t->interfaces[i]);
}
// 2. Run periodic jobs
transport_job_loop(t);
// 3. Small sleep to prevent busy-waiting
usleep(1000); // 1ms
}
}Application
|
v
+-------------------+
| Destination | Encrypt payload
+-------------------+
|
v
+-------------------+
| Transport | Look up route, build packet
+-------------------+
|
v
+-------------------+
| Interface | HDLC frame, send
+-------------------+
|
v
[Physical Medium]
[Physical Medium]
|
v
+-------------------+
| Interface | HDLC deframe, IFAC verify
+-------------------+
|
v
+-------------------+
| Transport | Deduplicate, route, forward
+-------------------+
|
v
+-------------------+
| Destination | Decrypt, deliver
+-------------------+
|
v
Application
| Table | Key | Purpose | Timeout |
|---|---|---|---|
| Path Table | Destination hash | Route to destinations | 7 days / 1 day / 6 hours |
| Link Table | Link ID | Route link packets | STALE_TIME * 1.25 |
| Reverse Table | Packet hash | Route proofs back | 8 minutes |
| Announce Table | Destination hash | Pending retransmissions | Until sent |
| Packet Hashes | Packet hash | Deduplication | 5 minutes |
| Transport Mode | Path TTL | Use Case |
|---|---|---|
| Boundary | 7 days | Default, full mesh |
| Access Point | 1 day | Gateway nodes |
| Roaming | 6 hours | Mobile nodes |
The Transport layer coordinates: - Interfaces: Physical connections to the network - Routing: Finding paths to destinations - Forwarding: Moving packets toward their destinations - Tables: Maintaining network state - Delivery: Getting packets to local destinations
This completes the core Reticulum protocol documentation. The remaining chapters cover implementation guidance and testing.