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:

  1. SenderID == myPeerID (locally authored packet)
  2. RecipientID present (directed unicast, not broadcast)
  3. TTL > 1 (at least one hop available)
  4. Recipient is not a direct neighbor
  5. Confirmed v2 path exists with ≤ 4 intermediate hops, where every hop speaks v2
  6. 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

  1. Suppress flood for link-local REQUEST_SYNC or locally-destined packets
  2. No route → allow flood
  3. TTL <= 1 → suppress flood (dead packet)
  4. 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)
  5. 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

  1. Topology discovery: Neighbor TLVs build the bidirectional mesh graph
  2. Origination: BLESourceRouteOriginationPolicy runs BFS, constructs route, upgrades to v2, signs, and hands to BLEService
  3. Relay: Each intermediate device executes BLERouteForwardingPolicy.plan, forwards to next hop, and suppresses flood on success
  4. 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_ROUTE flag and hop list
  • BLESourceRouteOriginationPolicy attaches routes only for qualified unicasts with ≤ 4 confirmed v2 hops
  • BLERouteForwardingPolicy directs relays to forward strictly to next hop when connected
  • Flood fallback activates when routes are stale, hops are unreachable, or TTL expires
  • BLESourceRouteFailureCache prevents 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →