1// DHTEngine.swift
2// SophaxChatCore
3//
4// Kademlia DHT engine — peer discovery over Tor.
5// Manages routing table, local store, iterative lookups, and periodic republish.
6//
7// Usage:
8// 1. let engine = DHTEngine(identity: ..., wireBuilder: ..., send: sendBlock)
9// 2. await engine.start(bootstrapContacts: [...])
10// 3. let (nodeID, bundle) = try await engine.lookup(peerID: "a1b2c3d4e5f6a1b2")
11// 4. Pipe incoming DHT wire messages: await engine.handleMessage(message, fromPeer: peerID)
12
13import Foundation
14
15// MARK: - DHTTransport callback
16
17/// Block that sends a DHT WireMessage to a contact.
18/// The implementor (ChatManager) is responsible for connecting if not already connected.
19public typealias DHTSendBlock = @Sendable (WireMessage, DHTContact) async -> Void
20
21// MARK: - DHTEngine
22
23public actor DHTEngine {
24
25 // Kademlia parameters
26 static let k = 20
27 static let alpha = 3
28 static let queryTimeout: TimeInterval = 30
29 static let publishInterval: TimeInterval = 24 * 3600
30 static let refreshInterval: TimeInterval = 60 * 60
31
32 // Core state
33 private let localNodeID: DHTNodeID
34 private let localContact: DHTContact
35 public let table: KBucketTable
36 public let store: DHTStore
37
38 // Dependencies (not retained as strong to avoid cycles)
39 private let wireBuilder: WireMessageBuilder
40 private let send: DHTSendBlock
41
42 // In-flight lookup tracking
43 private var activeLookups: [String: LookupState] = [:] // lookupID → state
44 private var pendingQueries: [String: PendingQuery] = [:] // peerNodeID → query
45
46 // Lifecycle
47 private var stopped = false
48 private var publishTask: Task<Void, Never>?
49 private var refreshTask: Task<Void, Never>?
50
51 // MARK: - Init
52
53 public init(
54 identity: IdentityManager,
55 wireBuilder: WireMessageBuilder,
56 send: @escaping DHTSendBlock
57 ) throws {
58 let nodeIDData = identity.publicIdentity.dhtNodeID
59 self.localNodeID = try DHTNodeID(bytes: nodeIDData)
60
61 let onion = identity.onionHostname ?? ""
62 self.localContact = DHTContact(
63 nodeID: nodeIDData.hexString,
64 onionAddress: onion,
65 port: 25519
66 )
67 self.table = KBucketTable(localNodeID: try DHTNodeID(bytes: nodeIDData))
68 self.store = DHTStore()
69 self.wireBuilder = wireBuilder
70 self.send = send
71 }
72
73 // MARK: - Lifecycle
74
75 public func start(bootstrapContacts: [DHTContact]) async {
76 stopped = false
77 schedulePublish()
78 scheduleRefresh()
79 await bootstrap(contacts: bootstrapContacts)
80 }
81
82 public func stop() {
83 stopped = true
84 publishTask?.cancel()
85 refreshTask?.cancel()
86 publishTask = nil
87 refreshTask = nil
88 // Fail all active lookups
89 for (id, state) in activeLookups {
90 state.continuation?.resume(throwing: DHTError.lookupFailed)
91 activeLookups[id] = nil
92 }
93 }
94
95 // MARK: - Public API
96
97 /// Lookup a peer by their 16-char peerID.
98 /// Returns their full dhtNodeID (hex64) and PreKeyBundle when found.
99 public func lookup(peerID: String) async throws -> (dhtNodeID: String, bundle: PreKeyBundle) {
100 // Fast path: local store hit
101 if let bundle = await store.lookupByPrefix(peerID),
102 let nodeID = await store.nodeID(forPeerID: peerID) {
103 return (nodeID, bundle)
104 }
105
106 // Construct a target nodeID: pad peerID to 64 hex chars with zeros.
107 // XOR routing converges toward this prefix; prefix-scan at responders finds exact match.
108 let padded = peerID + String(repeating: "0", count: 64 - peerID.count)
109 let target = try DHTNodeID.from(hex: padded)
110
111 let result = try await iterativeLookup(target: target, wantValue: true,
112 peerIDPrefix: peerID)
113 switch result {
114 case .found(let nodeID, let bundle):
115 return (nodeID, bundle)
116 case .notFound:
117 throw DHTError.lookupFailed
118 }
119 }
120
121 /// Publish our own PreKeyBundle to the k nodes closest to our nodeID.
122 public func publishSelf(bundle: PreKeyBundle) async {
123 let closest = await table.closestNodes(to: localNodeID, count: DHTEngine.k)
124 guard !closest.isEmpty else { return }
125
126 let expiry = Date().addingTimeInterval(DHTStore.defaultTTL)
127 let payload = DHTStorePayload(
128 nodeID: localNodeID.hexString,
129 bundle: bundle,
130 expiresAt: expiry
131 )
132 for contact in closest {
133 guard let msg = try? wireBuilder.build(.dhtStore, payload: payload) else { continue }
134 await send(msg, contact)
135 }
136 }
137
138 /// Route an incoming DHT wire message to the appropriate handler.
139 /// Called by ChatManager.dispatch() for all dht* message types.
140 public func handleMessage(_ message: WireMessage, fromPeer senderPeerID: String) async {
141 await table.markSeen(nodeID: senderPeerID)
142
143 switch message.type {
144 case .dhtPing:
145 await handlePing(message, fromPeer: senderPeerID)
146 case .dhtPong:
147 handlePong(fromPeer: senderPeerID)
148 case .dhtFindNode:
149 await handleFindNode(message, fromPeer: senderPeerID)
150 case .dhtFindNodeResp:
151 handleFindNodeResp(message, fromPeer: senderPeerID)
152 case .dhtStore:
153 await handleStore(message)
154 case .dhtFindValue:
155 await handleFindValue(message, fromPeer: senderPeerID)
156 case .dhtFindValueResp:
157 handleFindValueResp(message, fromPeer: senderPeerID)
158 default:
159 break
160 }
161 }
162
163 // MARK: - Incoming: Ping / Pong
164
165 private func handlePing(_ message: WireMessage, fromPeer peerID: String) async {
166 guard let payload = try? wireBuilder.decodePayload(DHTPingPayload.self, from: message),
167 let contact = await contactInfo(for: peerID, nodeID: payload.senderNodeID) else { return }
168 let pong = DHTPongPayload(senderNodeID: localNodeID.hexString)
169 guard let msg = try? wireBuilder.build(.dhtPong, payload: pong) else { return }
170 await send(msg, contact)
171 }
172
173 private func handlePong(fromPeer peerID: String) {
174 // markSeen already called above; just route to pending query if any
175 resolvePendingQuery(peerNodeID: peerID, closestNodes: [], bundle: nil)
176 }
177
178 // MARK: - Incoming: FindNode / FindNodeResp
179
180 private func handleFindNode(_ message: WireMessage, fromPeer peerID: String) async {
181 guard let payload = try? wireBuilder.decodePayload(DHTFindNodePayload.self, from: message),
182 let target = try? DHTNodeID.from(hex: payload.targetNodeID),
183 let contact = await contactInfo(for: peerID, nodeID: nil) else { return }
184
185 let closest = await table.closestNodes(to: target, count: DHTEngine.k)
186 let resp = DHTFindNodeRespPayload(closestNodes: closest.map(\.asDHTNodeInfo))
187 guard let msg = try? wireBuilder.build(.dhtFindNodeResp, payload: resp) else { return }
188 await send(msg, contact)
189
190 // Insert sender into our routing table
191 await table.insert(contact)
192 }
193
194 private func handleFindNodeResp(_ message: WireMessage, fromPeer peerID: String) {
195 guard let payload = try? wireBuilder.decodePayload(DHTFindNodeRespPayload.self, from: message) else { return }
196 let contacts = payload.closestNodes.compactMap { $0.asDHTContact }
197 resolvePendingQuery(peerNodeID: peerID, closestNodes: contacts, bundle: nil)
198 }
199
200 // MARK: - Incoming: Store
201
202 private func handleStore(_ message: WireMessage) async {
203 guard let payload = try? wireBuilder.decodePayload(DHTStorePayload.self, from: message) else { return }
204 // Only store if we are among the k closest (basic guard against spam)
205 await store.store(nodeID: payload.nodeID, bundle: payload.bundle, expiresAt: payload.expiresAt)
206 await store.purgeExpired()
207 }
208
209 // MARK: - Incoming: FindValue / FindValueResp
210
211 private func handleFindValue(_ message: WireMessage, fromPeer peerID: String) async {
212 guard let payload = try? wireBuilder.decodePayload(DHTFindValuePayload.self, from: message),
213 let contact = await contactInfo(for: peerID, nodeID: nil) else { return }
214
215 // Check exact match first, then prefix
216 var bundle = await store.lookup(nodeID: payload.targetNodeID)
217 if bundle == nil, let prefix = payload.peerIDPrefix {
218 bundle = await store.lookupByPrefix(prefix)
219 }
220
221 let resp: DHTFindValueRespPayload
222 if let bundle {
223 resp = DHTFindValueRespPayload(bundle: bundle, closestNodes: [])
224 } else {
225 let target = (try? DHTNodeID.from(hex: payload.targetNodeID)) ?? localNodeID
226 let closest = await table.closestNodes(to: target, count: DHTEngine.k)
227 resp = DHTFindValueRespPayload(bundle: nil, closestNodes: closest.map(\.asDHTNodeInfo))
228 }
229
230 guard let msg = try? wireBuilder.build(.dhtFindValueResp, payload: resp) else { return }
231 await send(msg, contact)
232 await table.insert(contact)
233 }
234
235 private func handleFindValueResp(_ message: WireMessage, fromPeer peerID: String) {
236 guard let payload = try? wireBuilder.decodePayload(DHTFindValueRespPayload.self, from: message) else { return }
237 let contacts = payload.closestNodes.compactMap { $0.asDHTContact }
238 resolvePendingQuery(peerNodeID: peerID, closestNodes: contacts, bundle: payload.bundle)
239 }
240
241 // MARK: - Iterative Lookup
242
243 private enum LookupResult {
244 case found(dhtNodeID: String, bundle: PreKeyBundle)
245 case notFound([DHTContact])
246 }
247
248 private struct LookupState {
249 var target: DHTNodeID
250 var wantValue: Bool
251 var peerIDPrefix: String?
252 var queried: Set<String> // nodeID hex strings already queried
253 var inFlight: Set<String> // nodeID hex strings currently in-flight
254 var closest: [DHTContact] // sorted by XOR distance to target, max k
255 var continuation: CheckedContinuation<LookupResult, Error>?
256 }
257
258 private struct PendingQuery {
259 let lookupID: String
260 let wantValue: Bool
261 let timeoutTask: Task<Void, Never>
262 }
263
264 private func iterativeLookup(
265 target: DHTNodeID,
266 wantValue: Bool,
267 peerIDPrefix: String? = nil
268 ) async throws -> LookupResult {
269 let lookupID = UUID().uuidString
270 let seed = await table.closestNodes(to: target, count: DHTEngine.alpha)
271
272 guard !seed.isEmpty else { throw DHTError.lookupFailed }
273
274 return try await withCheckedThrowingContinuation { cont in
275 var state = LookupState(
276 target: target,
277 wantValue: wantValue,
278 peerIDPrefix: peerIDPrefix,
279 queried: [],
280 inFlight: [],
281 closest: seed,
282 continuation: cont
283 )
284 activeLookups[lookupID] = state
285 // Send to alpha closest
286 sendNextQueries(lookupID: lookupID)
287 }
288 }
289
290 /// Send FIND_NODE / FIND_VALUE to the next alpha not-yet-queried candidates.
291 private func sendNextQueries(lookupID: String) {
292 guard var state = activeLookups[lookupID] else { return }
293
294 let candidates = state.closest.filter {
295 !state.queried.contains($0.nodeID) && !state.inFlight.contains($0.nodeID)
296 }.prefix(DHTEngine.alpha)
297
298 if candidates.isEmpty {
299 // Frontier converged — no more nodes to query
300 if state.inFlight.isEmpty {
301 let cont = state.continuation
302 state.continuation = nil
303 activeLookups[lookupID] = nil
304 cont?.resume(returning: .notFound(state.closest))
305 }
306 return
307 }
308
309 for contact in candidates {
310 state.inFlight.insert(contact.nodeID)
311 activeLookups[lookupID] = state
312
313 let timeoutTask = Task { [weak self] in
314 try? await Task.sleep(nanoseconds: UInt64(DHTEngine.queryTimeout * 1_000_000_000))
315 guard let self else { return }
316 await self.queryTimedOut(peerNodeID: contact.nodeID, lookupID: lookupID)
317 }
318 pendingQueries[contact.nodeID] = PendingQuery(
319 lookupID: lookupID, wantValue: state.wantValue, timeoutTask: timeoutTask
320 )
321
322 let msg: WireMessage?
323 if state.wantValue {
324 let p = DHTFindValuePayload(targetNodeID: state.target.hexString,
325 peerIDPrefix: state.peerIDPrefix)
326 msg = try? wireBuilder.build(.dhtFindValue, payload: p)
327 } else {
328 let p = DHTFindNodePayload(targetNodeID: state.target.hexString)
329 msg = try? wireBuilder.build(.dhtFindNode, payload: p)
330 }
331 guard let wireMsg = msg else { continue }
332 let capturedContact = contact
333 Task { await self.send(wireMsg, capturedContact) }
334 }
335 }
336
337 /// Called when a response arrives — updates lookup state and advances the frontier.
338 private func resolvePendingQuery(
339 peerNodeID: String,
340 closestNodes: [DHTContact],
341 bundle: PreKeyBundle?
342 ) {
343 guard let pending = pendingQueries[peerNodeID] else { return }
344 pending.timeoutTask.cancel()
345 pendingQueries.removeValue(forKey: peerNodeID)
346
347 guard var state = activeLookups[pending.lookupID] else { return }
348 state.inFlight.remove(peerNodeID)
349 state.queried.insert(peerNodeID)
350
351 // Insert newly discovered contacts into routing table (fire-and-forget)
352 for c in closestNodes {
353 let contact = c
354 Task { await self.table.insert(contact) }
355 }
356
357 // If value found — done
358 if pending.wantValue, let bundle {
359 // Find the nodeID from any stored entry or derive from peerID prefix
360 let nodeID = state.peerIDPrefix.flatMap { prefix in
361 closestNodes.first { $0.nodeID.hasPrefix(prefix) }?.nodeID
362 } ?? peerNodeID
363 let cont = state.continuation
364 state.continuation = nil
365 activeLookups[pending.lookupID] = nil
366 cont?.resume(returning: .found(dhtNodeID: nodeID, bundle: bundle))
367 return
368 }
369
370 // Merge new contacts into closest, keep k best
371 let newContacts = closestNodes.filter { !state.queried.contains($0.nodeID) }
372 state.closest = (state.closest + newContacts)
373 .sorted { a, b in
374 guard let aID = try? DHTNodeID.from(hex: a.nodeID),
375 let bID = try? DHTNodeID.from(hex: b.nodeID) else { return false }
376 return state.target.xorDistance(to: aID).lexicographicallyPrecedes(
377 state.target.xorDistance(to: bID)
378 )
379 }
380 .prefix(DHTEngine.k)
381 .map { $0 }
382
383 activeLookups[pending.lookupID] = state
384 sendNextQueries(lookupID: pending.lookupID)
385 }
386
387 private func queryTimedOut(peerNodeID: String, lookupID: String) async {
388 await table.markFailed(nodeID: peerNodeID)
389 resolvePendingQuery(peerNodeID: peerNodeID, closestNodes: [], bundle: nil)
390 }
391
392 // MARK: - Bootstrap
393
394 private func bootstrap(contacts: [DHTContact]) async {
395 for contact in contacts { await table.insert(contact) }
396 // Self-lookup to discover nodes close to us
397 _ = try? await iterativeLookup(target: localNodeID, wantValue: false)
398 }
399
400 // MARK: - Periodic Tasks
401
402 private func schedulePublish() {
403 publishTask = Task { [weak self] in
404 while true {
405 try? await Task.sleep(nanoseconds: UInt64(DHTEngine.publishInterval * 1_000_000_000))
406 guard let self, await !self.stopped else { return }
407 // ChatManager calls publishSelf(bundle:) — engine doesn't hold the bundle
408 // so it just purges expired store entries here
409 await self.store.purgeExpired()
410 }
411 }
412 }
413
414 private func scheduleRefresh() {
415 refreshTask = Task { [weak self] in
416 var bucketIdx = 0
417 while true {
418 try? await Task.sleep(nanoseconds: UInt64(DHTEngine.refreshInterval * 1_000_000_000))
419 guard let self, await !self.stopped else { return }
420 // 1. Ping the least-recently-seen node in this bucket (liveness check).
421 // Unresponsive nodes accumulate failCount and are evicted at 3.
422 if let lrs = await self.table.leastRecentlySeen(in: bucketIdx) {
423 await self.pingForRefresh(lrs)
424 }
425 // 2. FIND_NODE to a random target in this bucket's ID space —
426 // discovers new nodes and fills sparse buckets.
427 let nodeID = await self.localNodeID
428 if let target = Self.randomTargetInBucket(bucketIdx, localNodeID: nodeID) {
429 _ = try? await self.iterativeLookup(target: target, wantValue: false)
430 }
431 bucketIdx = (bucketIdx + 1) % 256
432 }
433 }
434 }
435
436 /// Sends a dhtPing to `contact` and registers a timeout.
437 /// On pong, `resolvePendingQuery` cancels the timeout via the normal path.
438 /// On timeout, `queryTimedOut` calls `table.markFailed` — eviction after 3 strikes.
439 private func pingForRefresh(_ contact: DHTContact) async {
440 // Re-use the pendingQueries mechanism with a sentinel lookupID that has no
441 // corresponding activeLookup entry — resolvePendingQuery gracefully no-ops for
442 // the lookup advancement but still cancels the timeout task.
443 let sentinelID = "ping_\(contact.nodeID)"
444 guard pendingQueries[contact.nodeID] == nil else { return } // already in-flight
445
446 let timeoutTask = Task { [weak self] in
447 try? await Task.sleep(nanoseconds: UInt64(DHTEngine.queryTimeout * 1_000_000_000))
448 guard let self else { return }
449 await self.queryTimedOut(peerNodeID: contact.nodeID, lookupID: sentinelID)
450 }
451 pendingQueries[contact.nodeID] = PendingQuery(
452 lookupID: sentinelID, wantValue: false, timeoutTask: timeoutTask
453 )
454
455 let payload = DHTPingPayload(senderNodeID: localNodeID.hexString)
456 guard let msg = try? wireBuilder.build(.dhtPing, payload: payload) else {
457 pendingQueries.removeValue(forKey: contact.nodeID)
458 timeoutTask.cancel()
459 return
460 }
461 await send(msg, contact)
462 }
463
464 // MARK: - Helpers
465
466 /// Returns a DHTContact for a peer by consulting the routing table.
467 /// Falls back to constructing a minimal contact from the known onion address if unavailable.
468 private func contactInfo(for peerID: String, nodeID: String?) async -> DHTContact? {
469 let all = await table.allContacts()
470 return all.first { $0.nodeID.hasPrefix(peerID) || $0.nodeID == nodeID }
471 }
472
473 /// Generate a random target node ID within the keyspace covered by bucket `idx`.
474 private static func randomTargetInBucket(_ idx: Int, localNodeID: DHTNodeID) -> DHTNodeID? {
475 var bytes = [UInt8](localNodeID.bytes)
476 // Flip the bit at position `idx` to land in the right bucket, randomise lower bits
477 let bytePos = 31 - (idx / 8)
478 let bitPos = idx % 8
479 bytes[bytePos] ^= (1 << bitPos)
480 // Randomise bits below idx
481 for i in stride(from: bytePos + 1, to: bytes.count, by: 1) {
482 bytes[i] = UInt8.random(in: 0...255)
483 }
484 if bitPos > 0 {
485 let mask = UInt8((1 << bitPos) - 1)
486 bytes[bytePos] = (bytes[bytePos] & ~mask) | (UInt8.random(in: 0...255) & mask)
487 }
488 return try? DHTNodeID(bytes: Data(bytes))
489 }
490}
491
492// MARK: - DHTContact extensions
493
494private extension DHTContact {
495 var asDHTNodeInfo: DHTNodeInfo {
496 DHTNodeInfo(nodeID: nodeID, onionAddress: onionAddress, port: port)
497 }
498}
499
500private extension DHTNodeInfo {
501 var asDHTContact: DHTContact? {
502 guard onionAddress.hasSuffix(".onion"), onionAddress.count == 62 else { return nil }
503 return DHTContact(nodeID: nodeID, onionAddress: onionAddress, port: port)
504 }
505}