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 acknowledgedreceiptID: Unique identifier for this receipt itselfreaderID: 8-bytePeerIDof the readerreaderNickname: Display name for UI renderingtimestamp: 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:
-
Sender UI triggers read action — When a user views a private message, the view model creates a
ReadReceiptwithoriginalMessageID, localPeerID, and nickname. -
Router selects transport —
router.sendReadReceipt(receipt, to: peerID)evaluates reachability and chooses the appropriate network path. -
Transport serializes and transmits — The selected transport calls
receipt.toBinaryData()and forwards the payload. -
Receiver deserializes payload —
ReadReceipt.fromBinaryData(data)validates and reconstructs the receipt. -
Receiver updates state and responds —
chatViewModel.didReceiveReadReceipt(incoming)marks the message as.readand 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
ReadReceiptinbitchat/Models/ReadReceipt.swiftwith dual JSON/binary serialization support - Binary encoding optimizes network efficiency through compact
toBinaryData()/fromBinaryData(_:)methods - Transport abstraction via
MessageRouterenables seamless operation across direct, mesh, and Nostr relay networks - Delivery status lifecycle is tracked by
DeliveryStatusenum with.deliveredand.readconfirmation states - Idempotent handling prevents duplicate receipts through
sentReadReceiptstracking andpruneSentReadReceipts(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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →