How Multi-Hop Message Relay Works in BitChat's BLE Mesh with Source Routing
Multi-hop message relay in BitChat's BLE mesh uses source routing (v2 packets) where the sender computes and embeds the full path through intermediate peers; relays forward only to the next hop while falling back to flooding if any hop is unreachable.
BitChat's source-routing extension enables deterministic multi-hop delivery across its Bluetooth Low Energy (BLE) mesh without flooding every packet. This article explains the complete mechanism—from packet format to relay logic—based on the permissionlesstech/bitchat source code.
BitChat Packet Versions and the Route Field
BitChat defines two packet versions with different routing capabilities.
Version 1: Flood-Only Packets
- 2-byte payload length field
- No routing bits; packets rely on classic flood mechanism
Version 2: Source-Routed Packets
- 4-byte length field
- HAS_ROUTE flag enabling variable-length Source Route field (
COUNT + HOPS)
The BinaryProtocol enforces strict version compatibility. In BinaryProtocol.encode (lines 53–66), only packets with version >= 2 may set the HAS_ROUTE flag:
// BinaryProtocol.swift — encoding validation
if version < 2 && hasRoute {
throw BinaryProtocolError.invalidVersionForRouting
}
Decoding ignores the flag for version 1 packets (BinaryProtocol.decodeCore, lines 19–22), ensuring backward compatibility.
The complete specification lives in [docs/SOURCE_ROUTING.md](https://github.com/permissionlesstech/bitchat/blob/main/docs/SOURCE_ROUTING.md).
Topology Discovery: Building the Mesh Graph
Every node advertises its direct neighbors via an IdentityAnnouncement TLV (type 0x04). The MeshTopologyTracker service constructs a bidirectional adjacency map, ignoring edges not confirmed by both endpoints.
This graph feeds the shortest-path search used during packet origination.
Origination Policy: When to Attach a Route
BLESourceRouteOriginationPolicy adds a route only when all six conditions are met:
SenderID == myPeerID(locally authored packet)RecipientIDpresent (directed unicast, not broadcast)TTL > 1(at least one hop available)- Recipient is not a direct neighbor
- Confirmed v2 path exists with ≤ 4 intermediate hops, where every hop speaks v2
- No recent route-failure entry for that recipient
If any condition fails, the packet sends unrouted and floods normally.
// Origination — building a source-routed packet
let route = MeshTopologyTracker.shared.shortestPath(
from: myPeerID,
to: recipientID,
maxHops: 4
)
let packet = BitchatPacket(
version: 2,
type: MessageType.chatMessage.rawValue,
ttl: 5,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
senderID: myPeerID.id,
recipientID: recipientID.id,
route: route.map { $0.id }, // Intermediate hops only
payload: payload,
signature: nil
)
if let routed = BLESourceRouteOriginationPolicy.route(packet, localPeerID: myPeerID) {
BLEService.shared.sendPacket(routed)
}
Relay Decision Logic in BLERouteForwardingPolicy
When BLEService receives a non-local packet, it invokes forwardAlongRouteIfNeeded (lines 3926–3939), which delegates to BLERouteForwardingPolicy.plan:
Step-by-Step Forwarding Rules
- Suppress flood for link-local
REQUEST_SYNCor locally-destined packets - No route → allow flood
TTL <= 1→ suppress flood (dead packet)- Locate self in route list:
- Not on route: forward to
route[0](first hop) - Last hop before destination: forward directly to
RecipientID - Intermediate hop: forward to
route[localIndex + 1](next hop)
- Not on route: forward to
- Forward only if next hop is connected; otherwise fall back to allow flood
The plan method returns three fields:
| Field | Purpose |
|---|---|
shouldSuppressFloodRelay |
true skips normal flood after forwarding |
forwardPacket |
Packet with decremented TTL |
nextHop |
PeerID of next hop, or nil if flooding |
// Relay-side handling
func handleIncoming(_ raw: Data) {
guard let pkt = BinaryProtocol.decode(raw) else { return }
let suppressed = BLEService.shared.forwardAlongRouteIfNeeded(pkt)
if !suppressed {
BLEService.shared.floodPacket(pkt) // Normal flood fallback
}
}
On successful forward, BLEService.sendPacketDirected transmits to the calculated next hop and returns true to skip flooding.
Failure Handling and Route Cache
Two mechanisms ensure reliability when source routes fail:
Immediate Fallback
If the next hop is unreachable during plan execution, the policy returns .allowFloodRelay, triggering re-broadcast to all neighbors.
Source-Route Failure Cache
BLESourceRouteFailureCache records routed sends that receive no response within 10 seconds. Subsequent sends to that peer flood for 60 seconds before attempting source routing again.
// Record potential failure after routed send
BLESourceRouteFailureCache.shared.recordIfNoResponse(
to: recipientID,
within: 10.0
)
End-to-End Multi-Hop Flow
- Topology discovery: Neighbor TLVs build the bidirectional mesh graph
- Origination:
BLESourceRouteOriginationPolicyruns BFS, constructsroute, upgrades to v2, signs, and hands toBLEService - Relay: Each intermediate device executes
BLERouteForwardingPolicy.plan, forwards to next hop, and suppresses flood on success - Destination: Validates signature (covering the route) and processes payload
This design provides deterministic, low-overhead unicast across up to four hops while preserving the flood safety net of the original mesh.
Summary
- Source routing in BitChat uses v2 packets with embedded
HAS_ROUTEflag and hop list BLESourceRouteOriginationPolicyattaches routes only for qualified unicasts with ≤ 4 confirmed v2 hopsBLERouteForwardingPolicydirects relays to forward strictly to next hop when connected- Flood fallback activates when routes are stale, hops are unreachable, or TTL expires
BLESourceRouteFailureCacheprevents repeated failed routed attempts with 60-second cooldown
Frequently Asked Questions
What is the maximum hop count for source-routed packets in BitChat?
BitChat limits source routes to 4 intermediate hops. The origination policy enforces this maximum to balance delivery reliability against latency and packet overhead. Beyond 4 hops, packets send unrouted and rely on classic flooding.
How does BitChat prevent routing loops with source routing?
Source routing inherently prevents loops because the sender computes the complete path and embeds it in the packet. Relays never make independent forwarding decisions—they simply locate themselves in the precomputed list and forward to the specified next hop. The TTL field provides additional protection; packets with TTL <= 1 are never forwarded.
What happens if an intermediate relay in the route goes offline?
If a relay's next hop is not currently connected, BLERouteForwardingPolicy returns .allowFloodRelay, causing the packet to flood to all neighbors instead of following the broken route. Additionally, the failure is recorded in BLESourceRouteFailureCache, triggering flood-only mode for that destination for 60 seconds while the mesh topology updates.
Why does BitChat use source routing instead of dynamic routing tables?
Source routing minimizes per-relay state and computation—relays need not maintain or consult routing tables, reducing memory and processing overhead on BLE-constrained devices. The sender bears the path computation cost once, and intermediate devices execute simple position-based forwarding. This tradeoff suits BitChat's decentralized, smartphone-based mesh where topology changes frequently.
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 →