How BLE Services in BitChat Facilitate Multi-Hop Message Relay in the Mesh Network
BitChat uses a three-layer BLE architecture—link layer, mesh engine, and feature modules—to forward messages across multiple hops through scheduled relays with jitter, TTL-based routing, and cryptographic deduplication.
BitChat's Bluetooth Low Energy (BLE) services implement a complete mesh transport that lets messages traverse multiple intermediate devices to reach peers outside direct radio range. The system combines CoreBluetooth primitives with a protocol engine that handles packet reassembly, routing decisions, and storm-controlled forwarding. According to the BitChat source code, this design achieves deadlock-free concurrency while preserving end-to-end authenticity across arbitrarily long relay chains.
The Three-Layer BLE Mesh Architecture
BitChat separates concerns across three tightly-coupled layers, each with distinct responsibilities and ownership rules.
BLE Link Layer: Raw Connectivity Without Protocol Knowledge
The BLE link layer owns all CoreBluetooth objects and manages the physical radio interface. It scans for peripherals, handles advertising, maintains per-link back-pressure buffers, and emits LinkEvent notifications to the engine.
In bitchat/Services/BLE/BLEService.swift, the link layer operates on a dedicated bleQueue:
// Lines 31-38: Link-layer queue and event emission
private let bleQueue = DispatchQueue(label: "co.bitchat.ble", qos: .userInitiated)
This layer is intentionally protocol-agnostic. It knows nothing about packets, peer identities, or Noise cryptography. Its sole job is reliable byte transport and queue management.
Mesh Engine: Centralized State on a Serial Queue
The mesh engine runs exclusively on a single serial messageQueue. It owns all protocol state:
- Packet codec and fragmentation
- Deduplication tracking
- Relay policy and routing decisions
- Peer registry and topology mapping
- Noise authentication and link bindings
As documented in docs/BLE-ARCHITECTURE-V3.md (lines 39-45), the engine is the sole owner of mesh state. When the link layer receives complete packets, it calls ingestDecodedPacket(linkID:packet:) to hand them to the engine, which then attributes the sender via engine-owned BLELinkAuthState and BLELinkBindings.
Feature Modules: Registered Handlers and Routing Policy
Feature modules (courier, private-media, voice, etc.) register packet handlers with the engine. The critical relay logic lives in BLERouteForwardingPolicy, which the engine consults to decide whether and when to forward packets.
The Multi-Hop Relay Flow
Messages traverse the mesh through five coordinated steps:
1. Receive and Reassemble
BLE peripherals send fragments. The link layer's BLEFragmentAssemblyBuffer collects these until a complete packet is decoded, then calls the engine's ingestion method.
2. Attribute the Sender
The engine verifies the sender's identity through its stored link bindings:
// Engine-owned link authentication
let sender = linkAuth.identity(for: linkID)
assertLinkIdentityEngineOwned() // Debug guard enforcing ownership
This attribution happens entirely within the engine's messageQueue context.
3. Routing Decision with Storm Protection
BLERouteForwardingPolicy.plan() evaluates:
- TTL (time-to-live) remaining
- Hop count already traversed
- Mesh topology from
MeshTopologyTracker
If relaying is warranted, the policy hands the packet to BLEScheduledRelayStore with a computed jitter delay:
// In BLERouteForwardingPolicy.plan(...)
if shouldRelay {
let jitter = computeJitter(for: packet, linkID: incomingLink)
scheduledRelays.scheduleRelay(packet, after: jitter)
}
The jitter algorithm uses highDegreeThreshold and messageTTL to prevent broadcast storms.
4. Execute Relay on Other Links
When the jitter timer fires, the engine enqueues writes on all other writable links:
writeOrEnqueueIfAccepted(packet, on: linkID)
Because the engine owns routing policy, the same packet relays across multiple hops without re-signing, preserving end-to-end Noise authenticity.
5. Deduplication Prevents Loops
Before any re-broadcast, MessageDeduplicator checks the packet's inner ID:
// BLEService.swift, lines 50-52
let messageDeduplicator = MessageDeduplicator()
If this node has already processed the packet, it's dropped immediately. This prevents infinite relay loops in cyclic mesh topologies.
Concurrency Safety and Queue Contracts
The architecture guarantees deadlock-free operation through strict queue discipline:
- The
bleQueueonly async-dispatches tomessageQueueand never synchronously waits - The engine may synchronously wait on the link layer only through
readLinkStatewith special guards
These contracts are enforced by dispatchPrecondition checks and verified by BLEQueueContractTests. The critical sync edge in BLEService.swift (lines 96-105) includes this guard:
onEngine {
// Engine-confined work only
}
Testing Multi-Hop Relay Without Hardware
BitChat provides a deterministic simulator for validating relay behavior. SimulatedMesh wires real BLEService instances together without physical radios.
A representative test from SimulatedMeshTests.swift (lines 66-73):
func test_oneRelayHop() async {
let mesh = SimulatedMesh(nodeCount: 3) // Three BLEService instances
await mesh.pumpUntilStable()
XCTAssertTrue(mesh.node(2).received(packetFrom: mesh.node(0)))
}
This confirms that node 0's message reaches node 2 via node 1 as an intermediate relay—with proper jitter timing and storm bounds enforced.
Practical Code Examples
Broadcasting a Self-Signed Message
func sendMessage(_ content: String) {
// Build and sign with Noise
let signedPacket = noiseService.signPacket(basePacket)!
// Prevent processing our own broadcast copy
let dedupID = BLESelfBroadcastTracker.dedupID(for: signedPacket)
messageDeduplicator.markProcessed(dedupID)
// Engine enqueues for link-layer transmission
broadcastPacket(signedPacket)
}
Key operations: packet signing (lines 71-78), deduplication priming (lines 84-86), and broadcast enqueue (lines 89-90) in BLEService.swift.
Scheduling Relay with Jitter
// BLEService.swift, lines 122-124
scheduledRelays = BLEScheduledRelayStore()
// Inside routing policy execution
scheduledRelays.scheduleRelay(packet, after: computedJitter)
Inbound Packet Processing Pipeline
func ingestDecodedPacket(_ packet: BitchatPacket, linkID: BLEIngressLinkID) {
onEngine {
// 1. Attribute sender
let sender = linkAuth.identity(for: linkID)
// 2. Plan routing
let relayPlan = BLERouteForwardingPolicy.plan(
packet,
from: sender,
topology: meshTopology
)
// 3. Execute (may schedule relays)
relayPlan.execute()
}
}
Key Source Files
| File | Purpose |
|---|---|
bitchat/Services/BLE/BLEService.swift |
Core BLE service—link layer queues, relay stores, deduplication |
docs/BLE-ARCHITECTURE-V3.md |
High-level architecture and engine ownership model |
bitchat/Services/BLE/BLERouteForwardingPolicy.swift |
Routing policy implementation |
bitchat/Services/BLE/BLEScheduledRelayStore.swift |
Jittered relay scheduling |
bitchatTests/Simulation/SimulatedMesh.swift |
Deterministic multi-node test harness |
bitchatTests/Simulation/SimulatedMeshTests.swift |
Relay and storm-bound validation |
Summary
- Three-layer separation: Link layer handles radios, engine owns protocol state, features register handlers
- Jittered relay scheduling:
BLEScheduledRelayStorewithBLERouteForwardingPolicyprevents broadcast storms - Engine-centralized routing: All forwarding decisions happen on the serial
messageQueuewith deadlock-free queue contracts - Cryptographic continuity: Messages relay without re-signing, maintaining Noise end-to-end authenticity
- Loop prevention:
MessageDeduplicatordrops packets already seen at each node - Deterministic testing:
SimulatedMeshvalidates multi-hop behavior without physical hardware
Frequently Asked Questions
How does BitChat prevent infinite relay loops in mesh networks?
The MessageDeduplicator in BLEService.swift tracks packet inner IDs before any re-broadcast. If a node has already processed a packet, it's dropped immediately. Additionally, BLERouteForwardingPolicy respects TTL and hop-count limits, ensuring packets expire after traversing the configured maximum path length.
What prevents broadcast storms when many devices relay simultaneously?
BitChat uses jittered relay scheduling via BLEScheduledRelayStore. The routing policy computes a per-packet delay based on highDegreeThreshold and remaining messageTTL. This randomizes transmission timing across nodes, spreading relay traffic over time rather than allowing synchronized flooding.
Why does the mesh engine use a single serial queue?
The messageQueue serializes all protocol state mutations—peer registry, link bindings, topology, and routing decisions. This eliminates data races without complex locking. The link layer's bleQueue communicates via async dispatch only, and the engine uses guarded synchronous reads (readLinkState) when it needs link-layer information.
Can BitChat's BLE mesh operate without internet connectivity?
Yes. The architecture is explicitly offline-first. All relay decisions happen locally in BLERouteForwardingPolicy using the MeshTopologyTracker. No central coordination, DHT, or internet gateway is required for multi-hop message delivery between peers within Bluetooth range of the connected mesh component.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →