How BitChat Implements Read Receipts and Delivery Acknowledgments: A Complete Technical Breakdown

BitChat implements read receipts and delivery acknowledgments through a structured ReadReceipt data model with binary serialization, transport-layer routing via MessageRouter, and view-model state tracking using the DeliveryStatus enum.

BitChat, a permissionless peer-to-peer messaging app, provides reliable message delivery confirmation through a carefully architected system of data models, transport abstractions, and state management. This article examines exactly how read receipts and delivery acknowledgments work in the permissionlesstech/bitchat codebase, from binary packet encoding to end-to-end flow completion.

Read Receipt Data Model and Binary Encoding

The foundation of BitChat's read receipt system is the ReadReceipt struct defined in bitchat/Models/ReadReceipt.swift. This struct captures five critical fields:

  • originalMessageID: UUID of the message being acknowledged
  • receiptID: Unique identifier for this receipt itself
  • readerID: 8-byte PeerID of the reader
  • readerNickname: Display name for UI rendering
  • timestamp: 64-bit Unix timestamp

The struct conforms to Codable for JSON persistence and JSON-based transport scenarios. More importantly, it provides custom binary encoding for low-level packet exchange through two key methods:

// Serialize to compact binary payload
let binaryData = receipt.toBinaryData()

// Deserialize from received payload
if let receipt = ReadReceipt.fromBinaryData(data) {
    // Process the receipt
}

The toBinaryData() method constructs a Data payload containing: two 16-byte UUIDs (original message ID and receipt ID), 8 bytes for the reader's PeerID, 8 bytes for the timestamp, and the UTF-8 encoded nickname string. The fromBinaryData(_:) validator enforces field integrity—checking UUID structure, peer ID validity, timestamp range bounds, and nickname presence—before constructing a valid ReadReceipt instance.

Transport Layer and Message Routing

BitChat abstracts network transport through the sendReadReceipt(_:to:) protocol method. Actual implementations vary by network path:

Transport Type Implementation Location Behavior
Mock (testing) bitchatTests/Mocks/MockTransport.swift Enqueues receipt in sentReadReceipts array for test verification
Direct peer-to-peer Production transport layer Forwards binary payload over direct connection
Mesh relay Production transport layer Routes through mesh network participants
Nostr relay Production transport layer Publishes to configured Nostr relays

The MessageRouter class in MessageRouterTests.swift (demonstrated via test-driven specifications) handles transport selection transparently. When router.sendReadReceipt(_:to:) is invoked, the router evaluates reachability and selects the appropriate transport—direct, mesh, or relay—guaranteeing receipt delivery regardless of underlying connection type.

// Router abstracts transport selection
router.sendReadReceipt(receipt, to: peerID)  // May use direct, mesh, or Nostr

Delivery Status Tracking with DeliveryStatus Enum

Message delivery state is represented by the DeliveryStatus enum in localPackages/BitFoundation/Sources/BitFoundation/DeliveryStatus.swift. This enum tracks the complete lifecycle:

  • .sending — Message queued for transmission
  • .sent — Handed to transport layer
  • .delivered(to:at:) — Remote peer confirmed receipt
  • .read(by:at:) — Remote peer sent read receipt back

Each BitchatMessage carries a deliveryStatus property that transitions through these states. Transport implementations update this status upon confirmation, enabling accurate UI indicators.

View-Model State Management and Idempotency

The ChatViewModel maintains receipt handling state through two critical sets, as demonstrated in ChatViewModelDeliveryStatusTests.swift:

  • sentReadReceipts: Set<UUID> tracking receipts already transmitted (prevents duplicate sends)
  • sentDeliveryAcks: Set<UUID> tracking messages with confirmed delivery

Three helper methods manage this state:

// Mark a receipt as sent to avoid retransmission
func markReadReceiptSent(_ receiptID: UUID)

// Clean up old receipt IDs to prevent unbounded memory growth
func pruneSentReadReceipts(keeping: Int)

// Process incoming receipt and update message status
func didReceiveReadReceipt(_ receipt: ReadReceipt)

The didReceiveReadReceipt(_:) implementation follows this pattern:

func didReceiveReadReceipt(_ receipt: ReadReceipt) {
    // Locate original message
    if var msg = messageStore[receipt.originalMessageID] {
        // Update to read status with reader metadata
        msg.deliveryStatus = .read(by: receipt.readerNickname, 
                                   at: receipt.timestamp)
        messageStore[receipt.originalMessageID] = msg
    }
    // Record processing to prevent duplicate handling
    sentReadReceipts.insert(receipt.receiptID)
}

Complete End-to-End Flow

The full read receipt lifecycle spans five coordinated steps:

  1. Sender UI triggers read action — When a user views a private message, the view model creates a ReadReceipt with originalMessageID, local PeerID, and nickname.

  2. Router selects transport — router.sendReadReceipt(receipt, to: peerID) evaluates reachability and chooses the appropriate network path.

  3. Transport serializes and transmits — The selected transport calls receipt.toBinaryData() and forwards the payload.

  4. Receiver deserializes payload — ReadReceipt.fromBinaryData(data) validates and reconstructs the receipt.

  5. Receiver updates state and responds — chatViewModel.didReceiveReadReceipt(incoming) marks the message as .read and may trigger a delivery acknowledgment back to the original sender.

This flow is exercised across the combined test suites in ChatViewModelTests, MessageRouterTests, and MockTransport, ensuring correctness across network condition variations.

Summary

  • Read receipts are modeled by ReadReceipt in bitchat/Models/ReadReceipt.swift with dual JSON/binary serialization support
  • Binary encoding optimizes network efficiency through compact toBinaryData()/fromBinaryData(_:) methods
  • Transport abstraction via MessageRouter enables seamless operation across direct, mesh, and Nostr relay networks
  • Delivery status lifecycle is tracked by DeliveryStatus enum with .delivered and .read confirmation states
  • Idempotent handling prevents duplicate receipts through sentReadReceipts tracking and pruneSentReadReceipts(keeping:) cleanup

Frequently Asked Questions

How does BitChat prevent duplicate read receipts from being sent?

The ChatViewModel maintains a sentReadReceipts set containing UUIDs of all receipts already transmitted. Before sending, the view model checks against this set via markReadReceiptSent(_:). The pruneSentReadReceipts(keeping:) method limits memory usage by retaining only recent IDs.

What transport protocols does BitChat use for read receipt delivery?

According to the MessageRouter implementation, BitChat supports three transport paths: direct peer-to-peer connections, mesh network relays, and Nostr protocol relays. The router automatically selects the reachable transport for each recipient without caller intervention.

How is the read receipt data structured at the binary level?

The ReadReceipt.toBinaryData() method produces a payload containing: 16 bytes for originalMessageID (UUID), 16 bytes for receiptID (UUID), 8 bytes for readerID (PeerID), 8 bytes for timestamp (UInt64), and variable-length UTF-8 bytes for readerNickname. This totals 48 bytes plus nickname length.

Can delivery status be tracked for messages sent to offline peers?

Yes. The DeliveryStatus enum includes intermediate states .sending and .sent that apply before remote confirmation. When a peer comes online and receives the message, the status advances to .delivered(to:at:). The sender's transport layer queues receipts and retry logic handles intermittent connectivity.

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 →