Skip to content

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-harness origins — 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 other docs/*.md files 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.


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.

[ 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 is SipHash(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:

  1. 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).
  2. 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.
  3. 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.

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.


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.

Traffic is protected at four escalating tiers, each cheaper than the next:

  1. 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.
  2. 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.
  3. 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.
  4. Full signaturesflags::SignedFull carries 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 (signed revoke), and the new Counter::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.

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”

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.

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.

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:

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.

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.

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-dir persists 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_id incarnation — a signed WELCOME confirming a changed boot_id makes 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”

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.

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.

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.


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).

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”

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
  • SRAM: 320 KB. The tight wall. chan_ring=16 today because at 64 a single cross-node chat eats ~28 KB and faults the node once a TUI + channels are live. The lrc::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, lifting chan_ring toward 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/app1 2 MB each (dual OTA), lrckv 512 KB, lrclog 3.4 MB. Firmware currently at 65.7% (1.38 MB) of the 2 MB OTA slot — a check_firmware_size.py gate 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_ring toward 256.
  • 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.
  • No SRAM/PSRAM constraint — every Node::Config limit 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 (one lrcd per region, federated) before reaching for io_uring. The core Node is single-threaded by design.
  • No radio driverlrcd is 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 same LaneSchedule/AirtimeLedger/queue seams the firmware uses).

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.


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 #pizza means ~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:
    1. 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.
    2. Directory growth. directory_limit=1024 caps 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.
    3. Single-thread lrcd. A backbone router saturates one core.
    4. 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”
  • 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.
  • 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.
  • 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.

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 of SHA-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 changed boot_id makes 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-change skill. The reason protocol changes can never be accidental.
  • IK compromise = permanent identity death (§3.5). No escrow. Back up seeds.
  • chan_ring=16 on 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.
  • LIST is 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.
  • The legacy ExtAddr bit reservation is now FLAGS.Erb in v2 — the v1 reservation scaffolding could be trimmed once v1 is sunset, but v1 nodes exist in the field, so not soon.
  • VeryLongSlow in 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:

  1. No app. The IRC gateway means irssi/WeeChat/serial-TUI all work. This is the deployability win.
  2. Asymmetric multi-SF routing. SF5 forward / SF12 return is real, host- tested policy, with on-wire carriage. No other LoRa mesh does this.
  3. 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)”
  1. Arena adoption in ChanStore — lift ESP32 chan_ring from 16 → 256. Primitive is built; needs board validation.
  2. 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.
  3. RF backbone transport — peer-link-over-RF is proven; the full BackboneLinkClass::FastRf/AnchorRf transport + actuation needs a bench.
  4. Scoped-confidentiality primitive — the seam exists; pick HMAC-ETM vs vendored XChaCha20-Poly1305 and implement seal/open (SECURITY.md).
  5. Linux spidev radio driver — make a Pi a radio edge, not just backbone.
  6. io_uring / threading — only when a region saturates one core.
  7. IRC CHATHISTORY, CTCP ACTION, +l/+v — parity gaps.
  8. Automatic IK rotation scheduling — mitigate the fatal-compromise mode.
  9. nRF52 bench-or-mark-client-only.
  10. 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.