LoRa & Mesh Functionality

How Archipelago sends encrypted messages, Bitcoin transactions, and emergency alerts over long-range radio when the internet is gone.

Meshcore Companion USB Double Ratchet E2E 23 Message Types 160-byte LoRa Frame

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

Think of LoRa as a whisper that travels 10 kilometers. Normal Wi-Fi is a shout: loud, fast, lots of data, but only a few rooms away. LoRa is the opposite — a tiny, slow whisper that can cross an entire city because it's so narrow and patient that it slips through walls, trees, and hills. The tradeoff: you can only whisper about 160 bytes at a time, and each whisper takes a second or two to complete.

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.

ComponentRoleExamples
MCURuns Meshcore firmware, talks USB serialESP32, nRF52840
RadioSemtech LoRa transceiverSX1262, SX1276
BoardMCU + radio + USB + antennaHeltec V3, T-Beam, RAK WisBlock, Station G2
FirmwareMesh routing + Companion USB protocolMeshcore
ConnectionUSB CDC-ACM serial/dev/mesh-radio (udev symlink), /dev/ttyUSB*, /dev/ttyACM*
Link params115200 baud, 8N1Set in mesh/serial.rs
It's a modem. Exactly like a 56k modem from the '90s plugged into your serial port, except the other end of the wire is a radio mesh network instead of a phone line. Archipelago tells it "send this to contact X", and it figures out which radios to hop through.

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

Host → Device: 0x3C '<' │ len_lo len_hiframe_bytes... Device → Host: 0x3E '>' │ len_lo len_hiframe_bytes... Baud: 115200 Framing: 8N1 Source: mesh/serial.rs

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

CodeNamePurpose
0x01APP_STARTHandshake; device returns its node_id and name
0x02SEND_TXT_MSGSend payload to a contact (targeted by 6-byte pubkey prefix)
0x03SEND_CHANNEL_TXT_MSGBroadcast on a channel (no specific recipient)
0x04GET_CONTACTSPull the device's contact table
0x06SET_DEVICE_TIMESync Unix timestamp for message dating
0x07SEND_SELF_ADVERTBroadcast our identity onto the mesh
0x08SET_ADVERT_NAMESet our display name
0x0ASYNC_NEXT_MESSAGEPop the next queued inbound message
0x0BSET_RADIO_PARAMSFrequency, spreading factor, bandwidth
0x0CSET_RADIO_TX_POWERTransmit power (dBm)
0x38GET_STATSDevice 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.

CodeNameMeaning
0x00RESP_OKCommand accepted
0x01RESP_ERRCommand failed + error code
0x03RESP_CONTACTOne contact entry (32-byte pubkey + metadata)
0x05RESP_SELF_INFOOur node_id and name after APP_START
0x10RESP_CONTACT_MSG_V3Direct inbound message (SNR + sender prefix + payload)
0x11RESP_CHANNEL_MSG_V3Channel broadcast inbound
0x83PUSH_MESSAGES_WAITINGAsync: 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.

0x00 Plain text (legacy, unencrypted) 0x01 Identity broadcast (ARCHY:2 / ARCHY:3) 0x02 Typed CBOR envelope (plaintext, used for debug or intra-LAN) 0xEE Encrypted typed — ChaCha20-Poly1305 w/ static shared secret 0xDD Ratcheted typed — Double Ratchet, forward-secure

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]

0xDD — Double Ratchet envelope

[0xDD] [RatchetHeader: 40 bytes] [nonce: 12] [ciphertext] [tag: 16]
Static key vs. ratchet = a safe vs. a self-shredding envelope. The 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.

Chunk header ┌──────────┬──────────┬────────────┐ │ type (1) │ id (1) │ total (1) │ └──────────┴──────────┴────────────┘ Chunk body Up to 140 bytes of Base64-encoded payload Sender: compress → encrypt → split into 140-char chunks → send with tiny inter-chunk delay Receiver: accumulate by (sender, chunk_id) → reassemble → decrypt → decompress → dispatch

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.

Escape hatch: federation fallback. If a peer is a synthetic federation contact and the message is bigger than 160 bytes, Archipelago skips LoRa entirely and routes the message over Tor federation instead. See the 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.

┌──────────────────┐ │ mesh.send(...) │ └────────┬─────────┘ │ ┌──────────┴──────────┐ │ Is peer synthetic? │ └──────────┬──────────┘ No │ Yes ┌──────────┘ └──────────┐ ▼ ▼ LoRa radio Tor federation (160-byte frame) (unlimited, slower setup) │ │ │ if > 160 B && synth ──────┘ (fallback) ▼ Chunked over LoRa or refused if no fallback

Addressing

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.

IDTypePurposeMarkerCmdChunked?
0TextPlain chat message0xDD0x02If >160 B
1AlertEmergency / dead-man heartbeat0xDD0x02/0x03No (short)
2InvoiceLightning / BOLT11 invoice0xDD0x02Usually
3PsbtHashUnsigned tx hash for co-signing0xDD0x02No
4CoordinateGPS location share0xDD0x02No
5PrekeyBundleX3DH bootstrap (pre-session)0xEE0x02No
6SessionInitInitial ratchet message0xEE0x02No
7BlockHeaderBitcoin block height/hash0xDD0x03No
8TxRelaySigned Bitcoin tx for on-grid peer to broadcast0xDD0x02Yes
9TxRelayResponsetxid or error from the relay peer0xDD0x02No
10LightningRelayBOLT11 to pay via on-grid peer0xDD0x02Yes
11LightningRelayResponsepayment_hash or error0xDD0x02No
12TxConfirmationDepth update (1/2/3 confs)0xDD0x02No
13ReplyQuoted reply to a previous message0xDD0x02If long
14ReactionEmoji reaction on MessageKey0xDD0x02No
15ReadReceipt"Seen up to MessageKey X"0xDD0x02No
16ForwardRe-forwarded original w/ provenance0xDD0x02Yes
17EditIn-place text replacement0xDD0x02If long
18DeleteTombstone for earlier message0xDD0x02No
19ContentRefCID of blob held by sender (file/image)0xDD0x02 or TorFederation fallback
20PresenceHeartbeat + last-activity epoch0xDD0x03No
21ChannelInviteGroup membership announcement0xDD0x03No
22ContactCardShareable federation node card0xDD0x02Maybe

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

Like a lighthouse beacon. Presence doesn't go to anyone in particular — it's a flash that everyone in radio range can see. "I'm still here, last active two minutes ago." Cheap and unaddressed.

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.

Sender Receiver ────── ──────── store blob locally (CID) ┌──────────────────────┐ │ ContentRef {cid, │ ──ratchet──▶ │ mime, size, │ 0xDD │ thumb_hash} │ over LoRa └──────────────────────┘ see CID in chat click to fetch ┌─────────────────┐ │ rpc.mesh.fetch- │ │ content(cid) │ └────────┬────────┘ ▼ federation (Tor) resolve DID → pull blob
Resolution bug fix note. An earlier revision of 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)

Offline signer On-grid relay peer Bitcoin p2p ────────────── ────────────────── ─────────── sign tx ┌─────────────┐ │ TxRelay │ ─ratchet/LoRa▶ decrypt → validate │ {raw_tx} │ broadcast via bitcoind ───▶ mempool └─────────────┘ │ ▼ ┌────────────────────────┐ ◀─ratchet│ TxRelayResponse{txid} │ └────────────────────────┘ (or {error}) later, as blocks arrive: ┌────────────────────────┐ ◀─ratchet│ TxConfirmation │ │ {txid, depth: 1..3} │ └────────────────────────┘

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

RPCEffect
mesh.statusDevice info, peer count, enabled state
mesh.peersList all discovered peers with RSSI / SNR / hop count
mesh.messagesRetrieve stored mesh messages
mesh.sendSend plain text to a specific peer
mesh.send-channelBroadcast on a channel
mesh.broadcastMesh-wide announcement
mesh.configureSet device params (name, power, channel)
mesh.debug-dumpRaw state for debugging

Rich message commands

RPCMsg TypeNotes
mesh.send-invoiceInvoice (2)Deliver BOLT11 to peer
mesh.send-coordinateCoordinate (4)Single frame, fixed-point
mesh.send-alertAlert (1)Emergency or deadman
mesh.send-contentContentRef (19)Stores blob, sends CID
mesh.fetch-contentPulls blob via federation
mesh.send-psbtPsbtHash (3)Hash only, full PSBT via fetch
mesh.send-replyReply (13)Quoted response
mesh.send-reactionReaction (14)Emoji
mesh.send-read-receiptReadReceipt (15)Cumulative "seen up to"
mesh.forward-messageForward (16)Wraps original + provenance
mesh.edit-messageEdit (17)In-place text replacement
mesh.delete-messageDelete (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.

loop { event = await serial_read() match event { PUSH_MESSAGES_WAITING → send SYNC_NEXT_MESSAGE until empty RESP_CONTACT_MSG_V3 → decode.rs extracts payload → match first byte: 0x00 plain text 0x01 identity → frames::parse_identity 0x02 typed CBOR plaintext 0xEE → crypto::decrypt_static 0xDD → session::load + ratchet::decrypt → dispatch.rs routes typed msg to chat store / bitcoin relay / alerts / presence / ... RESP_CONTACT → contact list update RESP_SELF_INFO → record our node_id } }

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

FileSizeRole
mesh/mod.rs52 KBPublic API, send paths, federation integration
mesh/protocol.rs26 KBFrame encoding/decoding, command builders
mesh/serial.rs15 KBUSB driver, device detection, handshake
mesh/crypto.rs10 KBX25519 ECDH, ChaCha20-Poly1305, HKDF
mesh/ratchet.rs16 KBDouble Ratchet implementation
mesh/message_types.rs23 KB23 typed message discriminators + CBOR schemas
mesh/bitcoin_relay.rs17 KBTxRelay / LightningRelay binary framing
mesh/listener/dispatch.rs29 KBTyped-message routing into chat/relay/alerts
mesh/listener/session.rs14 KBRatchet session persistence + chunk reassembly
mesh/x3dh.rsPrekey / SessionInit bootstrap
mesh/outbox.rsRetry queue for unacked sends
mesh/steganography.rsWeather/sensor framing for deniable traffic
api/rpc/mesh/typed_messages.rsAll mesh.* RPC handlers
neode-ui/src/stores/mesh.ts14 KBPinia store consumed by all mesh Vue views

Summary scoreboard

23
Message types
160
Bytes / frame
2
Transports
5
Wire markers
~6k
LoC in mesh/
FS
Forward-secure
Bottom line. Archipelago's mesh isn't a chat toy. It's a complete off-grid transport with forward-secure end-to-end encryption, 23 typed message kinds, Bitcoin and Lightning relay, fragmentation, store-and-forward, and a seamless Tor federation fallback. From the user's perspective it looks like iMessage; from the wire's perspective it's a carefully budgeted 160 bytes of ChaCha20 ciphertext riding on a sub-kbps radio link.