1// KBucket.swift2// SophaxChatCore3//4// Kademlia routing table: 256 k-buckets indexed by XOR distance, k=20.5// All mutations are actor-isolated — no external locking required.67import Foundation8import CryptoKit910// MARK: - DHTNodeID1112/// A 256-bit Kademlia node identifier derived from SHA256(signingKey || dhKey).13public struct DHTNodeID: Equatable, Hashable, Sendable {14 public let bytes: Data // always 32 bytes1516 public init(bytes: Data) throws {17 guard bytes.count == 32 else {18 throw DHTError.invalidNodeID(bytes.count)19}20 self.bytes = bytes21}2223 public static func from(hex: String) throws -> DHTNodeID {24 guard hex.count == 64 else { throw DHTError.invalidNodeID(hex.count / 2)}25 var data = Data()26 var idx = hex.startIndex27 while idx < hex.endIndex {28 let next = hex.index(idx, offsetBy: 2)29 guard let byte = UInt8(hex[idx..<next], radix: 16) else {30 throw DHTError.invalidNodeID(0)31}32 data.append(byte)33 idx = next34}35 return try DHTNodeID(bytes: data)36}3738 public var hexString: String { bytes.hexString }3940/// XOR distance to another node — big-endian 32-byte result.41 public func xorDistance(to other: DHTNodeID)-> Data {42 Data(zip(bytes, other.bytes).map { $0 ^ $1 })43}4445/// Kademlia bucket index: position of the highest set bit in the XOR distance (0–255).46/// Returns -1 if distance == 0 (same node — never inserted).47 public func bucketIndex(to other: DHTNodeID)-> Int {48 let d = xorDistance(to: other)49 for byteIdx in 0..<d.count {50 let byte = d[d.startIndex + byteIdx]51 if byte == 0 { continue }52 for bitIdx in stride(from: 7, through: 0, by:-1){53 if byte &(1 << bitIdx)!= 0 {54// (31 - byteIdx) * 8 + bitIdx → 0 = LSB of rightmost byte55 return (d.count - 1 - byteIdx)* 8 + bitIdx56}57}58}59 return -1 // distance == 060}61}6263public enum DHTError: Error, Sendable {64 case invalidNodeID(Int)65 case lookupTimeout66 case lookupFailed67}6869// MARK: - DHTContact7071public struct DHTContact: Codable, Sendable {72 public let nodeID: String // hex 64 chars (DHTNodeID.hexString)73 public let onionAddress: String // 62-char .onion hostname, no port74 public let port: UInt1675 public var lastSeen: Date76 public var failCount: Int7778 public init(nodeID: String, onionAddress: String, port: UInt16 = 25519,79 lastSeen: Date = Date(), failCount: Int = 0){80 self.nodeID = nodeID81 self.onionAddress = onionAddress82 self.port = port83 self.lastSeen = lastSeen84 self.failCount = failCount85}8687/// "host.onion:port" string for TCPTransport.connect().88 public var tcpAddress: String {"\(onionAddress):\(port)"}89}9091// MARK: - KBucketTable9293/// Kademlia routing table. Actor-isolated — all access is serial.94public actor KBucketTable {9596 public static let k = 20 // bucket size97 private static let bucketCount = 256 // one per bit position of the 256-bit ID space9899 private var buckets:[[DHTContact]]// [256][≤20]100 private let localNodeID: DHTNodeID101102 public init(localNodeID: DHTNodeID){103 self.localNodeID = localNodeID104 self.buckets = Array(repeating:[], count: KBucketTable.bucketCount)105}106107// MARK: - Core operations108109/// Insert or refresh a contact. Ignored if nodeID == localNodeID.110/// When the bucket is full, the oldest contact with failCount ≥ 1 is evicted;111/// otherwise the new contact is discarded (Kademlia stability preference).112 public func insert(_ contact: DHTContact){113 guard let localID = try? DHTNodeID(bytes: localNodeID.bytes),114 let contactID = try? DHTNodeID.from(hex: contact.nodeID) else { return }115 let idx = localID.bucketIndex(to: contactID)116 guard idx >= 0 else { return }// same node117118// If already present, refresh lastSeen.119 if let pos = buckets[idx].firstIndex(where:{ $0.nodeID == contact.nodeID }){120 var updated = buckets[idx][pos]121 updated.lastSeen = contact.lastSeen122 updated.failCount = 0123 buckets[idx][pos]= updated124 return125}126127 if buckets[idx].count < KBucketTable.k {128 buckets[idx].append(contact)129} else {130 evictOrDiscard(contact, bucketIdx: idx)131}132}133134/// Returns up to `count` contacts closest to `target` by XOR distance.135 public func closestNodes(to target: DHTNodeID, count: Int)->[DHTContact]{136 let all = buckets.flatMap { $0 }137 return all138.filter { $0.nodeID != localNodeID.hexString }139.sorted { a, b in140 guard let aID = try? DHTNodeID.from(hex: a.nodeID),141 let bID = try? DHTNodeID.from(hex: b.nodeID) else { return false }142 return target.xorDistance(to: aID).lexicographicallyPrecedes(143 target.xorDistance(to: bID)144)145}146.prefix(count)147.map { $0 }148}149150/// Mark a contact as successfully seen — resets failCount.151 public func markSeen(nodeID: String){152 for i in 0..<buckets.count {153 if let pos = buckets[i].firstIndex(where:{ $0.nodeID == nodeID }){154 buckets[i][pos].lastSeen = Date()155 buckets[i][pos].failCount = 0156 return157}158}159}160161/// Increment failCount. Evicts the contact after 3 consecutive failures.162 public func markFailed(nodeID: String){163 for i in 0..<buckets.count {164 if let pos = buckets[i].firstIndex(where:{ $0.nodeID == nodeID }){165 buckets[i][pos].failCount += 1166 if buckets[i][pos].failCount >= 3 {167 buckets[i].remove(at: pos)168}169 return170}171}172}173174 public func remove(nodeID: String){175 for i in 0..<buckets.count {176 buckets[i].removeAll { $0.nodeID == nodeID }177}178}179180 public func allContacts()->[DHTContact]{181 buckets.flatMap { $0 }182}183184/// Returns the contact in `bucketIdx` with the oldest `lastSeen` date, or nil if empty.185 public func leastRecentlySeen(in bucketIdx: Int)-> DHTContact?{186 guard bucketIdx >= 0, bucketIdx < buckets.count else { return nil }187 return buckets[bucketIdx].min(by:{ $0.lastSeen < $1.lastSeen })188}189190// MARK: - Persistence191192 public func snapshot()->[[DHTContact]]{ buckets }193194 public func restore(from snapshot:[[DHTContact]]){195 guard snapshot.count == KBucketTable.bucketCount else { return }196 buckets = snapshot.map { Array($0.prefix(KBucketTable.k))}197}198199// MARK: - Private200201/// When a bucket is full, evict the oldest contact whose failCount ≥ 1202/// and replace it with the new contact. Otherwise discard the new contact.203 private func evictOrDiscard(_ contact: DHTContact, bucketIdx idx: Int){204 let staleThreshold = Date().addingTimeInterval(-15 * 60)// 15 min ago205 if let evictPos = buckets[idx]206.enumerated()207.first(where:{ $0.element.failCount >= 1 && $0.element.lastSeen < staleThreshold })?208.offset209{210 buckets[idx][evictPos]= contact211}212// else: bucket is full of fresh contacts — discard new one (Kademlia spec)213}214}