Compare commits

..
Author SHA1 Message Date
jack 84d1fe9881 Plumtree gossip: don't subset REQUEST_SYNC fanout; make RequestSyncPacket.encode use const 2025-09-11 23:21:19 +02:00
callebtc 0b76d76b3d woohooo 2025-09-11 22:47:22 +02:00
callebtc 2db48a34c0 wip 2025-09-11 22:35:41 +02:00
11 changed files with 511 additions and 43 deletions
+32
View File
@@ -41,6 +41,14 @@
0481A35D2E6DA18600FC845E /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A35C2E6DA18600FC845E /* libz.tbd */; };
0481A3902E734CAE00FC845E /* CommandProcessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0481A38F2E734CAE00FC845E /* CommandProcessorTests.swift */; };
0481A3912E734CAE00FC845E /* CommandProcessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0481A38F2E734CAE00FC845E /* CommandProcessorTests.swift */; };
0481A3932E73730100FC845E /* RequestSyncPacket.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0481A3922E73730100FC845E /* RequestSyncPacket.swift */; };
0481A3942E73730100FC845E /* RequestSyncPacket.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0481A3922E73730100FC845E /* RequestSyncPacket.swift */; };
0481A3992E73730F00FC845E /* GossipSyncManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0481A3952E73730F00FC845E /* GossipSyncManager.swift */; };
0481A39A2E73730F00FC845E /* PacketIdUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0481A3962E73730F00FC845E /* PacketIdUtil.swift */; };
0481A39B2E73730F00FC845E /* SeenPacketsBloomFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0481A3972E73730F00FC845E /* SeenPacketsBloomFilter.swift */; };
0481A39C2E73730F00FC845E /* GossipSyncManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0481A3952E73730F00FC845E /* GossipSyncManager.swift */; };
0481A39D2E73730F00FC845E /* PacketIdUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0481A3962E73730F00FC845E /* PacketIdUtil.swift */; };
0481A39E2E73730F00FC845E /* SeenPacketsBloomFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0481A3972E73730F00FC845E /* SeenPacketsBloomFilter.swift */; };
048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; };
048A4BE82E5CCCC300162C4A /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; };
048A4BE92E5CCCC300162C4B /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; };
@@ -233,6 +241,10 @@
0481A35A2E6D9BEF00FC845E /* libz.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libz.tbd; path = usr/lib/libz.tbd; sourceTree = SDKROOT; };
0481A35C2E6DA18600FC845E /* libz.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libz.tbd; path = usr/lib/libz.tbd; sourceTree = SDKROOT; };
0481A38F2E734CAE00FC845E /* CommandProcessorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandProcessorTests.swift; sourceTree = "<group>"; };
0481A3922E73730100FC845E /* RequestSyncPacket.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestSyncPacket.swift; sourceTree = "<group>"; };
0481A3952E73730F00FC845E /* GossipSyncManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GossipSyncManager.swift; sourceTree = "<group>"; };
0481A3962E73730F00FC845E /* PacketIdUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketIdUtil.swift; sourceTree = "<group>"; };
0481A3972E73730F00FC845E /* SeenPacketsBloomFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeenPacketsBloomFilter.swift; sourceTree = "<group>"; };
048A4BE62E5CCCC300162C4A /* TransportConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransportConfig.swift; sourceTree = "<group>"; };
048A4C272E5FCD6600162C4A /* GeohashBookmarksStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeohashBookmarksStore.swift; sourceTree = "<group>"; };
048A4C2A2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeohashBookmarksStoreTests.swift; sourceTree = "<group>"; };
@@ -372,9 +384,20 @@
path = Frameworks;
sourceTree = "<group>";
};
0481A3982E73730F00FC845E /* Sync */ = {
isa = PBXGroup;
children = (
0481A3952E73730F00FC845E /* GossipSyncManager.swift */,
0481A3962E73730F00FC845E /* PacketIdUtil.swift */,
0481A3972E73730F00FC845E /* SeenPacketsBloomFilter.swift */,
);
path = Sync;
sourceTree = "<group>";
};
0575DCBD15C7C719ADDCB67E /* Models */ = {
isa = PBXGroup;
children = (
0481A3922E73730100FC845E /* RequestSyncPacket.swift */,
11186E29A064E8D210880E1B /* BitchatPeer.swift */,
);
path = Models;
@@ -414,6 +437,7 @@
0575DCBD15C7C719ADDCB67E /* Models */,
637EDFDD042BDB5F2569A501 /* Noise */,
E78C7F4B6769C0A72F5DE544 /* Nostr */,
0481A3982E73730F00FC845E /* Sync */,
ADD53BCDA233C02E53458926 /* Protocols */,
D98A3186D7E4C72E35BDF7FE /* Services */,
9A78348821A7D3374607D4E3 /* Utils */,
@@ -829,6 +853,9 @@
923027D6F2F417AFA2488127 /* BitchatProtocol.swift in Sources */,
D450CF41F207BDE1A1AAA56E /* ChatViewModel.swift in Sources */,
B0CA7796B2B2AC2B33F84548 /* CompressionUtil.swift in Sources */,
0481A3992E73730F00FC845E /* GossipSyncManager.swift in Sources */,
0481A39A2E73730F00FC845E /* PacketIdUtil.swift in Sources */,
0481A39B2E73730F00FC845E /* SeenPacketsBloomFilter.swift in Sources */,
92D34E7A07C990C8A815B0CE /* ContentView.swift in Sources */,
5C93B4FDD0C448C3EDDBF8AE /* FavoritesPersistenceService.swift in Sources */,
047502B42E55FED60083520F /* MeshPeerList.swift in Sources */,
@@ -854,6 +881,7 @@
6A85FC357ACD85DBD9020845 /* NostrRelayManager.swift in Sources */,
749D8CF8A362B6CD0786782D /* NotificationService.swift in Sources */,
049BD3AF2E51ED60001A566B /* Transport.swift in Sources */,
0481A3942E73730100FC845E /* RequestSyncPacket.swift in Sources */,
E2DCF7817344F1CCDB8B7B2F /* SecureIdentityStateManager.swift in Sources */,
049BD3A02E51DBF4001A566B /* Packets.swift in Sources */,
047502892E5416250083520F /* Geohash.swift in Sources */,
@@ -894,6 +922,9 @@
6DE056E1EE9850E9FBF50157 /* BitchatProtocol.swift in Sources */,
7576A357B278E5733E9D9F33 /* ChatViewModel.swift in Sources */,
7DCA0DBCB8884E3B31C7BCE3 /* CompressionUtil.swift in Sources */,
0481A39C2E73730F00FC845E /* GossipSyncManager.swift in Sources */,
0481A39D2E73730F00FC845E /* PacketIdUtil.swift in Sources */,
0481A39E2E73730F00FC845E /* SeenPacketsBloomFilter.swift in Sources */,
1D9674FA5F998503831DC281 /* ContentView.swift in Sources */,
ACE2ED172C37F01561E50B71 /* FavoritesPersistenceService.swift in Sources */,
047502B62E55FED60083520F /* MeshPeerList.swift in Sources */,
@@ -919,6 +950,7 @@
BCD0EBACD82AF5E55C2CB2B9 /* NostrRelayManager.swift in Sources */,
61C81ED5F679D5E973EE0C07 /* NotificationService.swift in Sources */,
049BD3AE2E51ED60001A566B /* Transport.swift in Sources */,
0481A3932E73730100FC845E /* RequestSyncPacket.swift in Sources */,
68C4BE564735F6E7915274A2 /* SecureIdentityStateManager.swift in Sources */,
049BD3A22E51DBF4001A566B /* Packets.swift in Sources */,
047502872E5416250083520F /* Geohash.swift in Sources */,
+62
View File
@@ -0,0 +1,62 @@
import Foundation
// REQUEST_SYNC payload TLV (type, length16, value)
// - 0x01: mBytes (uint16)
// - 0x02: k (uint8)
// - 0x03: bloom filter bits (opaque byte array of length mBytes)
struct RequestSyncPacket {
let mBytes: Int
let k: Int
let bits: Data
func encode() -> Data {
var out = Data()
func putTLV(_ t: UInt8, _ v: Data) {
out.append(t)
let len = UInt16(v.count)
out.append(UInt8((len >> 8) & 0xFF))
out.append(UInt8(len & 0xFF))
out.append(v)
}
// mBytes
let mbBE = UInt16(mBytes).bigEndian
let mbData = withUnsafeBytes(of: mbBE) { Data($0) }
putTLV(0x01, mbData)
// k
putTLV(0x02, Data([UInt8(k & 0xFF)]))
// bits
putTLV(0x03, bits)
return out
}
static func decode(from data: Data) -> RequestSyncPacket? {
var off = 0
var mBytes: Int? = nil
var k: Int? = nil
var bits: Data? = nil
while off + 3 <= data.count {
let t = Int(data[off]); off += 1
guard off + 2 <= data.count else { return nil }
let len = (Int(data[off]) << 8) | Int(data[off+1]); off += 2
guard off + len <= data.count else { return nil }
let v = data.subdata(in: off..<(off+len)); off += len
switch t {
case 0x01:
if v.count == 2 {
let mb = (Int(v[0]) << 8) | Int(v[1])
mBytes = mb
}
case 0x02:
if v.count == 1 { k = Int(v[0]) }
case 0x03:
bits = v
default:
break // forward compatible; ignore unknown TLVs
}
}
guard let mb = mBytes, let kk = k, let bb = bits, mb == bb.count else { return nil }
return RequestSyncPacket(mBytes: mb, k: kk, bits: bb)
}
}
+2
View File
@@ -125,6 +125,7 @@ enum MessageType: UInt8 {
case announce = 0x01 // "I'm here" with nickname
case message = 0x02 // Public chat message
case leave = 0x03 // "I'm leaving"
case requestSync = 0x21 // Bloom filter-based sync request (local-only)
// Noise encryption
case noiseHandshake = 0x10 // Handshake (init or response determined by payload)
@@ -138,6 +139,7 @@ enum MessageType: UInt8 {
case .announce: return "announce"
case .message: return "message"
case .leave: return "leave"
case .requestSync: return "requestSync"
case .noiseHandshake: return "noiseHandshake"
case .noiseEncrypted: return "noiseEncrypted"
case .fragment: return "fragment"
+70 -3
View File
@@ -137,6 +137,9 @@ final class BLEService: NSObject {
private var pendingDirectedRelays: [String: [String: (packet: BitchatPacket, enqueuedAt: Date)]] = [:]
// Debounce for 'reconnected' logs
private var lastReconnectLogAt: [String: Date] = [:]
// MARK: - Gossip Sync
private var gossipSyncManager: GossipSyncManager?
// MARK: - Maintenance Timer
@@ -395,9 +398,15 @@ final class BLEService: NSObject {
}
timer.resume()
maintenanceTimer = timer
// Publish initial empty state
requestPeerDataPublish()
// Initialize gossip sync manager
let sync = GossipSyncManager(myPeerID: myPeerID)
sync.delegate = self
sync.start()
self.gossipSyncManager = sync
}
func setNickname(_ nickname: String) {
@@ -772,6 +781,8 @@ final class BLEService: NSObject {
self.messageDeduplicator.markProcessed(dedupID)
// Call synchronously since we're already on background queue
self.broadcastPacket(signedPacket)
// Track our own broadcast for sync
self.gossipSyncManager?.onPublicPacketSeen(signedPacket)
}
}
}
@@ -1096,10 +1107,13 @@ final class BLEService: NSObject {
}
// For broadcast (no directed peer) and non-fragment, choose a subset deterministically
// Special-case announces: do NOT subset to maximize reach for presence
// Special-case control/presence messages: do NOT subset to maximize immediate coverage
var selectedPeripheralIDs = Set(allowedPeripheralIDs)
var selectedCentralIDs = Set(allowedCentralIDs)
if directedOnlyPeer == nil && packet.type != MessageType.fragment.rawValue && packet.type != MessageType.announce.rawValue {
if directedOnlyPeer == nil
&& packet.type != MessageType.fragment.rawValue
&& packet.type != MessageType.announce.rawValue
&& packet.type != MessageType.requestSync.rawValue {
let kp = subsetSizeForFanout(allowedPeripheralIDs.count)
let kc = subsetSizeForFanout(allowedCentralIDs.count)
selectedPeripheralIDs = selectDeterministicSubset(ids: allowedPeripheralIDs, k: kp, seed: messageID)
@@ -1130,6 +1144,12 @@ final class BLEService: NSObject {
}
}
// Directed send helper (unicast to a specific peerID) without altering packet contents
private func sendPacketDirected(_ packet: BitchatPacket, to peerID: String) {
guard let data = packet.toBinaryData(padding: false) else { return }
sendOnAllLinks(packet: packet, data: data, pad: false, directedOnlyPeer: peerID)
}
// MARK: - Directed store-and-forward
private func spoolDirectedPacket(_ packet: BitchatPacket, recipientPeerID: String) {
let msgID = makeMessageID(for: packet)
@@ -1380,6 +1400,9 @@ final class BLEService: NSObject {
case .message:
handleMessage(packet, from: senderID)
case .requestSync:
handleRequestSync(packet, from: senderID)
case .noiseHandshake:
handleNoiseHandshake(packet, from: senderID)
@@ -1580,12 +1603,17 @@ final class BLEService: NSObject {
// Only notify of connection for new or reconnected peers when it is a direct announce
if (packet.ttl == self.messageTTL) && (isNewPeer || isReconnectedPeer) {
self.delegate?.didConnectToPeer(peerID)
// Schedule initial unicast sync to this peer
self.gossipSyncManager?.scheduleInitialSyncToPeer(peerID, delaySeconds: 5.0)
}
self.requestPeerDataPublish()
self.delegate?.didUpdatePeerList(currentPeerIDs)
}
// Track for sync (include our own and others' announces)
gossipSyncManager?.onPublicPacketSeen(packet)
// Send announce back for bidirectional discovery (only once per peer)
let announceBackID = "announce-back-\(peerID)"
let shouldSendBack = !messageDeduplicator.contains(announceBackID)
@@ -1607,6 +1635,15 @@ final class BLEService: NSObject {
}
}
}
// Handle REQUEST_SYNC: decode payload and respond with missing packets via sync manager
private func handleRequestSync(_ packet: BitchatPacket, from peerID: String) {
guard let req = RequestSyncPacket.decode(from: packet.payload) else {
SecureLogger.warning("⚠️ Malformed REQUEST_SYNC from \(peerID)", category: .session)
return
}
gossipSyncManager?.handleRequestSync(fromPeerID: peerID, request: req)
}
// Mention parsing moved to ChatViewModel
@@ -1647,6 +1684,11 @@ final class BLEService: NSObject {
}
}
// Track broadcast messages (recipientID == nil indicates broadcast)
if packet.recipientID == nil && packet.type == MessageType.message.rawValue {
gossipSyncManager?.onPublicPacketSeen(packet)
}
guard accepted else {
SecureLogger.warning("🚫 Dropping public message from unverified or unknown peer \(peerID.prefix(8))", category: .security)
return
@@ -1858,6 +1900,8 @@ final class BLEService: NSObject {
self?.broadcastPacket(signedPacket)
}
}
// Ensure our own announce is included in sync state
gossipSyncManager?.onPublicPacketSeen(signedPacket)
}
func sendDeliveryAck(for messageID: String, to peerID: String) {
@@ -2246,6 +2290,29 @@ final class BLEService: NSObject {
}
}
// MARK: - GossipSyncManager Delegate
extension BLEService: GossipSyncManager.Delegate {
func sendPacket(_ packet: BitchatPacket) {
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
broadcastPacket(packet)
} else {
messageQueue.async { [weak self] in self?.broadcastPacket(packet) }
}
}
func sendPacket(to peerID: String, packet: BitchatPacket) {
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
sendPacketDirected(packet, to: peerID)
} else {
messageQueue.async { [weak self] in self?.sendPacketDirected(packet, to: peerID) }
}
}
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket {
return noiseService.signPacket(packet) ?? packet
}
}
// MARK: - CBCentralManagerDelegate
extension BLEService: CBCentralManagerDelegate {
@@ -13,16 +13,12 @@ final class FavoritesPersistenceService: ObservableObject {
let theyFavoritedUs: Bool
let favoritedAt: Date
let lastUpdated: Date
// Track what we last sent as OUR npub to this peer, to avoid resending unless it changes
// Note: we do not track which npub we last sent to them; sending happens only on favorite toggle
var isMutual: Bool {
isFavorite && theyFavoritedUs
}
}
// We intentionally do not track when we last sent our npub; sending happens only on favorite toggle.
private static let storageKey = "chat.bitchat.favorites"
private static let keychainService = "chat.bitchat.favorites"
-1
View File
@@ -74,7 +74,6 @@ final class MessageRouter {
}
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {
// Route via mesh when connected; else use Nostr
if mesh.isPeerConnected(peerID) {
mesh.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
} else {
+21
View File
@@ -195,6 +195,27 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
let favoriteStatus = favorites[noiseKey] {
peer.favoriteStatus = favoriteStatus
peer.nostrPublicKey = favoriteStatus.peerNostrPublicKey
} else {
// Check by nickname for reconnected peers
let favoriteByNickname = favorites.values.first {
$0.peerNickname == peerInfo.nickname
}
if let favorite = favoriteByNickname,
let noiseKey = peerInfo.noisePublicKey {
SecureLogger.debug("🔄 Found favorite for '\(peerInfo.nickname)' by nickname, updating noise key", category: .session)
// Update the favorite's key in persistence
favoritesService.updateNoisePublicKey(
from: favorite.peerNoisePublicKey,
to: noiseKey,
peerNickname: peerInfo.nickname
)
// Get updated favorite
peer.favoriteStatus = favoritesService.getFavoriteStatus(for: noiseKey)
peer.nostrPublicKey = peer.favoriteStatus?.peerNostrPublicKey ?? favorite.peerNostrPublicKey
}
}
return peer
+168
View File
@@ -0,0 +1,168 @@
import Foundation
// Gossip-based sync manager using rotating Bloom filters
final class GossipSyncManager {
protocol Delegate: AnyObject {
func sendPacket(_ packet: BitchatPacket)
func sendPacket(to peerID: String, packet: BitchatPacket)
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket
}
struct Config {
var seenCapacity: Int = 100 // recent broadcast messages kept
var bloomMaxBytes: Int = 256 // up to 256 bytes
var bloomTargetFpr: Double = 0.01 // 1%
}
private let myPeerID: String
private let config: Config
weak var delegate: Delegate?
// Bloom filter
private let bloom: SeenPacketsBloomFilter
// Storage: broadcast messages (ordered by insert), and latest announce per sender
private var messages: [String: BitchatPacket] = [:] // idHex -> packet
private var messageOrder: [String] = []
private var latestAnnouncementByPeer: [String: (id: String, packet: BitchatPacket)] = [:]
// Timer
private var periodicTimer: DispatchSourceTimer?
private let queue = DispatchQueue(label: "mesh.sync", qos: .utility)
init(myPeerID: String, config: Config = Config()) {
self.myPeerID = myPeerID
self.config = config
self.bloom = SeenPacketsBloomFilter(maxBytes: config.bloomMaxBytes, targetFpr: config.bloomTargetFpr)
}
func start() {
stop()
let timer = DispatchSource.makeTimerSource(queue: queue)
timer.schedule(deadline: .now() + 30.0, repeating: 30.0, leeway: .seconds(1))
timer.setEventHandler { [weak self] in self?.sendRequestSync() }
timer.resume()
periodicTimer = timer
}
func stop() {
periodicTimer?.cancel(); periodicTimer = nil
}
func scheduleInitialSyncToPeer(_ peerID: String, delaySeconds: TimeInterval = 5.0) {
queue.asyncAfter(deadline: .now() + delaySeconds) { [weak self] in
self?.sendRequestSync(to: peerID)
}
}
func onPublicPacketSeen(_ packet: BitchatPacket) {
let mt = MessageType(rawValue: packet.type)
let isBroadcastMessage = (mt == .message && packet.recipientID == nil)
let isAnnounce = (mt == .announce)
guard isBroadcastMessage || isAnnounce else { return }
let idBytes = PacketIdUtil.computeId(packet)
bloom.add(idBytes)
let idHex = idBytes.hexEncodedString()
if isBroadcastMessage {
if messages[idHex] == nil {
messages[idHex] = packet
messageOrder.append(idHex)
// Enforce capacity
let cap = max(1, config.seenCapacity)
while messageOrder.count > cap {
let victim = messageOrder.removeFirst()
messages.removeValue(forKey: victim)
}
}
} else if isAnnounce {
let sender = packet.senderID.hexEncodedString()
latestAnnouncementByPeer[sender] = (id: idHex, packet: packet)
}
}
private func sendRequestSync() {
let snap = bloom.snapshotActive()
let payload = RequestSyncPacket(mBytes: snap.mBytes, k: snap.k, bits: snap.bits).encode()
let pkt = BitchatPacket(
type: MessageType.requestSync.rawValue,
senderID: Data(hexString: myPeerID) ?? Data(),
recipientID: nil, // broadcast
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: 0 // local-only
)
let signed = delegate?.signPacketForBroadcast(pkt) ?? pkt
delegate?.sendPacket(signed)
}
private func sendRequestSync(to peerID: String) {
let snap = bloom.snapshotActive()
let payload = RequestSyncPacket(mBytes: snap.mBytes, k: snap.k, bits: snap.bits).encode()
var recipient = Data()
var temp = peerID
while temp.count >= 2 && recipient.count < 8 {
let hexByte = String(temp.prefix(2))
if let b = UInt8(hexByte, radix: 16) { recipient.append(b) }
temp = String(temp.dropFirst(2))
}
let pkt = BitchatPacket(
type: MessageType.requestSync.rawValue,
senderID: Data(hexString: myPeerID) ?? Data(),
recipientID: recipient,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: 0 // local-only
)
let signed = delegate?.signPacketForBroadcast(pkt) ?? pkt
delegate?.sendPacket(to: peerID, packet: signed)
}
func handleRequestSync(fromPeerID: String, request: RequestSyncPacket) {
// Build membership checker from provided parameters
let mBits = request.mBytes * 8
let k = request.k
func mightContain(_ id: Data) -> Bool {
// Same hashing as local bloom; compute indices, check MSB-first bits in request.bits
var h1: UInt64 = 1469598103934665603
var h2: UInt64 = 0x27d4eb2f165667c5
for b in id { h1 = (h1 ^ UInt64(b)) &* 1099511628211; h2 = (h2 ^ UInt64(b)) &* 0x100000001B3 }
for i in 0..<k {
let combined = h1 &+ (UInt64(i) &* h2)
let idx = Int((combined & 0x7fff_ffff_ffff_ffff) % UInt64(mBits))
let byteIndex = idx / 8
let bitIndex = idx % 8
let byte = request.bits[byteIndex]
let bit = ((Int(byte) >> (7 - bitIndex)) & 1) == 1
if !bit { return false }
}
return true
}
// 1) Announcements: send latest per peer if requester lacks them
for (_, pair) in latestAnnouncementByPeer {
let (idHex, pkt) = pair
let idBytes = Data(hexString: idHex) ?? Data()
if !mightContain(idBytes) {
var toSend = pkt
toSend.ttl = 0
delegate?.sendPacket(to: fromPeerID, packet: toSend)
}
}
// 2) Broadcast messages: send all missing
let toSendMsgs = messageOrder.compactMap { messages[$0] }
for pkt in toSendMsgs {
let idBytes = PacketIdUtil.computeId(pkt)
if !mightContain(idBytes) {
var toSend = pkt
toSend.ttl = 0
delegate?.sendPacket(to: fromPeerID, packet: toSend)
}
}
}
}
+22
View File
@@ -0,0 +1,22 @@
import Foundation
import CryptoKit
// Deterministic packet ID used for sync Bloom membership
// ID = first 16 bytes of SHA-256 over: [type | senderID | timestamp | payload]
enum PacketIdUtil {
static func computeId(_ packet: BitchatPacket) -> Data {
var hasher = SHA256()
hasher.update(data: Data([packet.type]))
hasher.update(data: packet.senderID)
var tsBE = packet.timestamp.bigEndian
withUnsafeBytes(of: &tsBE) { raw in hasher.update(data: Data(raw)) }
hasher.update(data: packet.payload)
let digest = hasher.finalize()
return Data(digest.prefix(16))
}
static func computeIdHex(_ packet: BitchatPacket) -> String {
return computeId(packet).hexEncodedString()
}
}
+116
View File
@@ -0,0 +1,116 @@
import Foundation
// Rotating Bloom filter for recently seen packet IDs (16-byte IDs)
final class SeenPacketsBloomFilter {
struct Snapshot { let mBytes: Int; let k: Int; let bits: Data }
private struct Filter { var mBits: Int; var k: Int; var bits: [UInt8]; var count: Int }
private let maxBytes: Int
private let targetFpr: Double
private let seed: UInt64 = 0x27d4eb2f165667c5
private let mBits: Int
private let kOptimal: Int
private let capacityOptimal: Int
private var active: Filter
private var standby: Filter
private var usingStandby: Bool = false
private let lock = NSLock()
init(maxBytes: Int = 256, targetFpr: Double = 0.01) {
self.maxBytes = max(1, maxBytes)
self.targetFpr = targetFpr
self.mBits = max(8, self.maxBytes * 8)
let (k, n) = SeenPacketsBloomFilter.deriveParams(mBits: self.mBits, fpr: targetFpr)
self.kOptimal = max(1, k)
self.capacityOptimal = max(1, n)
self.active = Filter(mBits: self.mBits, k: self.kOptimal, bits: [UInt8](repeating: 0, count: self.maxBytes), count: 0)
self.standby = Filter(mBits: self.mBits, k: self.kOptimal, bits: [UInt8](repeating: 0, count: self.maxBytes), count: 0)
}
private static func deriveParams(mBits: Int, fpr: Double) -> (Int, Int) {
// n -(m (ln 2)^2) / ln p ; k (m/n) ln 2
let ln2 = log(2.0)
let n = max(1, Int(Double(-mBits) * ln2 * ln2 / log(fpr)))
let k = max(1, Int(ceil((Double(mBits) / Double(n)) * ln2)))
return (k, n)
}
private func indicesFor(id: Data, mBits: Int, k: Int) -> [Int] {
var h1: UInt64 = 1469598103934665603 // FNV-1a 64-bit offset
var h2: UInt64 = seed
for b in id { // treat as unsigned bytes
h1 = (h1 ^ UInt64(b)) &* 1099511628211
h2 = (h2 ^ UInt64(b)) &* 0x100000001B3
}
var result = [Int]()
result.reserveCapacity(k)
for i in 0..<k {
let combined = h1 &+ (UInt64(i) &* h2)
let idx = Int((combined & 0x7fff_ffff_ffff_ffff) % UInt64(mBits))
result.append(idx)
}
return result
}
func add(_ id: Data) {
lock.lock(); defer { lock.unlock() }
let startStandbyAt = capacityOptimal / 2
if !usingStandby && active.count >= startStandbyAt {
standby = Filter(mBits: mBits, k: kOptimal, bits: [UInt8](repeating: 0, count: maxBytes), count: 0)
usingStandby = true
}
insert(into: &active, id: id)
if usingStandby { insert(into: &standby, id: id) }
if active.count >= capacityOptimal {
active = standby
standby = Filter(mBits: mBits, k: kOptimal, bits: [UInt8](repeating: 0, count: maxBytes), count: 0)
usingStandby = false
}
}
private func insert(into filter: inout Filter, id: Data) {
let idxs = indicesFor(id: id, mBits: filter.mBits, k: filter.k)
for i in idxs {
let byteIndex = i / 8
let bitIndex = i % 8
filter.bits[byteIndex] = UInt8(Int(filter.bits[byteIndex]) | (1 << (7 - bitIndex)))
}
filter.count &+= 1
}
func mightContain(_ id: Data) -> Bool {
lock.lock(); defer { lock.unlock() }
let a = active
let idx = indicesFor(id: id, mBits: a.mBits, k: a.k)
var inActive = true
for i in idx {
let byteIndex = i / 8
let bitIndex = i % 8
let set = ((Int(a.bits[byteIndex]) >> (7 - bitIndex)) & 1) == 1
if !set { inActive = false; break }
}
if inActive { return true }
if usingStandby {
let s = standby
let idx2 = indicesFor(id: id, mBits: s.mBits, k: s.k)
for i in idx2 {
let byteIndex = i / 8
let bitIndex = i % 8
let set = ((Int(s.bits[byteIndex]) >> (7 - bitIndex)) & 1) == 1
if !set { return false }
}
return true
}
return false
}
func snapshotActive() -> Snapshot {
lock.lock(); defer { lock.unlock() }
let a = active
return Snapshot(mBytes: a.bits.count, k: a.k, bits: Data(a.bits))
}
}
+18 -35
View File
@@ -420,7 +420,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
private var geoSamplingSubs: [String: String] = [:] // subID -> geohash
private var lastGeoNotificationAt: [String: Date] = [:] // geohash -> last notify time
// MARK: - Message Delivery Tracking
// Delivery tracking
@@ -4600,8 +4599,16 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Register ephemeral session with identity manager
SecureIdentityStateManager.shared.registerEphemeralSession(peerID: peerID)
// Intentionally do not resend favorites on reconnect.
// We only send our npub when a favorite is toggled on, or if our npub changes.
// Check if we favorite this peer and resend notification on reconnect
// This ensures Nostr key mapping is maintained across reconnections
if let peer = unifiedPeerService.getPeer(by: peerID),
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: peer.noisePublicKey),
favoriteStatus.isFavorite {
// Resend favorite notification with our Nostr key after a short delay
try? await Task.sleep(nanoseconds: TransportConfig.uiAsyncMediumSleepNs) // 0.5 seconds
meshService.sendFavoriteNotification(to: peerID, isFavorite: true)
SecureLogger.debug("📤 Resent favorite notification to reconnected peer \(peerID)", category: .session)
}
// Force UI refresh
objectWillChange.send()
@@ -4950,26 +4957,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
messages.append(systemMessage)
}
/// Add a system message to the mesh timeline only (never geohash).
/// If mesh is currently active, also append to the visible `messages`.
@MainActor
private func addMeshOnlySystemMessage(_ content: String) {
let systemMessage = BitchatMessage(
sender: "system",
content: content,
timestamp: Date(),
isRelay: false
)
// Persist to mesh timeline
meshTimeline.append(systemMessage)
trimMeshTimelineIfNeeded()
// Only show inline if mesh is the active channel
if case .mesh = activeChannel {
messages.append(systemMessage)
}
objectWillChange.send()
}
/// Public helper to add a system message to the public chat timeline.
/// Also persists the message into the active channel's backing store so it survives timeline rebinds.
@MainActor
@@ -5397,27 +5384,23 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
SecureLogger.warning("⚠️ Cannot get Noise key for peer \(peerID)", category: .session)
return
}
// Determine prior state to avoid duplicate system messages on repeated notifications
let prior = FavoritesPersistenceService.shared.getFavoriteStatus(for: finalNoiseKey)?.theyFavoritedUs ?? false
// Update the favorite relationship (idempotent storage)
// Update the favorite relationship
FavoritesPersistenceService.shared.updatePeerFavoritedUs(
peerNoisePublicKey: finalNoiseKey,
favorited: isFavorite,
peerNickname: senderNickname,
peerNostrPublicKey: nostrPubkey
)
// If they favorited us and provided their Nostr key, ensure it's stored (log only)
// If they favorited us and provided their Nostr key, ensure it's stored
if isFavorite && nostrPubkey != nil {
SecureLogger.info("💾 Storing Nostr key association for \(senderNickname): \(nostrPubkey!.prefix(16))...", category: .session)
}
// Only show a system message when the state changes, and only in mesh
if prior != isFavorite {
let action = isFavorite ? "favorited" : "unfavorited"
addMeshOnlySystemMessage("\(senderNickname) \(action) you")
}
// Show system message
let action = isFavorite ? "favorited" : "unfavorited"
addSystemMessage("\(senderNickname) \(action) you")
}
@MainActor