This document specifies the wire format of every datagram a LOTI node sends to another
node — the on-the-wire protocol spoken between running lotid daemons. It is a self-contained
reference: no knowledge of the simulator (or of OMNeT++) is required or assumed.
A node sends exactly three kinds of datagram. Each is a flat, length-prefixed byte string
built by the codec in src/core/wire/: the message grammar is in
packets.hpp / packets.cpp, and the
primitive read/write rules in codec.hpp. The field types are the
plain domain structs in src/core/domain/types.hpp.
One serializer, both runtimes. These exact bytes are produced by
loti-core. The deployable daemon (lotid) puts them on a real UDP socket; the research simulator wraps the identical bytes so its bandwidth statistics predict production. This document describes those bytes — i.e. the real protocol.
- Datagram protocol: UDP over IPv4 (
AF_INET,SOCK_DGRAM). Each LOTI message is carried in exactly one UDP datagram; a datagram is self-delimiting (all variable fields are length-prefixed), so there is no framing, no length header, and no message spans two datagrams. - Port: chosen per node at launch (
lotid --port <p>); there is no protocol-mandated port. A peer is addressed asid:ip:portand the node keeps aNodeId → (IP, port)table for its neighbors. - Addressing: a node only ever sends to directly-configured neighbors. Every datagram
carries the sender's
NodeIdin its header (see below); the receiver uses it to identify which neighbor sent the packet. Multi-hop discovery is achieved by neighbor-to-neighbor forwarding, not end-to-end addressing. An outbound datagram to an unknown next hop is silently dropped. The next hop(s) for a chain request are chosen by a pluggable, time-dependent forwarding policy (a single static next hop, or a bounded flood over the neighbors cross-linked in the query's time window); the response follows the reverse-path breadcrumb the request recorded rather than any routing table. - Maximum size: the receive buffer is 65535 bytes (one UDP datagram). Chain responses are the only messages that can approach this; the protocol is designed so a chain fits in a single datagram.
Every field is one of a small set of primitives, concatenated with no padding or alignment. All multi-byte integers are big-endian (network byte order).
| Primitive | Layout | Notes |
|---|---|---|
u8 |
1 byte | Used for the message type tag. |
u32 |
4 bytes, big-endian | Used only as a length / count prefix. |
u64 |
8 bytes, big-endian | Salts and timestamps. |
NodeId |
16 raw bytes (fixed) | A node's 128-bit identity — written as 16 bytes, not an integer. |
blob |
u32 length then length raw bytes |
Any variable-length byte string (hashes, content, signatures). A zero-length blob is 4 bytes (length = 0). |
array<T> |
u32 count then count × T |
Repeated elements, each encoded in turn. |
The decoder (Reader) validates every length against the remaining
buffer and throws wire: truncated datagram on underrun, so a short or malformed datagram is
rejected rather than misread.
How each logical field type maps onto the primitives above:
| Field type | Wire encoding | Typical size | Meaning |
|---|---|---|---|
NodeId |
16 raw bytes | 16 B | A node's 128-bit identity — the first 16 bytes of SHA-256(pubkey). |
Salt |
u64 |
8 B | Random salt folded into a hash. |
Timestamp |
u64 |
8 B | A raw clock tick (nanoseconds in production), signed 64-bit reinterpreted as u64. |
TimeRange |
two u64 (lo, hi) |
16 B | A closed [lo, hi] window of Timestamp ticks — a discovery's estimated time window. |
EventHash |
blob |
36 B | A SHA-256 digest → 4 (length) + 32. |
content data |
blob |
4 + n B | Arbitrary event payload bytes. |
Signature |
blob |
4 B unsigned · 68 B signed | Empty (length = 0) when the node is unsigned; an Ed25519 signature → 4 + 64 when signed. |
Timestamp is a plain 64-bit integer clock tick — nanoseconds since the epoch on a real node. It is not a floating-point or platform-specific time type; it is written with the same big-endian
u64rule as every other integer.
Every datagram begins with the same 17-byte header (write_header in
packets.cpp):
| Offset | Size | Field | Encoding | Meaning |
|---|---|---|---|---|
| 0 | 1 B | type |
u8 |
Message type: 0 = clock notification, 1 = chain request, 2 = chain response. Selects how the rest of the datagram is parsed. |
| 1 | 16 B | sender |
NodeId |
The sender's own NodeId. Lets the receiver identify the neighbor; the payload that follows never repeats it. |
0 1 17
+--------+------------------------------------------+
| type | sender |
| u8 | NodeId (16 B) |
+--------+------------------------------------------+
1 B 16 B
Header size: 17 bytes (fixed, on every datagram).
The three type values and their payloads:
type |
Name | Payload | Direction |
|---|---|---|---|
0 |
Clock notification | ClockNotification |
broadcast to every neighbor |
1 |
Chain request | ChainRequest |
forwarded toward the event's creator |
2 |
Chain response | ChainResponse |
forwarded back toward the originator |
When: a node emits one to each neighbor every time it creates a new local clock event,
on any of its clock chains. This is the continuous background gossip that hash-links every
node's clock DAG. A node runs several independent, geometrically-spaced clock chains (see
implementation.md), so the notification
carries a chain id alongside the two hashes — never the clock event's contents.
Payload after the 17-byte header:
| Field | Encoding | Meaning |
|---|---|---|
chain |
u64 |
Which of the sender's clock chains this notification is about (0 = fastest). Advertised change-only: a chain's tip is (re-)sent only when it ticks. |
last_clock_event_hash |
blob (EventHash) |
Hash of the sender's newest local clock event on that chain. |
neighbor_last_clock_event_hash |
blob (EventHash) |
Hash of the newest clock event the sender has heard from this neighbor on that chain — the back-reference that stitches the two DAGs together. |
+---------- header 17 B ----------+---- u64 ----+------ blob ------+------ blob ------+
| type=0 | sender NodeId | chain | last_clock_hash | neighbor_last… |
+--------+------------------------+-------------+------------------+------------------+
16 B 8 B \--- 4 + 32 B ---/ \--- 4 + 32 B ---/
Size (SHA-256 hashes): 17 + 8 + 36 + 36 = 97 bytes (fixed).
On receipt, the node records the neighbor's newest hash for that chain, and, if it recognizes
neighbor_last_clock_event_hash as one of its own clock events, records the reverse cross-link.
When: a node (the originator) starts an event-chain discovery for an event it did not create, or an intermediate node forwards such a request one hop closer to the event's creator. Routed toward the creator via each node's next-hop table.
Payload after the 17-byte header:
| Field | Encoding | Meaning |
|---|---|---|
originator |
NodeId (16 B) |
The node that started the discovery; the response is routed back to it. Unchanged across every hop. |
event.creator |
NodeId (16 B) |
Creator of the target event — the routing destination. |
event.hash |
blob (EventHash) |
Hash of the target event. |
range |
TimeRange (16 B) |
The querying party's estimated time window for the event; forwarding routes over the overlay as it was within it. |
hop_limit |
u64 |
Maximum forward hops (0 = unlimited) — bounds a flood's depth. |
path |
array<NodeId> |
The reverse-path breadcrumb: the request's forward path of senders [originator, …, previous hop]. It doubles as the per-copy visited set (a node forwards only to neighbors not already on it), and the creator reflects the response back along it. |
(event.creator + event.hash together are an EventReference, inlined directly into the datagram.)
+-- header 17 B --+-- NodeId --+-- NodeId --+- blob -+- TimeRange -+-- u64 --+- array<NodeId> --+
| type=1 | sender | orig. 16 B | creat. 16 B| e.hash | lo | hi | hop_lim | u32 n · n·16 B |
+--------+--------+------------+------------+--------+-------------+---------+------------------+
16 B 16 B 4 + 32 B 16 B 8 B 4 + 16·n (breadcrumb)
Size (SHA-256 hash): 17 + 16 + 16 + 36 + 16 + 8 + (4 + 16·h) = 113 + 16·h bytes, where h
is the breadcrumb length (one entry per hop travelled so far). Variable — one 16-byte NodeId is
appended to the breadcrumb at each hop.
Routing: if event.creator is this node, it builds the enclosing chain locally and replies with a
chain response. Otherwise it appends itself to path and forwards a copy to each next hop its
forwarding policy chooses — the single static next hop, or, under the flood policy, every neighbor
it cross-linked with in range that is not already on the breadcrumb (see
implementation.md and src/core/routing/).
When: the creator builds the initial EventChain and replies; each intermediate node, on the
way back, extends the chain with its own clock-event bounds before forwarding it one hop closer
to the originator — along the reverse-path breadcrumb the request recorded, not via a routing
table (the response never re-routes).
Payload after the 17-byte header:
| Field | Encoding | Meaning |
|---|---|---|
originator |
NodeId (16 B) |
Copied from the request; identifies the discovery. The response is home when it reaches this node. |
chain |
EventChain (see below) |
The accreting proof: the target event plus a lower- and upper-bound run of clock events. Grows at each hop. |
path |
array<NodeId> |
The remaining reverse-path breadcrumb toward the originator: the next hop is path.back(), popped at each hop; empty once the response reaches the originator. |
+---------- header 17 B ----------+-- NodeId --+-- EventChain (var) --+- array<NodeId> --+
| type=2 | sender NodeId | orig. 16 B | event·lower[]·upper[]| u32 n · n·16 B |
+--------+------------------------+------------+----------------------+------------------+
16 B 16 B (variable) 4 + 16·n (remaining)
Size: 17 + 16 + sizeof(EventChain) + (4 + 16·h) bytes (variable — the largest datagram),
where h is the remaining breadcrumb length. The size is dominated by the number of clock events
accreted into the chain: one per hop on each side, each further inflated by its referenced-event
list and (if present) its signature.
These structures appear only within EventChain. Field order below is the exact byte order the
codec writes (codec.hpp).
A typed pointer to an event or clock event.
| Field | Encoding | Size |
|---|---|---|
creator |
NodeId |
16 B |
hash |
blob (EventHash) |
4 + 32 B |
The target content event at the center of a chain. d = content length, r = referenced-event count.
| Field | Encoding | Size |
|---|---|---|
creator |
NodeId |
16 B |
hash |
blob (EventHash) |
4 + 32 B |
data |
blob |
4 + d B |
salt |
u64 |
8 B |
referenced_events |
array<EventReference> |
4 + 52·r B |
signature |
blob (Signature) |
4 B unsigned · 68 B signed |
The event content
datais on the wire. A chain response transmits the target event's full payload, not just its hash. (Note: the separate byte-accounting helper used for bandwidth statistics,event_size_bytesinsrc/core/hash/hashing.hpp, deliberately excludesdata— that is a modeling choice for size statistics, not a claim about the encoded bytes. The actual datagram includes it.)
A timestamped node of the clock DAG; a chain carries many of them. r = referenced-event count.
chain — which of the node's multi-resolution clock chains this event belongs to (0 = fastest)
— is part of the hashed content, so a clock event cannot lie about its chain.
| Field | Encoding | Size |
|---|---|---|
creator |
NodeId |
16 B |
hash |
blob (EventHash) |
4 + 32 B |
chain |
u64 |
8 B |
timestamp |
u64 |
8 B |
salt |
u64 |
8 B |
referenced_events |
array<EventReference> |
4 + 52·r B |
signature |
blob (Signature) |
4 B unsigned · 68 B signed |
The proof structure. Note the wire order: the target event is written first, then the
lower-bound run, then the upper-bound run (logically the chain reads lower · event · upper, but
the bytes lead with the event).
| Order | Field | Encoding |
|---|---|---|
| 1 | event |
one Event |
| 2 | lower_bound |
array<ClockEvent> (u32 count, then that many clock events) |
| 3 | upper_bound |
array<ClockEvent> (u32 count, then that many clock events) |
sizeof(EventChain) = sizeof(Event)
+ 4 + Σ_lower sizeof(ClockEvent)
+ 4 + Σ_upper sizeof(ClockEvent)
Sizes assume SHA-256 (32-byte) hashes and unsigned events (4-byte empty signatures). Signing
adds 64 bytes per signed Event/ClockEvent.
| Datagram | type |
Size | Fixed? |
|---|---|---|---|
| Clock notification | 0 | 97 B | ✔ fixed |
| Chain request | 1 | 113 + 16·h B (h = breadcrumb hops) | ✘ variable |
| Chain response | 2 | 37 + 16·h B + EventChain |
✘ variable |
(UDP + IPv4 headers are added by the OS network stack and are not part of these figures.)
The hash in every event/clock event is a SHA-256 digest, and it is what the whole scheme
hangs on. Importantly, the hash is computed over a fixed canonical layout that is separate from
the transport encoding above (defined in src/core/hash/hashing.hpp) —
so a node can recompute and verify any hash it receives:
- Event hash =
SHA-256( data ‖ salt(u64 BE) ‖ for each referenced event: creator(16 B) ‖ hash bytes ). - Clock-event hash =
SHA-256( chain(u64 BE) ‖ timestamp(u64 BE) ‖ salt(u64 BE) ‖ for each referenced event: creator(16 B) ‖ hash bytes ).
A node's own creator/hash fields and its signature are not part of the preimage — the
signature signs the hash, so signing never changes a hash. This is why the data bytes must
travel in the chain response: without them the originator could not recompute the event's hash to
validate the proof.
Runs on every clock-event tick, independent of any query — this is what keeps the DAGs linked.
Node A Node B (neighbor)
| creates a local clock event on chain ℓ |
| clock notification (type 0) |
| { chain, |
| last_clock_event_hash, |
| neighbor_last_clock_event_hash } |
|----------------------------------------->| record A's newest hash for chain ℓ;
| | add reverse cross-link into DAG
| (and symmetrically B -> A on B's own ticks, per chain)
Started by a node (the originator) that wants the proof chain for an event created by another node. The request is forwarded hop-by-hop toward the creator; the response is forwarded back, accreting each hop's clock-event bounds.
Originator O Intermediate I Creator C
| request(type 1){originator=O, event} |
|------------->| | (routed toward event.creator)
| | request(type 1) |
| |---------------------------->| builds EventChain locally
| | | (event · lower · upper)
| | response(type 2){orig=O, |
| | chain} |
| |<----------------------------|
| | extends chain with |
| | its own clock-event bounds |
| response(type 2){originator=O, chain} |
|<-------------| |
| adds its own local bounds, |
| completes discovery + validates the chain |
Notes:
- The request carries only an event reference plus the breadcrumb (
113 + 16·hB); the response carries the full, growing chain (variable, the largest datagram). - If the originator itself created the event, the chain is built entirely locally with no datagrams at all.
- Event bounds and event order discoveries send no new datagram types — they are built on top of one (or two) chain discoveries and read the resulting chain's endpoint timestamps.
src/core/wire/packets.hpp·packets.cpp— the three datagram definitions and their encode/decode.src/core/wire/codec.hpp— the primitiveWriter/Reader(big-endian ints, length-prefixed blobs/arrays).src/core/domain/types.hpp— the field types.src/core/hash/hashing.hpp— the canonical hashing layout and the byte-accounting helpers.- architecture.md — how one core serializer feeds both the real daemon and the simulator.
- implementation.md — the protocol engine, the three discoveries, and chain validation around these datagrams.