LoRa & Mesh Functionality
How Archipelago sends encrypted messages, Bitcoin transactions, and emergency alerts over long-range radio when the internet is gone.
Introduction
This document explains Archipelago's mesh subsystem — the code under core/archipelago/src/mesh/ that lets nodes talk to each other over LoRa radio instead of (or alongside) the internet. It covers every message type, the transport layer that carries it, the cryptography that protects it, and the code paths that glue it all together.
The goal: give you a mental model that works both ways. If you're an engineer, you can read this and know exactly which bytes get put on the wire for a given RPC call. If you're not, the purple "Layman Analogy" boxes translate each piece into familiar metaphors.
What is LoRa? Layman
Technically, LoRa (Long Range) is a proprietary radio modulation by Semtech that uses chirp spread spectrum (CSS). It operates in unlicensed ISM bands (915 MHz in the Americas, 868 MHz in Europe) and trades bandwidth for sensitivity, allowing receivers to decode signals below the noise floor. Typical line-of-sight range is 5–15 km with a simple antenna; data rates are 0.3–50 kbps.
Archipelago does not talk to a LoRa chipset directly. Instead it delegates to a small USB-attached device running Meshcore firmware, which handles the radio, the mesh routing, and the store-and-forward queue. Archipelago speaks to that device over USB serial.
Why Archipelago uses it
Off-grid safety
Dead-man switch and emergency alerts reach family without cell coverage.
Censorship resistance
No ISP, no DNS, no TLS termination — just radio waves between nodes.
Bitcoin when internet is down
Relay signed transactions and Lightning payments through on-grid peers.
Truly peer-to-peer chat
Text, replies, reactions, read-receipts — Telegram-quality UX, zero servers.
Hardware & Firmware
Archipelago expects a Meshcore-compatible radio board plugged into USB. The firmware handles RF, mesh forwarding, and contact management; Archipelago handles encryption, message types, and UI.
| Component | Role | Examples |
|---|---|---|
| MCU | Runs Meshcore firmware, talks USB serial | ESP32, nRF52840 |
| Radio | Semtech LoRa transceiver | SX1262, SX1276 |
| Board | MCU + radio + USB + antenna | Heltec V3, T-Beam, RAK WisBlock, Station G2 |
| Firmware | Mesh routing + Companion USB protocol | Meshcore |
| Connection | USB CDC-ACM serial | /dev/mesh-radio (udev symlink), /dev/ttyUSB*, /dev/ttyACM* |
| Link params | 115200 baud, 8N1 | Set in mesh/serial.rs |
USB Serial Transport
Every byte in and out of the radio is wrapped in a framed serial protocol. The host speaks with '<' and listens for '>'.
The frame body is a Meshcore Companion command or response. Archipelago builds these in mesh/protocol.rs and parses replies in mesh/listener/decode.rs.
Companion commands Archipelago uses
| Code | Name | Purpose |
|---|---|---|
0x01 | APP_START | Handshake; device returns its node_id and name |
0x02 | SEND_TXT_MSG | Send payload to a contact (targeted by 6-byte pubkey prefix) |
0x03 | SEND_CHANNEL_TXT_MSG | Broadcast on a channel (no specific recipient) |
0x04 | GET_CONTACTS | Pull the device's contact table |
0x06 | SET_DEVICE_TIME | Sync Unix timestamp for message dating |
0x07 | SEND_SELF_ADVERT | Broadcast our identity onto the mesh |
0x08 | SET_ADVERT_NAME | Set our display name |
0x0A | SYNC_NEXT_MESSAGE | Pop the next queued inbound message |
0x0B | SET_RADIO_PARAMS | Frequency, spreading factor, bandwidth |
0x0C | SET_RADIO_TX_POWER | Transmit power (dBm) |
0x38 | GET_STATS | Device statistics |
Responses and push notifications
Responses begin with a status byte. Codes < 0x80 are replies to a command we sent; codes >= 0x80 are asynchronous push events from the device.
| Code | Name | Meaning |
|---|---|---|
0x00 | RESP_OK | Command accepted |
0x01 | RESP_ERR | Command failed + error code |
0x03 | RESP_CONTACT | One contact entry (32-byte pubkey + metadata) |
0x05 | RESP_SELF_INFO | Our node_id and name after APP_START |
0x10 | RESP_CONTACT_MSG_V3 | Direct inbound message (SNR + sender prefix + payload) |
0x11 | RESP_CHANNEL_MSG_V3 | Channel broadcast inbound |
0x83 | PUSH_MESSAGES_WAITING | Async: new messages in queue, call SYNC_NEXT_MESSAGE |
Wire Format — the payload byte 0
Once a frame reaches the message payload, Archipelago looks at the first byte to decide what kind of thing it's dealing with. This single-byte marker is the master switch of the entire mesh protocol.
Markers 0xEE and 0xDD are the interesting ones — they carry real production traffic. Everything else is either debug or identity bootstrap.
0xEE — static-key encrypted envelope
[0xEE] [nonce: 12 bytes] [ciphertext...] [auth tag: 16 bytes]
- Key: X25519 ECDH between our Ed25519 identity (converted) and the peer's.
- Cipher: ChaCha20-Poly1305 AEAD.
- Max plaintext:
160 − 1 − 12 − 16 = 131bytes (seecrypto::MAX_ENCRYPTED_PLAINTEXT). - Properties: confidential + authenticated, but compromise of a key decrypts all history.
0xDD — Double Ratchet envelope
[0xDD] [RatchetHeader: 40 bytes] [nonce: 12] [ciphertext] [tag: 16]
- Per-message keys derived via DH ratchet + symmetric-key ratchet (HKDF-SHA256).
- Handles out-of-order delivery via a skipped-keys cache.
- Properties: forward secrecy + post-compromise recovery. Used for
mesh.*chat once a session is established. - Implementation:
mesh/ratchet.rs, session load/save inmesh/listener/session.rs.
0xEE lane is like a locked safe: one key opens everything. The 0xDD lane is like handing your friend a new envelope each time, and burning the old one — so even if someone steals next week's key, they can't read last week's messages.
Encryption Layers
Three cryptographic primitives combine to produce the 0xDD ratchet flow:
X25519 ECDH
Each Double Ratchet step generates a fresh keypair. Peers mix the new shared secret into the chain.
HKDF-SHA256
Derives root key, chain key, and message key at each ratchet step.
ChaCha20-Poly1305
Symmetric AEAD used for the actual payload encryption + authentication tag.
Session bootstrap — X3DH-like handshake
Before the ratchet can start, peers exchange a PrekeyBundle (type 5) and a SessionInit (type 6). Those two messages are carried by the 0xEE static-key envelope, because the ratchet session doesn't exist yet. Once SessionInit is processed, subsequent traffic switches to 0xDD. See mesh/x3dh.rs.
Fragmentation — how a 500-byte message rides a 160-byte pipe
The LoRa frame budget is 160 bytes (protocol::MAX_MESSAGE_LEN). Subtract the marker, nonce, ratchet header, and tag and you end up with ~90 usable plaintext bytes per frame. Anything bigger gets chunked.
For chat messages shorter than 160 bytes, none of this kicks in — the whole thing fits in one frame. For larger payloads (long messages, forwarded content, PSBTs), the sender splits and the receiver joins.
ContentRef path in rpc/mesh/typed_messages.rs.
Dual Transport — LoRa + Tor federation
Archipelago treats LoRa and Tor federation as two lanes of the same highway. A single chat window may receive some messages over radio and others over onion routing, and the UI doesn't distinguish. The mesh module picks the lane per-message based on the peer type and payload size.
Addressing
- Contact ID — 32-bit handle from Meshcore's contact table. Used by
SEND_TXT_MSG. - Pubkey prefix — first 6 bytes of the peer's Ed25519 public key. Included on the wire so receivers can deduplicate and route replies.
- DID / onion — used for federation peers; synthetic contacts carry the DID so the mesh layer can hand the message to the federation layer.
Synthetic federation contacts
To let the chat list show federation peers before any message arrives, Archipelago inserts synthetic contacts into the mesh peer list. Their contact IDs live in the upper half of the 32-bit space (≥ 0x8000_0000), derived deterministically from the federation node's Ed25519 pubkey. Collisions with real LoRa contact IDs are impossible by construction.
All 23 Message Types
Every typed message is a CBOR envelope identified by a single MeshMessageType byte. The Transport column shows which marker carries it on the wire and which Companion command is used.
| ID | Type | Purpose | Marker | Cmd | Chunked? |
|---|---|---|---|---|---|
| 0 | Text | Plain chat message | 0xDD | 0x02 | If >160 B |
| 1 | Alert | Emergency / dead-man heartbeat | 0xDD | 0x02/0x03 | No (short) |
| 2 | Invoice | Lightning / BOLT11 invoice | 0xDD | 0x02 | Usually |
| 3 | PsbtHash | Unsigned tx hash for co-signing | 0xDD | 0x02 | No |
| 4 | Coordinate | GPS location share | 0xDD | 0x02 | No |
| 5 | PrekeyBundle | X3DH bootstrap (pre-session) | 0xEE | 0x02 | No |
| 6 | SessionInit | Initial ratchet message | 0xEE | 0x02 | No |
| 7 | BlockHeader | Bitcoin block height/hash | 0xDD | 0x03 | No |
| 8 | TxRelay | Signed Bitcoin tx for on-grid peer to broadcast | 0xDD | 0x02 | Yes |
| 9 | TxRelayResponse | txid or error from the relay peer | 0xDD | 0x02 | No |
| 10 | LightningRelay | BOLT11 to pay via on-grid peer | 0xDD | 0x02 | Yes |
| 11 | LightningRelayResponse | payment_hash or error | 0xDD | 0x02 | No |
| 12 | TxConfirmation | Depth update (1/2/3 confs) | 0xDD | 0x02 | No |
| 13 | Reply | Quoted reply to a previous message | 0xDD | 0x02 | If long |
| 14 | Reaction | Emoji reaction on MessageKey | 0xDD | 0x02 | No |
| 15 | ReadReceipt | "Seen up to MessageKey X" | 0xDD | 0x02 | No |
| 16 | Forward | Re-forwarded original w/ provenance | 0xDD | 0x02 | Yes |
| 17 | Edit | In-place text replacement | 0xDD | 0x02 | If long |
| 18 | Delete | Tombstone for earlier message | 0xDD | 0x02 | No |
| 19 | ContentRef | CID of blob held by sender (file/image) | 0xDD | 0x02 or Tor | Federation fallback |
| 20 | Presence | Heartbeat + last-activity epoch | 0xDD | 0x03 | No |
| 21 | ChannelInvite | Group membership announcement | 0xDD | 0x03 | No |
| 22 | ContactCard | Shareable federation node card | 0xDD | 0x02 | Maybe |
The remaining sections walk through each category and explain both the sender-side code path and what the bytes look like on the air.
Text, Reply, Edit, Delete, Forward
Text (type 0)
Sender path. rpc.mesh.send → typed_messages::send_text → CBOR-encode the Text{body} variant → ratchet-encrypt → prefix 0xDD → if under 160 B, send in one SEND_TXT_MSG frame; otherwise split into Base64 chunks and send sequentially with a small inter-frame sleep so the radio doesn't overflow its TX buffer.
Reply (type 13)
Same as Text, but the CBOR envelope carries a MessageKey pointing at the parent message (sender pubkey prefix + timestamp). The UI renders a quote banner; the wire cost is ~12 extra bytes.
Edit (type 17)
Envelope contains the original MessageKey plus the new body. Receiver updates its local store in-place and tags the entry "edited".
Delete (type 18)
Tombstone only: MessageKey with no body. Receivers keep the original bytes but mark the row deleted. Costs ~20 bytes on the wire.
Forward (type 16)
Wraps original {sender_name, original_timestamp, body} so the receiver can render "Forwarded from <name>". Because the body is nested, forwards are almost always chunked.
Reaction, ReadReceipt, Presence
Reaction (type 14)
Envelope: {target: MessageKey, emoji: String}. Single-frame, single-emoji. Receiver aggregates reactions per MessageKey and shows them as inline chips (see MessageActions in neode-ui).
ReadReceipt (type 15)
Envelope: {up_to: MessageKey}. Semantically "I've seen everything up to and including this message." One receipt covers all prior unread, so traffic is O(1) per read burst rather than O(n).
Presence (type 20)
Periodic heartbeat carrying {last_activity_epoch}. Broadcast on a channel (SEND_CHANNEL_TXT_MSG, cmd 0x03) rather than to a specific peer, so every listener updates their "last seen" indicator in one shot.
ContentRef — files and images without bloating the radio
LoRa cannot move a 500 KB image. The ContentRef type (19) solves this by sending only a pointer — a content ID (CID) plus a tiny thumbnail or description — and letting the receiver fetch the full blob out-of-band over Tor federation.
ContentRef routed the fetch via a name-match on the contact list, which broke when two peers had the same display name. The fix (see commit 5f7ebf14) resolves the owning peer by DID and falls back to name-match only if DID lookup fails.
Bitcoin & Lightning over LoRa
Archipelago uses the mesh as a Bitcoin transport of last resort. Signed transactions travel from an offline signer, through the mesh, to a peer with internet, who then rebroadcasts them to the Bitcoin network and reports back.
TxRelay (8) → TxRelayResponse (9) → TxConfirmation (12)
The binary framing in mesh/bitcoin_relay.rs is intentionally tight — raw binary, not CBOR — to keep a signed 1-input/1-output tx inside one or two 160-byte frames. Confirmation updates are tiny (txid + depth byte) and ride in a single frame.
LightningRelay (10) → LightningRelayResponse (11)
Same shape but the payload is a BOLT11 invoice string. The relay peer pays the invoice from its own node and returns payment_hash or an error. Invoices are often long enough to chunk.
Invoice (2) and PsbtHash (3)
These are not relays — they're peer-to-peer handoffs. Invoice delivers a BOLT11 to be paid by the recipient. PsbtHash carries just the hash of an unsigned PSBT so the recipient can retrieve the full PSBT out-of-band and co-sign.
BlockHeader (7)
Off-grid nodes need a recent block height to avoid being fooled by stale data. A BlockHeader broadcast (sent via SEND_CHANNEL_TXT_MSG) lets anyone in range learn the latest height and hash from any peer with internet. Tiny payload: 4 bytes height + 32 bytes hash.
Alerts, Coordinates, Dead-Man
Alert (type 1)
Envelope: {kind, message, sender_contact_id}. Kinds include Emergency and Deadman. Alerts can be sent direct-to-contact (for family) or channel-broadcast (for community).
Dead-man switch
A background task in mesh/alerts.rs sends a Deadman alert on a configurable interval (default 6 hours). If the user doesn't touch the UI within that window, the alert fires automatically and asks chosen recipients to check in. Powered off? The next peer to receive your last heartbeat notices the gap.
Coordinate (type 4)
Envelope: {lat, lon, accuracy_m} with lat/lon as fixed-point integers to stay under 16 bytes. Used for off-grid location sharing — hiking, sailing, field ops.
ChannelInvite (type 21)
Phase 5 group chat primitive. Announces a new channel and its membership so other nodes can subscribe. Broadcast via SEND_CHANNEL_TXT_MSG.
Identity, PrekeyBundle, ContactCard
Identity broadcast (marker 0x01, ARCHY:2/3)
The handshake. Before any ratchet session exists, a node advertises its Ed25519 public key on the mesh with an identity packet prefixed 0x01. This is how peers discover each other. The payload encodes protocol version (ARCHY:2 or ARCHY:3) and the raw pubkey. Carried by CMD_SEND_SELF_ADVERT (0x07).
PrekeyBundle (type 5) and SessionInit (type 6)
X3DH handshake. PrekeyBundle advertises a signed prekey; SessionInit consumes it to derive the initial ratchet root key. Both ride on 0xEE (static-key encryption), because the ratchet session they're creating doesn't yet exist.
ContactCard (type 22)
A shareable card containing {did, onion_address, pubkey, display_name}. When a receiver taps "add" on the card, Archipelago one-click federates with that node over Tor. This is the bridge that lets LoRa-discovered peers become full federation contacts.
RPC API — what callers actually invoke
Every user-facing action goes through the RPC dispatcher (api/rpc/dispatcher.rs, lines 287+) and ends in api/rpc/mesh/typed_messages.rs. The tables below show the public surface.
Core commands
| RPC | Effect |
|---|---|
mesh.status | Device info, peer count, enabled state |
mesh.peers | List all discovered peers with RSSI / SNR / hop count |
mesh.messages | Retrieve stored mesh messages |
mesh.send | Send plain text to a specific peer |
mesh.send-channel | Broadcast on a channel |
mesh.broadcast | Mesh-wide announcement |
mesh.configure | Set device params (name, power, channel) |
mesh.debug-dump | Raw state for debugging |
Rich message commands
| RPC | Msg Type | Notes |
|---|---|---|
mesh.send-invoice | Invoice (2) | Deliver BOLT11 to peer |
mesh.send-coordinate | Coordinate (4) | Single frame, fixed-point |
mesh.send-alert | Alert (1) | Emergency or deadman |
mesh.send-content | ContentRef (19) | Stores blob, sends CID |
mesh.fetch-content | — | Pulls blob via federation |
mesh.send-psbt | PsbtHash (3) | Hash only, full PSBT via fetch |
mesh.send-reply | Reply (13) | Quoted response |
mesh.send-reaction | Reaction (14) | Emoji |
mesh.send-read-receipt | ReadReceipt (15) | Cumulative "seen up to" |
mesh.forward-message | Forward (16) | Wraps original + provenance |
mesh.edit-message | Edit (17) | In-place text replacement |
mesh.delete-message | Delete (18) | Tombstone |
User Interface
The Vue side lives under neode-ui/src/views/mesh/ with state in stores/mesh.ts. Notable panels:
Mesh chat
Telegram-style UI with reply banners, inline reaction chips, forward/edit/delete action menu, read-receipts, outbox status.
MeshBitcoinPanel
UI for TxRelay / LightningRelay submission and confirmation tracking.
MeshDeadmanPanel
Configure dead-man interval, pick recipients, show last heartbeat time.
Unified inbox
Federation and mesh chats appear side-by-side; the transport is invisible to the user.
Listener loop — how inbound traffic is decoded
A long-running async task in mesh/listener/mod.rs owns the serial device and feeds events into the rest of the system.
Chunk reassembly happens in listener/session.rs, keyed by (sender_pubkey_prefix, chunk_id). Incomplete chunks expire after a timeout so a lost frame doesn't leak memory.
File Map
| File | Size | Role |
|---|---|---|
mesh/mod.rs | 52 KB | Public API, send paths, federation integration |
mesh/protocol.rs | 26 KB | Frame encoding/decoding, command builders |
mesh/serial.rs | 15 KB | USB driver, device detection, handshake |
mesh/crypto.rs | 10 KB | X25519 ECDH, ChaCha20-Poly1305, HKDF |
mesh/ratchet.rs | 16 KB | Double Ratchet implementation |
mesh/message_types.rs | 23 KB | 23 typed message discriminators + CBOR schemas |
mesh/bitcoin_relay.rs | 17 KB | TxRelay / LightningRelay binary framing |
mesh/listener/dispatch.rs | 29 KB | Typed-message routing into chat/relay/alerts |
mesh/listener/session.rs | 14 KB | Ratchet session persistence + chunk reassembly |
mesh/x3dh.rs | — | Prekey / SessionInit bootstrap |
mesh/outbox.rs | — | Retry queue for unacked sends |
mesh/steganography.rs | — | Weather/sensor framing for deniable traffic |
api/rpc/mesh/typed_messages.rs | — | All mesh.* RPC handlers |
neode-ui/src/stores/mesh.ts | 14 KB | Pinia store consumed by all mesh Vue views |