Engineering deep dive
Status of this document: This is an academically-honest, unflinching description of how chIRpChat (formerly LRC) actually works, updated as the codebase evolves past its early
phase0/virtual-radio-harnessorigins — the good, the bad, the ugly, and the things that are still missing. It is written for an engineer who wants the real story, not a sales pitch. Normative detail lives in the otherdocs/*.mdfiles and the code; this document synthesizes them into one honest picture and names every wart.
chIRpChat is a signed, plaintext, cellular LoRa mesh with an IRC gateway. It is not a mesh-egalitarian protocol. Routers are a backbone; clients attach cellular-style; everything is authenticated by Ed25519 and integrity-protected end-to-end, but message content is never encrypted by charter. The IRC gateway is the user interface — there is no app to deploy. This document explains why each of those choices was made and what it costs.
1. What chIRpChat is, in one paragraph
Section titled “1. What chIRpChat is, in one paragraph”A network of routers (typically ESP32-S3 with an SX1262 LoRa radio, or
Linux lrcd over TCP) sequence channel traffic and federate with each other.
Clients (ESP32, nRF52, or any IRC client against an lrcd) attach to a
router with a cellular-style HELLO/WELCOME handshake, after which their
identity (an Ed25519 keypair) is vouched for by that router. Channel messages
are sequenced per (channel, router) stream, deduplicated, gap-healed, and
federated across routers so a channel like #pizza can span countries. The
whole thing is exposed as a real subset of RFC 1459/2812 IRC — you connect
with irssi/WeeChat/a TUI over serial and it just works. Messages are signed,
not encrypted: origin authenticity and infrastructure integrity are the
guarantees; confidentiality of chat content is explicitly out of scope.
2. The messaging protocol: LPP (not protobuf)
Section titled “2. The messaging protocol: LPP (not protobuf)”chIRpChat does not use protobuf. The wire format is LPP (LoRa Packet
Protocol), a hand-rolled binary format designed for one overriding
constraint: a frame must fit in 255 bytes (the LoRa air ceiling) and be
strict-decodable with zero allocation on the radio path. PROTOCOL.md is the
normative spec; core/src/packet.cpp is the codec, frozen by
tests/test_packet.cpp::packet_golden_bytes.
2.1 Frame layout
Section titled “2.1 Frame layout”[ hdr 8 ][ addressing ][ path? ][ erb? ][ frag? ][ payload ][ tag8? ][ keyhint+sig? ]Common header (8 bytes):
VT(1): top 2 bits = protocol version (00=v1,01=v2); bottom 6 bits = packet type.FLAGS(1): AckReq, SignedFull, Frag, Path, Prio, Tagged, ViaTcp, and bit 7 (v1: reserved ExtAddr; v2: Erb).TH(1): high nibble = TTL remaining; low nibble = hops taken. TTL/hops bounded to 15.PLEN(1): payload length.MSGID(4, LE): origin-local monotonic counter; dedup key isSipHash(SRC ‖ MSGID).
Addressing by type group:
- Group A (channel traffic,
<0x10):SRC(8) CHAN4(4) SEQ3(3) RTR2(2)= 17 B. - Group B (unicast,
0x10–0x17):SRC(8) DST(8)= 16 B. - Group C (control,
0x20+):SRC(8)= 8 B.
Optional blocks: Path block (MeshCore-style source routing, 1 byte/relay),
ERB (Edge Routing Block, v2 — the asymmetric return-path carriage), Frag header
(16-fragment reassembly), and trailers: Tag8 (8-byte SipHash) and/or a full
Ed25519 signature (keyhint(1) sig(64)).
2.2 Why not protobuf, CBOR, or MessagePack?
Section titled “2.2 Why not protobuf, CBOR, or MessagePack?”Three reasons, and they are defensible:
- Deterministic byte layout. LPP is frozen byte-for-byte; golden tests pin every field. Protobuf’s variable-length encoding makes that harder and its schema evolution is a liability on a network where nodes may not reflash for years (nodes on hills are slow to reach).
- No allocation on the decode path. Protobuf decoders generally allocate. chIRpChat’s radio path (decode → dedup → frag → dispatch) is heap-free by charter; a single 255-byte buffer and fixed-size rings carry everything.
- Strict decode. Every byte must be accounted for (
o.total == len). This is how protocol rot is prevented; a permissive decoder is a compatibility-hack magnet.
The cost: LPP is bespoke and you must read PROTOCOL.md to understand it.
There is no .proto schema file a new contributor can grok in 30 seconds. The
lrc-wire-change skill exists precisely because changing it is a deliberate,
versioned event, not a refactor.
2.3 Packet types (the full set)
Section titled “2.3 Packet types (the full set)”Group A: ChanMsg, ChanCtl (join/part/topic/kick/mode broadcast), ChanSync
(gap-healing REQ/RESP). Group B: Dm, Ack, Ping, KeyReq/KeyResp,
DccCtl/DccData (file transfer), LaneGrant. Group C: Beacon, Hello,
Welcome, Bye, Rotate, Checkpoint, Admin, Telem, RtrSync,
LaneProbe, MailboxSync. 22 types total. New types in reserved ranges are
additive and cheap; the lrc-wire-change skill governs touching any existing one.
3. Authentication, identity, and trust
Section titled “3. Authentication, identity, and trust”This is the part most LoRa networks get wrong, and chIRpChat gets it right.
Identity is an Ed25519 keypair. The UID is the first 8 bytes of
SHA-256(public_key) — a fingerprint, not a name. Nicks are cosmetic.
3.1 Tiered authenticity (the smart part)
Section titled “3.1 Tiered authenticity (the smart part)”Traffic is protected at four escalating tiers, each cheaper than the next:
- Ingress attestation — the router vouches for the source it saw on the radio. Clients are not trusted to name themselves; the router’s measured source is authoritative. This is why a registered user’s traffic can’t be trivially forged: you must come through a router that vouches for your UID.
- Chat tags (Tag8) — an 8-byte SipHash-2-4 over the canonical image, keyed by a per-epoch 64-bit ChatTagKey (CTK). This is a spoofing tripwire, not a signature — a 64-bit MAC is forgeable with effort, but the next tier catches forgeries cryptographically.
- Checkpoint signatures — amortized Ed25519 over a rolling SHA-256 hash of up to 32 messages / 15 minutes. This is what turns the tag from a tripwire into proof: a forger within the window is exposed by the next checkpoint.
- Full signatures —
flags::SignedFullcarries a real Ed25519 trailer, used for registration, admin, rotation, and anything high-stakes.
3.2 The canonical-image trick (load-bearing, looks like a bug)
Section titled “3.2 The canonical-image trick (load-bearing, looks like a bug)”Tags and signatures are computed over a canonical image: the encoded bytes
with TH zeroed, mutable flags masked (ViaTcp | Path | Erb), SEQ3 forced
to the unstamped sentinel, and the Path/ERB blocks skipped entirely. This is
not a bug — it’s why relay mutation works. A relay appends its UID byte to
the path block (or TRAIL) and a router re-stamps SEQ3 after signing, and the
tag/signature stay valid because those fields were never in the image. If you
“fix” this (AGENTS.md flags it explicitly), you break relaying and DM
re-routing. The trade: whatever is excluded from the image is freely mutable
by an attacker — but only Path/ERB/TH/ViaTcp are excluded, and none of those
feed a trust decision. The worst a forged Path/ERB does is a suboptimal route.
3.3 The registration handshake (cellular-style attach)
Section titled “3.3 The registration handshake (cellular-style attach)”HELLO (group C, signed) carries the client’s pubkey + caps + nick + a nonce.
The router verifies uid_of(pubkey) == header.src (rejecting self-inconsistent
records) and the signature, then replies WELCOME (signed) echoing the nonce,
with its own pubkey + nonce + boot_id. Both sides derive a 128-bit session
key SK = HMAC-SHA256(X25519(client_priv, router_pub), nonce_c ‖ nonce_r)[0..16]
and tag further unicast traffic with SipHash under SK. The path back to the
router is pinned from the WELCOME (reversed). This is a real authenticated DH
key agreement over LoRa, which is genuinely hard to do cheaply.
3.4 How unauthenticated / adversary traffic is handled
Section titled “3.4 How unauthenticated / adversary traffic is handled”- Forge a user without compromising a router? You can’t get ingress attestation. Your frames arrive with a source no router vouches for.
- Forge a tag? Within the checkpoint window (≤32 msgs / 15 min) you might
slip one through, but the next checkpoint exposes the discrepancy
(
chkpt_mismatch). The tag is a tripwire; the checkpoint is the proof. - Flood / spam? Per-session IRC flood limiter (20 lines / 1000 ms →
263), UID-based identity revocation (signedrevoke), and the newCounter::reg_evict(the Registrar table is capped, so a forge-and-flood of distinct HELLO sources can’t OOM a router). - Sybil? Cheap at the relay layer (a Sybil can relay), but airtime-bounded by TTL + dedup, and routers inside the trust boundary are the authority. This is a documented residual (ROUTING.md), not a solved problem — no proof-of-work, no stake.
- Compromised router? Inside the trust boundary. It can mis-sequence, drop, or forge-within-its-scope. Federation reconciliation (max-seq vectors) catches sequence shenanigans across routers, but a lone rogue router is a real threat model, accepted as a residual.
3.5 The IK-compromise failure mode (the ugly)
Section titled “3.5 The IK-compromise failure mode (the ugly)”If your Identity Key (IK) is leaked, your identity is dead, permanently.
There is no key escrow, no recovery. Revocation (identity.revoke <uid>,
signed) stops new traffic from that UID but cannot undo what an attacker
already did. Rotation (Rotate) moves to a subkey but the IK root is still
fatal if compromised. Automatic rotation scheduling is future work
(IDENTITY.md flags it). This is the single harshest failure mode in the system
and it’s documented, not solved. Back up your seed.
3.6 Key generation cost (client side)
Section titled “3.6 Key generation cost (client side)”Identity::from_seed(seed) calls ed25519_create_keypair, which is a SHA-512
based scalar expansion + point multiplication. On an ESP32-S3 at 240 MHz this
is single-digit milliseconds — imperceptible at attach time, and it only
happens once per identity (then the expanded 64-byte private key is cached and
persisted to lrckv as a *.seed record). The expensive part is not
generation; it’s the boot of a node that holds many identities (each
re-expanded from seed until the cache warms). Key loss = identity death (§3.5),
so seeds are the one thing an operator must not lose.
4. Transit: routing, relaying, and the cellular model
Section titled “4. Transit: routing, relaying, and the cellular model”4.1 Why cellular, not mesh-egalitarian?
Section titled “4.1 Why cellular, not mesh-egalitarian?”Meshtastic and MeshCore are mesh-egalitarian: every node is a peer, flooding carries traffic, and the network is one big shared broadcast domain. That scales to a few hundred nodes per channel and then drowns in its own flood. chIRpChat is cellular: routers are a backbone, clients attach, and flooding is reserved for discovery only. The backbone may use the internet (TCP federation) — this is the “embrace the internet” decision. The payoff is that a federated channel can span countries without each message traversing every node’s airtime.
4.2 Multi-hop: source routing today, ERB tomorrow
Section titled “4.2 Multi-hop: source routing today, ERB tomorrow”Today, multi-hop uses MeshCore-style source routing via FLAGS.Path: a
path[] of relay UID-bytes. A relay forwards only when path[hops_taken]
matches its own UID byte, then increments hops. Discovery packets carry
path_len == hops_taken and each relay appends its byte. The ACK/WELCOME
carries the path reversed for the return leg. This works but it’s one byte
per hop (ambiguous by design — a hint, not security; dedup absorbs the
occasional duplicate relay) and bounded to kMaxPathHops = 15.
The v2 ERB (Edge Routing Block), landed on the wire in this branch, is the
successor: it carries rprog (router-distance progress), hint, rack_rtr1,
a trail[] of observed-delivery relay bytes, and crucially a return-lane
descriptor (rd_mode/rd_lane_id/rd_preset/rd_ttl_quart) — the
carriage for the asymmetric multi-SF return path (§6). The ERB is mutable and
image-excluded like the Path block. v1 nodes blind-relay v2 frames unchanged
(PROTOCOL.md §Versioning), so the rollout is incremental.
4.3 The asymmetric routing problem (the headline innovation)
Section titled “4.3 The asymmetric routing problem (the headline innovation)”Here is the physical reality of LoRa that most mesh stacks ignore: a router on
a hill with line-of-sight can reach a hidden client on SF5 (fast, ~40 kbps,
short range but loud) in one hop, while that same client — behind terrain — can
only reach back on SF12 (slow, ~180 bps, maximum range) possibly over
relay hops. Forward and return legs are not symmetric. chIRpChat’s
return_lane.h picks the return lane: the slowest preset whose probe floor
the return link clears with margin, breaking ties toward fewer hops and lower
airtime. The objective is delivery-probability-per-airtime, not latency —
a link that barely reaches rides SF12 once-and-delivered, not SF7
dropped-and-retransmitted-thrice. Node::compute_return_lane(uid) consults
this from the router’s measured uplink SNR. This is genuinely novel for LoRa
mesh stacks and is the thing that most sets chIRpChat apart. The host policy
is tested; the on-wire carriage (ERB) and the RF retune actuation are landed /
pending respectively.
4.4 Relay store-and-forward
Section titled “4.4 Relay store-and-forward”A relay with no current egress keeps a bounded, TTL-limited copy of the
already-mutated frame and forwards when an egress returns. Default: 8 frames /
30 s (relay_store_forward_limit / relay_store_forward_ttl_ms), oldest
dropped, counted in path_store_drop. This is a local queue only — it never
adds fields, rewrites payloads, mints ACKs, or changes source/dest semantics.
4.5 Flood routing (when, and why rarely)
Section titled “4.5 Flood routing (when, and why rarely)”Full flooding is used only for discovery (TTL ≤ 3, rate-limited per origin). Steady-state traffic is routed: a router sequences its members’ channel messages and federates the stream; DMs use source-routed paths. This is the opposite of Meshtastic, where everything floods. The cellular model’s scaling advantage comes precisely from not flooding the steady state.
5. Loss recovery: dedup, fragmentation, gap healing
Section titled “5. Loss recovery: dedup, fragmentation, gap healing”Every frame is assumed losable. The recovery stack is layered:
5.1 Dedup ring
Section titled “5.1 Dedup ring”A fixed-capacity ring of 64-bit SipHash(SRC ‖ MSGID) keys with an 8-slot
linear probe. Capacity: 512 on clients (~4 KB), 8192 on PSRAM routers, 4096 by
default in Node. At 100% load it accepts a bounded false-negative rate
(duplicates beyond the ring’s age horizon aren’t suppressed) — a documented,
deliberate trade for bounded memory. Tag verification precedes dedup insertion
so a bad frame can’t reserve a key.
5.2 Fragmentation
Section titled “5.2 Fragmentation”MAILBOXSYNC (the only fragmented type today) splits into up to 16 fragments
of ≤255 B each (kMaxReassembled = 4080 B). Reassembly slots are caller-owned
(in PSRAM on device), expire after 30 s, and all non-final fragments must share
one stride. Fragmented frames reassemble before dedup so sibling fragments
aren’t mistaken for duplicates.
5.3 CHANSYNC gap healing (the clever part)
Section titled “5.3 CHANSYNC gap healing (the clever part)”A router stamps each channel message with SEQ3 (24-bit, per
(CHAN4, RTR2) stream). On seq n > last+1, the node marks a gap and, after
a grace period (gap_grace_ms, default 5 s), sends CHANSYNC REQ with up to 8
{from,to} ranges. Retries are exponential (gap_attempts, default 3); gaps
self-cancel if the local ring already holds the range, and hard-drop after
10 min. Backfill is just a gap from a cursor — JOIN scrollback and
client-reboot backfill use the identical path with zero new mechanism. This is
elegant: one recovery primitive serves initial fill, roaming, and post-reboot.
5.4 ACKs and retry
Section titled “5.4 ACKs and retry”CHANMSG uplink sets ACK_REQ; the router’s stamped downlink is the
implicit ACK. DM uses explicit ACK with 1.5× exponential backoff, jittered
±25%. ack_attempts (default 3 retries after initial) then not delivered +
tx_fail. RFC 1982 serial arithmetic handles 24-bit wrap.
5.5 Failure modes and recovery paths (the honest list)
Section titled “5.5 Failure modes and recovery paths (the honest list)”- Lost router database (server side):
lrcd --state-dirpersists registrations, channel records, mailbox, sequence cursors, identity records. Loss is not fatal — RTRSYNC re-learns the directory from federation peers, CHANSYNC backfills channels, the registrar re-measures. The one thing that doesn’t auto-recover is the router’s own RK (router key) seed; lose that and the router’s UID changes and clients must re-attach. - Lost client seed: identity death (§3.5). No recovery. Back it up.
- Netsplit: routers mark each other down only after the last ready link
drops; a split router keeps its directory entry (rendered as IRC
QUIT) and reconciles on netjoin (JOIN), backfilling missed sequences. This is IRC-net-split semantics, deliberately. - Clock skew: history replay is guarded by
history_time_skew_guard_s(default 10 min) — messages with origin_time outside the guard are dropped (time_skew_drop) so a badly-clocked replayer can’t poison the timeline. - Router reboot:
boot_idincarnation — a signed WELCOME confirming a changedboot_idmakes clients flush stale mirror/gap state instead of treating the reboot as a giant sequence gap.
6. Radio: lanes, spreading factors, airtime, and power
Section titled “6. Radio: lanes, spreading factors, airtime, and power”6.1 The lane model
Section titled “6.1 The lane model”A lane is a (frequency, preset, schedule) triple. A preset fixes exactly one
(SF, BW, CR). There are 8 presets, slowest→fastest:
| id | name | SF | BW(kHz) | ~kbps | floor(dB) |
|---|---|---|---|---|---|
| P0 | ANCHOR | 12 | 125 | 0.18 | −20.0 |
| P2 | REACH | 10 | 250 | −15.0 | |
| P5 | LOCAL | 7 | 250 | −7.5 | |
| P6 | TRUNK | 7 | 500 | −4.5 | |
| P7 | BURST | 5 | 500 | 39 | 0.0 |
The anchor (P0) is always-on — the dial tone everyone must hear. It is also
the scaling chokepoint: beacon suppression keeps routers off it, and
promotion moves clients off it once registered_clients >= min_clients
(default 8) and the client’s uplink SNR clears a faster lane’s floor + margin.
6.2 Frequency planning (deliberately over-engineered — keep this)
Section titled “6.2 Frequency planning (deliberately over-engineered — keep this)”This is one of the most over-engineered, and best, parts. Frequency slots are
chosen by SipHash(region ‖ lane_index) % nslots, then probed forward to dodge
every Meshtastic modem preset (computed via Meshtastic’s own djb2 hash of
the preset name, so each LongFast/ShortTurbo/etc. sits on its own frequency we
avoid) plus three coordinated MeshCore metro channels (910.525 MHz US/CAN
default, SoCal, Sacramento) with a bandwidth-scaled 125 kHz guard band. ~9
channels out of US915’s 104 are dodged. The result: a chIRpChat node is a good
neighbor to both networks in any region without hardcoding region numbers.
This is more careful than it needs to be. Keep it.
6.3 Airtime and duty cycle
Section titled “6.3 Airtime and duty cycle”airtime_ms(preset, bytes) uses the Semtech AN1200.13 formula. The
AirtimeLedger (new in this branch) is a per-lane sliding-window token bucket
that every TX decision consults — admit(lane, bytes, now, policy) returns
false when the lane’s regulatory budget is exhausted, the caller defers (CAD
backoff) and bumps tx_duty_defer. Region-aware: EU868 10% sub-band, US915
unlimited. duty_pct_now() is the value telemetry exports. Before this
branch the ledger was documented but unimplemented; now it’s real and wired
into the VirtualRadio harness so duty deferral is CI-tested.
6.4 TX queues and backpressure (the part that was missing)
Section titled “6.4 TX queues and backpressure (the part that was missing)”The firmware’s TX queue is now bounded (TxBoundedQueue, priority-aware: a
PRIO frame preempts the oldest non-PRIO entry on a full queue, so
admin/ACK/registration survives a flood). The daemon’s per-peer outbuf has a
256 KiB high-water that drops the oldest complete framed record (keeping the
peer parser in sync) and counts tx_queue_drop. High-water flips the BEACON
Busy bit; clients stretch pacing. Before this branch both queues were
unbounded — a slow peer could OOM the daemon. Now overload degrades rather
than crashes.
6.5 Power
Section titled “6.5 Power”Four states: ACTIVE / DOZE / NAP / HIBERNATE. The lane schedule doubles as the
power schedule (a device not due to TX/RX for N slots can NAP). TX defaults
OFF on firmware — receive-only until the operator runs tx on. This is
RF safety (AGENTS.md rule 5): transmitting requires explicit human action.
7. Federation and the IRC gateway
Section titled “7. Federation and the IRC gateway”7.1 Federation model
Section titled “7.1 Federation model”Channel identity is (CHAN4, scope). Solo scope: #pizza.rt1 ≠
#pizza.rt2. Federated scope: routers in one federation_id jointly host
a channel; each sequences its own local members in its own stream; members
merge all streams. Total order does not exist (physics) — display order is
(origin timestamp, RTR2). Reconciliation is per-stream max-seq vectors in
RTRSYNC (not set-reconciliation), then ranged CHANSYNC backfill. Backbone
link preference: TCP > fast LoRa lane > anchor lane > lowest RTT.
7.2 The IRC gateway (the user interface — no app to deploy)
Section titled “7.2 The IRC gateway (the user interface — no app to deploy)”core/src/irc/server.cpp is a real subset of RFC 1459/2812, transport-
agnostic via two seams: SessionSink (how lines reach sockets) and Bridge
(how chat reaches the radio network). The TUI is not a second chat client —
it’s a local IRC client of the same engine. One codepath, two faces.
Implemented commands:
- Pre-reg:
CAP(LS/REQ/END; 5 caps: echo-message, away-notify, labeled-response, batch, server-time),NICK,USER,PING/PONG,QUIT. - Channel:
JOIN(multi + keys, +i/+k/+b gates),PART,PRIVMSG/NOTICE(+m moderation, CTCP DCC SEND interception),TOPIC(+t),NAMES,LIST,MODE(o/b/t/n/m/i/A/S/k),KICK,INVITE. - Status:
WHO,WHOIS(311/301/319/312/317 idle+signon/320/318 + rich chIRpChat extra; 313 oper),ISON(303),USERHOST(302),VERSION(351),TIME(391),LUSERS(251),AWAY(306/301),MOTD. - Oper (new):
OPER(381/464),SAJOIN,SAPART,SAMODE— oper override of +i/+k/+b/modes to resolve abuse.
Not implemented (would 421): SASL (NAK’d), WHOWAS, LINKS, MAP,
ADMIN, INFO, STATS, USERS, SERVLIST, SQUERY, TRACE, CONNECT,
SQUIT, MONITOR, IRCv3 CHATHISTORY (history is the JOIN-time batch replay
only), CAP NEW/DEL, typed FAIL/WARN/NOTE. CTCP is DCC-SEND-only; ACTION
is not specially handled.
Better than IRC itself: the chIRpChat-specific extra lines in WHOIS
expose the things IRC never could — UID fingerprint, public-key prefix, current
router, attachment (local/remote), reachability, attestation, last-heard
wall-clock. And *lrc is a service user whose nick deliberately violates RFC
nick grammar so it can never collide with a real user.
7.3 Channel management (no separate channel server)
Section titled “7.3 Channel management (no separate channel server)”There is no dedicated channel-server process. Channel state lives in (a) the
in-process IRC Channel struct (ephemeral, nick-scoped) and (b) durable,
identity-bound ChannelRecords persisted by the Node (founder/ops[]/
bans[] are UID-based, federated via RTRSYNC). First joiner with no record
becomes founder; subsequent op grants are UID-checked. The IRC-side ops/bans
are nick-local; the durable ones are UID-federated. *lrc namespace manages
scope (join/merge/move/views).
7.4 Admin / oper
Section titled “7.4 Admin / oper”Operators are UID-bound (--admin-operator UID8HEX, or admin op add on
firmware). Admin actions travel as signed ADMIN packets with optional
quorum (--admin-quorum N; mutating sets accumulate distinct operator
approvals). Keys are deny-by-default to a safe subset. New: IRC OPER
authenticates against --oper-credential NAME:SECRET for roaming opers, and
UID operators + OPER-granted opers both reach the same override capability
(SAJOIN/SAMODE/SAPART/KICK). There is no global oper “kick from any channel”
beyond SAJOIN+SAMODE+KICK override — that’s the abuse-resolution surface.
8. Memory utilization: ESP32 vs nRF52 vs Linux
Section titled “8. Memory utilization: ESP32 vs nRF52 vs Linux”8.1 The radio path: bounded and heap-free
Section titled “8.1 The radio path: bounded and heap-free”Everything below kMaxFrame = 255 is fixed-size and heap-free: 255-byte
frames, 4 KB reassembly slots (16 × 255), 200-byte payloads, fixed dedup
rings. This is the load-bearing portability invariant.
8.2 The Node controller: heap, but bounded by Node::Config
Section titled “8.2 The Node controller: heap, but bounded by Node::Config”The Node itself is heap-heavy (std::map/std::vector/std::string), but
every growth dimension has a named limit and a paired eviction function. The
key limits:
| Limit | Default | Notes |
|---|---|---|
chan_ring |
256 (firmware: 16) | StoredMsg records/channel (~224 B each) |
directory_limit |
1024 | remote online attachments |
identity_cache_limit |
64 | cached identity records |
mailbox_limit_per_uid |
32 | 7-day TTL |
relay_store_forward_limit |
8 | 30 s TTL |
dedup |
4096 (router) | SipHash keys |
registrar |
1024 (new cap) | was unbounded — DoS fix |
8.3 ESP32-S3 (XIAO, primary target)
Section titled “8.3 ESP32-S3 (XIAO, primary target)”- SRAM: 320 KB. The tight wall.
chan_ring=16today because at 64 a single cross-node chat eats ~28 KB and faults the node once a TUI + channels are live. Thelrc::Arena(new) is the fix: the firmware will bind an 8 MB PSRAM region to it and the hot-tier consumers will draw fixed-block pools, liftingchan_ringtoward the documented 1.5 MB ring budget. The Arena primitive is landed and host-tested; the ChanStore adoption is the board-validated migration. - PSRAM: 8 MB. Documented budget (STORAGE.md): rings 1.5 MB, directory 1 MB, dedup 0.5 MB, DCC 1 MB, lrclog 1 MB, TUI 0.5 MB, 2.5 MB headroom.
- Flash: 8 MB partitioned:
app0/app12 MB each (dual OTA),lrckv512 KB,lrclog3.4 MB. Firmware currently at 65.7% (1.38 MB) of the 2 MB OTA slot — acheck_firmware_size.pygate enforces 95% in CI. - Realistic ceiling today: few hundred local clients (directory bound
before SRAM without Arena), 16-message channel history. Post-Arena:
~10× — directory ~1 MB,
chan_ringtoward 256.
8.4 nRF52 (RAK4631)
Section titled “8.4 nRF52 (RAK4631)”- Compile-only, untested on hardware. Smaller SRAM than the ESP32-S3, no external PSRAM in this config. Effectively client-only — do not plan it as a router until benched. The profile seam (FieldHotspot/UplinkRouter/ SerialOnly) would in principle allow it, but without PSRAM the chan_ring and relay limits make it thin.
8.5 Linux lrcd (the daemon / backbone)
Section titled “8.5 Linux lrcd (the daemon / backbone)”- No SRAM/PSRAM constraint — every
Node::Configlimit can rise 10–100×. - Single-threaded
poll()— one core, one event loop. The wall is single-core CPU, not RAM. Thousands of TCP peers comfortably; beyond that, shard by region (onelrcdper region, federated) before reaching forio_uring. The coreNodeis single-threaded by design. - No radio driver —
lrcdis TCP-only. A Linux/Pi box is a backbone node, not a radio edge, until the spidev RadioLib bridge is written (a self-contained HAL reusing the sameLaneSchedule/AirtimeLedger/queue seams the firmware uses).
8.6 Raspberry Pi 2w (512 MB)
Section titled “8.6 Raspberry Pi 2w (512 MB)”RAM is ~unlimited for these frame sizes — effectively a Linux lrcd node.
Walls: single-core poll() + no radio driver. For a backbone/federation role,
512 MB is effectively unlimited.
9. Scaling: why not a million users?
Section titled “9. Scaling: why not a million users?”Academic honesty: chIRpChat will not scale to a million users on one router, and the design does not pretend it will. The cellular model scales by federation, not by making one router huge.
- Per-router ceiling (ESP32, today): ~few hundred clients (SRAM-bound), 16-msg channel history, 1024-directory cap. Post-Arena, ~10×.
- Per-router ceiling (Linux): RAM-unbounded, single-core-CPU-bounded. Thousands of TCP peers.
- The million-user path: thousands of routers, each federated, each
carrying its region’s clients. A federated channel merges all routers’
streams — so a million-user
#pizzameans ~thousands of routers each sequencing its members, and each client merging all streams. The merge cost is per-stream, not per-user, so it scales with router count, not user count. - The real walls:
- Airtime. LoRa is a shared medium. One router’s anchor lane has a finite duty budget. Promotion offloads clients to faster lanes, but a dense cell will saturate. This is physics, not software.
- Directory growth.
directory_limit=1024caps a router’s view of remote users. Beyond that, eviction (LRU-by-reachability). A million-user network needs a hierarchical directory or scoped federation, neither of which exists. - Single-thread
lrcd. A backbone router saturates one core. - No global DHT. Cross-federation DM resolution is operator-pinned peerings + bounded referrals (by design — ROUTING.md). At million-user scale this needs more.
Honest verdict: chIRpChat scales to thousands of routers × hundreds of clients each comfortably — call it low hundreds of thousands of users in a well-federated deployment. A million needs directory hierarchy and region-sharded backbones that are not built.
10. Performance: validation cost, sizes, hops
Section titled “10. Performance: validation cost, sizes, hops”10.1 Per-packet validation cost
Section titled “10.1 Per-packet validation cost”- Decode: strict, O(len), heap-free. Cheap.
- Tag8 verify: one SipHash-2-4 over the canonical image. SipHash is fast (~tens of µs on ESP32). This is the hot path — every tagged frame.
- Ed25519 verify: ~a few ms on ESP32-S3 (SHA-512-based). Used for registration, checkpoints (amortized over 32 msgs), admin. Not per-chat- message — the tag is. This split is the performance design: cheap MAC per frame, expensive signature amortized.
- Checkpoint hashing: rolling SHA-256, signed once per 32 msgs / 15 min.
10.2 Sizes and hop limits
Section titled “10.2 Sizes and hop limits”- Frame: ≤255 B. Header 8 B. Group-A addressing 17 B (leaves ~230 B for payload + trailers). Tag8 8 B, full sig 65 B.
- Hops: TTL ≤ 7 default, bounded to 15 (4-bit fields). Discovery TTL ≤ 3.
- Reassembly: 16 frags × 255 B = 4080 B max.
10.3 Problematic path routing
Section titled “10.3 Problematic path routing”- Packet loss: CHANSYNC gap healing (§5.3). Bounded retry, hard-drop after 10 min — a permanently-lost range is eventually abandoned, not chased forever.
- Path poisoning: the ERB’s TRAIL is a hint; the observed-delivery gate
(return_path.h) confirms a return lane actually works before committing;
poisoned values decay (
rd_decay). Self-heal, not prevent. - Long distance: SF12 anchor reaches furthest; asymmetric return (§4.3) handles the hidden-client case. There is no guarantee of delivery at the edge of range — it’s a best-effort radio.
11. Beacon contents
Section titled “11. Beacon contents”BEACON (group C) payload, all LE:
rtr(2) rk_fp(8) flags(1) n_lanes(1)lanes[n]: lane_id(1) preset(1) freq_slot(1) period_s(1) offset_s(1) window_s(1)fed_id(4) time(4) boot_id(4)rtr: router’s sequencing scope id.rk_fp: first 8 bytes ofSHA-256(router pubkey)= the router’s UID fingerprint.flags:Busy(backpressure),FedMember,AcceptsNew.lanes[]: the router’s lane calendar — clients use it to know when each lane’s window is open (TX pacing + sleeping through gaps; it’s the radio schedule and the power schedule).fed_id: which federation this router belongs to.time: router’s unix time (loose sync source).boot_id: incarnation; changes every boot. Advisory unsigned; a signed WELCOME confirming a changedboot_idmakes clients flush stale state.
Cadence is congestion-controlled (BeaconCadenceController): busy/accepting/
active routers use the active cadence; idle ones suppress to avoid clogging
the anchor lane.
12. What’s over-engineered (keep it), what’s ugly (watch it), what to cut
Section titled “12. What’s over-engineered (keep it), what’s ugly (watch it), what to cut”Over-engineered — keep all of this (the user wants over-engineering)
Section titled “Over-engineered — keep all of this (the user wants over-engineering)”- Mesh-avoidance frequency planning (§6.2). Dodges every Meshtastic preset
- three MeshCore metros with bandwidth-scaled guards. More careful than necessary. Keep.
- TUI QR-v4-L + phonetic key export over serial (
tui.cpp). Hand-rolled Reed-Solomon GF(256), finder/alignment/timing patterns, ASCII-art QR for air-gapped key exchange. Absurdly above-and-beyond. Keep. - 108 telemetry counters via X-macro. Stable names are API. Keep.
- The 8-preset ladder + promotion lifecycle with SNR hysteresis, renew-before-lapse, demote-runs. More states than strictly needed, exactly right for flickering RF.
- Golden-byte wire pinning + the
lrc-wire-changeskill. The reason protocol changes can never be accidental.
Ugly — documented, accepted residuals
Section titled “Ugly — documented, accepted residuals”- IK compromise = permanent identity death (§3.5). No escrow. Back up seeds.
chan_ring=16on ESP32 until Arena adoption lands in ChanStore (§8.3).- nRF52 compile-only, untested (§8.4).
- No confidentiality for chat, ever — by charter (SECURITY.md). The scoped-confidentiality seam (new) covers admin/oper only, and its primitive is a documented operator decision, not silently vendored.
LISTis local-only despite ARCHITECTURE.md once claiming router- directory — a known doc/code drift, now honest.- One-byte relay UID hints (ambiguous by design, dedup absorbs dupes).
- Sybil at the relay layer is free — contained by TTL+dedup, not prevented.
Might be worth cutting someday
Section titled “Might be worth cutting someday”- The legacy
ExtAddrbit reservation is nowFLAGS.Erbin v2 — the v1 reservation scaffolding could be trimmed once v1 is sunset, but v1 nodes exist in the field, so not soon. VeryLongSlowin the Meshtastic-preset dodge list is deprecated upstream; harmless to keep (its hash is stale but carries no traffic).
13. What sets chIRpChat apart (the honest comparison)
Section titled “13. What sets chIRpChat apart (the honest comparison)”| Axis | Meshtastic | MeshCore | chIRpChat |
|---|---|---|---|
| User interface | Phone app required | App required | IRC gateway — any IRC client, no app |
| Topology | Mesh-egalitarian flood | Mesh | Cellular (routers + backbone), flood for discovery only |
| Internet | Avoids it | Avoids it | Embraces it (TCP federation), survives without it (RF backbone) |
| Auth | Channel key (symmetric) | Channel key | Ed25519 identity, tiered (attest→tag→checkpoint→sig) |
| Confidentiality | Encrypted | Encrypted | Plaintext by charter (signed, not encrypted) |
| Multi-SF per link | Preset per channel | Preset | Asymmetric forward/return (ERB + return_lane) — novel |
| Recovery | Re-flood | Re-flood | Per-stream seq + CHANSYNC gap healing + netsplit/rejoin |
| Scaling | Hundreds/node | Hundreds/node | Thousands of federated routers |
The three things that genuinely set it apart:
- No app. The IRC gateway means irssi/WeeChat/serial-TUI all work. This is the deployability win.
- Asymmetric multi-SF routing. SF5 forward / SF12 return is real, host- tested policy, with on-wire carriage. No other LoRa mesh does this.
- Embraces the internet without depending on it. TCP federation spans countries; the RF backbone keeps working when the internet drops (peer-link-over-RF is proven; full backbone transport is the next step).
The honest cost: no chat confidentiality (by design), IK-compromise is fatal, and the cellular model is more complex to operate than a flood mesh.
14. Things still to improve (the punchlist)
Section titled “14. Things still to improve (the punchlist)”- Arena adoption in ChanStore — lift ESP32
chan_ringfrom 16 → 256. Primitive is built; needs board validation. - ERB relay-contention wiring — the ERB is on the wire; the Node must act on it (contention window, RACK, observed-delivery). Host policy exists; Node integration is next.
- RF backbone transport — peer-link-over-RF is proven; the full
BackboneLinkClass::FastRf/AnchorRftransport + actuation needs a bench. - Scoped-confidentiality primitive — the seam exists; pick HMAC-ETM vs
vendored XChaCha20-Poly1305 and implement
seal/open(SECURITY.md). - Linux spidev radio driver — make a Pi a radio edge, not just backbone.
io_uring/ threading — only when a region saturates one core.- IRC
CHATHISTORY, CTCP ACTION,+l/+v— parity gaps. - Automatic IK rotation scheduling — mitigate the fatal-compromise mode.
- nRF52 bench-or-mark-client-only.
- Directory hierarchy for >100k-user federations.
15. How to set it up, manage it, and secure it (operator’s quick reference)
Section titled “15. How to set it up, manage it, and secure it (operator’s quick reference)”Build & verify: cmake -S . -B build && cmake --build build -j (warning-
clean -Werror), ./build/lrc_tests (the full CI-gated host suite, always
green on main), python3 tests/smoke_lrcd.py,
(cd firmware && pio run). The lrc-wire-change skill governs wire edits.
Run a Linux backbone node: lrcd --name node1 --rtr 1 (+ --peer host:port for federation, --admin-operator UID8HEX for opers,
--oper-credential NAME:SECRET for roaming OPER, --state-dir ~/.lrc).
Listeners: IRC 6667, peer 6810, TUI 2323, metrics 8462. Install with
cmake --install (now produces lrcd + lrcctl).
Admin it: lrcctl status|fed|radio|counters|watch (reads :8462); IRC
/msg *lrc admin ...; oper OPER/SAJOIN/SAMODE/SAPART/KICK.
Secure it: back up client seeds (loss = identity death); set
--admin-operator UID(s) + quorum; TX stays OFF on firmware until tx on
(RF safety); peer links are authenticated (X25519+MAC) but plaintext —
assume the LAN can read federation traffic. The scoped-confidentiality
exception (when implemented) covers admin/oper only.
Recover: router DB loss is non-fatal (RTRSYNC re-learns); client seed loss is fatal; netsplit self-heals on rejoin; reboot is incarnation-gated.
This document is the unvarnished picture. The code and the normative docs are the truth where this document and they disagree; file issues for any drift.