diff --git a/bitchat.xcodeproj/project.pbxproj b/bitchat.xcodeproj/project.pbxproj index 6e38a875..f49ea9d9 100644 --- a/bitchat.xcodeproj/project.pbxproj +++ b/bitchat.xcodeproj/project.pbxproj @@ -7,6 +7,14 @@ objects = { /* Begin PBXBuildFile section */ + 04657AB52E633C6A002D5BBE /* NostrPoW.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04657AB42E633C6A002D5BBE /* NostrPoW.swift */; }; + 04657AB62E633C6A002D5BBE /* NostrPoW.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04657AB42E633C6A002D5BBE /* NostrPoW.swift */; }; + 04657AB82E633C76002D5BBE /* PowPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04657AB72E633C76002D5BBE /* PowPolicy.swift */; }; + 04657AB92E633C76002D5BBE /* PowPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04657AB72E633C76002D5BBE /* PowPolicy.swift */; }; + 04657ABB2E633C94002D5BBE /* MiningAnimation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04657ABA2E633C94002D5BBE /* MiningAnimation.swift */; }; + 04657ABC2E633C94002D5BBE /* MiningAnimation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04657ABA2E633C94002D5BBE /* MiningAnimation.swift */; }; + 04657ABE2E633CA4002D5BBE /* NostrPoWTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04657ABD2E633CA4002D5BBE /* NostrPoWTests.swift */; }; + 04657ABF2E633CA4002D5BBE /* NostrPoWTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04657ABD2E633CA4002D5BBE /* NostrPoWTests.swift */; }; 047502802E53A0FC0083520F /* FragmentationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0475027E2E53A0FC0083520F /* FragmentationTests.swift */; }; 047502812E53A0FC0083520F /* FragmentationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0475027E2E53A0FC0083520F /* FragmentationTests.swift */; }; 047502872E5416250083520F /* Geohash.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502852E5416250083520F /* Geohash.swift */; }; @@ -197,6 +205,10 @@ /* Begin PBXFileReference section */ 03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_macOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 04657AB42E633C6A002D5BBE /* NostrPoW.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrPoW.swift; sourceTree = ""; }; + 04657AB72E633C76002D5BBE /* PowPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PowPolicy.swift; sourceTree = ""; }; + 04657ABA2E633C94002D5BBE /* MiningAnimation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MiningAnimation.swift; sourceTree = ""; }; + 04657ABD2E633CA4002D5BBE /* NostrPoWTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrPoWTests.swift; sourceTree = ""; }; 0475027E2E53A0FC0083520F /* FragmentationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FragmentationTests.swift; sourceTree = ""; }; 047502852E5416250083520F /* Geohash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Geohash.swift; sourceTree = ""; }; 047502862E5416250083520F /* LocationChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationChannel.swift; sourceTree = ""; }; @@ -451,6 +463,7 @@ A55126E93155456CAA8D6656 /* Views */ = { isa = PBXGroup; children = ( + 04657ABA2E633C94002D5BBE /* MiningAnimation.swift */, AA77BB13CC22DD33EE44FF57 /* VerificationViews.swift */, 047502B22E55FED60083520F /* GeohashPeopleList.swift */, 047502B32E55FED60083520F /* MeshPeerList.swift */, @@ -489,8 +502,9 @@ C3D98EB3E1B455E321F519F4 /* bitchatTests */ = { isa = PBXGroup; children = ( - 048A4C2A2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift */, D69A18D27F9A565FD6041E12 /* Info.plist */, + 04657ABD2E633CA4002D5BBE /* NostrPoWTests.swift */, + 048A4C2A2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift */, 047502912E547ACC0083520F /* LocationChannelsTests.swift */, C272F137CE00FC5A96E0CC06 /* NostrProtocolTests.swift */, 980B109CBA72BC996455C62B /* BLEServiceTests.swift */, @@ -526,6 +540,7 @@ D98A3186D7E4C72E35BDF7FE /* Services */ = { isa = PBXGroup; children = ( + 04657AB72E633C76002D5BBE /* PowPolicy.swift */, 048A4C272E5FCD6600162C4A /* GeohashBookmarksStore.swift */, 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */, AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */, @@ -550,6 +565,7 @@ E78C7F4B6769C0A72F5DE544 /* Nostr */ = { isa = PBXGroup; children = ( + 04657AB42E633C6A002D5BBE /* NostrPoW.swift */, A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */, 049BD39B2E51DBD9001A566B /* NostrEmbeddedBitChat.swift */, 5F8043995007F0D84438EDD9 /* NostrIdentity.swift */, @@ -792,15 +808,18 @@ 749D8CF8A362B6CD0786782D /* NotificationService.swift in Sources */, 049BD3AF2E51ED60001A566B /* Transport.swift in Sources */, E2DCF7817344F1CCDB8B7B2F /* SecureIdentityStateManager.swift in Sources */, + 04657ABC2E633C94002D5BBE /* MiningAnimation.swift in Sources */, 049BD3A02E51DBF4001A566B /* Packets.swift in Sources */, 047502892E5416250083520F /* Geohash.swift in Sources */, 0475028A2E5416250083520F /* LocationChannel.swift in Sources */, + 04657AB82E633C76002D5BBE /* PowPolicy.swift in Sources */, 049BD3A12E51DBF4001A566B /* PeerID.swift in Sources */, 049BD3942E4EC4F0001A566B /* PrivateChatManager.swift in Sources */, 049BD3952E4EC4F0001A566B /* AutocompleteService.swift in Sources */, 049BD3962E4EC4F0001A566B /* CommandProcessor.swift in Sources */, D111988977C3BC246AB27FA4 /* SecureLogger.swift in Sources */, 8DE687D2EB5EB120868DBFB5 /* BLEService.swift in Sources */, + 04657AB52E633C6A002D5BBE /* NostrPoW.swift in Sources */, AA11BB22CC33DD44EE55FF66 /* MessageTextHelpers.swift in Sources */, E0A1B2C3D4E5F6012345678B /* GeoRelayDirectory.swift in Sources */, ); @@ -852,15 +871,18 @@ 61C81ED5F679D5E973EE0C07 /* NotificationService.swift in Sources */, 049BD3AE2E51ED60001A566B /* Transport.swift in Sources */, 68C4BE564735F6E7915274A2 /* SecureIdentityStateManager.swift in Sources */, + 04657ABB2E633C94002D5BBE /* MiningAnimation.swift in Sources */, 049BD3A22E51DBF4001A566B /* Packets.swift in Sources */, 047502872E5416250083520F /* Geohash.swift in Sources */, 047502882E5416250083520F /* LocationChannel.swift in Sources */, + 04657AB92E633C76002D5BBE /* PowPolicy.swift in Sources */, 049BD3A32E51DBF4001A566B /* PeerID.swift in Sources */, 049BD3902E4EC4F0001A566B /* PrivateChatManager.swift in Sources */, 049BD3912E4EC4F0001A566B /* AutocompleteService.swift in Sources */, 049BD3922E4EC4F0001A566B /* CommandProcessor.swift in Sources */, EC5241969D2550B97629EBD0 /* SecureLogger.swift in Sources */, C165DD35BB8E9C327A3C2DA4 /* BLEService.swift in Sources */, + 04657AB62E633C6A002D5BBE /* NostrPoW.swift in Sources */, AA11BB22CC33DD44EE55FF67 /* MessageTextHelpers.swift in Sources */, E0A1B2C3D4E5F6012345678C /* GeoRelayDirectory.swift in Sources */, ); @@ -875,6 +897,7 @@ 8F282E9CCA5AE1ECC001D2E4 /* IntegrationTests.swift in Sources */, 047502B12E55E8450083520F /* InputValidatorTests.swift in Sources */, D727EA273CB214FC32612469 /* MockBluetoothMeshService.swift in Sources */, + 04657ABF2E633CA4002D5BBE /* NostrPoWTests.swift in Sources */, 047502932E547ACC0083520F /* LocationChannelsTests.swift in Sources */, 048A4C2B2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */, 6C803BF930E7E19BE6E99EAA /* MockBLEService.swift in Sources */, @@ -898,6 +921,7 @@ 686441ABC2AF83EE98E6ECF2 /* IntegrationTests.swift in Sources */, 047502B02E55E8450083520F /* InputValidatorTests.swift in Sources */, 8851F08D88C5B1DE7B9F55C6 /* MockBluetoothMeshService.swift in Sources */, + 04657ABE2E633CA4002D5BBE /* NostrPoWTests.swift in Sources */, 047502922E547ACC0083520F /* LocationChannelsTests.swift in Sources */, 048A4C2C2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */, 3849CA6D99B2D536636DF4A6 /* MockBLEService.swift in Sources */, diff --git a/bitchat/Nostr/NostrPoW.swift b/bitchat/Nostr/NostrPoW.swift new file mode 100644 index 00000000..102c55e9 --- /dev/null +++ b/bitchat/Nostr/NostrPoW.swift @@ -0,0 +1,96 @@ +import Foundation +import CryptoKit +import Security +import Darwin + +/// NIP-13 Proof-of-Work utilities for Nostr events. +/// - Counts leading zero bits and mines a nonce tag to meet a target. +enum NostrPoW { + /// Table of leading zero bit counts for all 256 byte values. + private static let lzTable: [UInt8] = { + (0...255).map { v -> UInt8 in + var x = UInt8(v) + if x == 0 { return 8 } + var n: UInt8 = 0 + while (x & 0x80) == 0 { + n &+= 1 + x <<= 1 + } + return n + } + }() + + /// Count leading zero bits in a 32-byte hash. + static func leadingZeroBits(_ data: Data) -> Int { + var total = 0 + for b in data { + let lz = Int(lzTable[Int(b)]) + total += lz + if lz != 8 { break } + } + return total + } + + /// Compute Nostr event id hash (SHA-256 over canonical serialization) for given fields. + private static func eventIDHash(pubkey: String, createdAt: Int, kind: Int, tags: [[String]], content: String) -> (hex: String, data: Data) { + let serialized: [Any] = [0, pubkey, createdAt, kind, tags, content] + let json = try! JSONSerialization.data(withJSONObject: serialized, options: [.withoutEscapingSlashes]) + let digest = SHA256.hash(data: json) + let data = Data(digest) + let hex = data.map { String(format: "%02x", $0) }.joined() + return (hex, data) + } + + /// Mine a NIP-13 nonce tag that satisfies the target leading-zero bits. + /// - Parameters: + /// - pubkey: x-only hex pubkey (64-hex chars) + /// - createdAt: seconds since epoch + /// - kind: event kind + /// - baseTags: existing tags (e.g., [ ["g", geohash], ["n", nickname], ... ]) + /// - content: event content + /// - targetBits: required leading zero bits + /// - startNonce: optional starting nonce (random if nil) + /// - Returns: (nonce, idHex) + static func mine(pubkey: String, + createdAt: Int, + kind: Int, + baseTags: [[String]], + content: String, + targetBits: Int, + startNonce: UInt64? = nil) -> (nonce: UInt64, idHex: String) { + var nonce: UInt64 = startNonce ?? randomNonce() + // Build a local tags buffer once to avoid reallocating unrelated tags + var tags = baseTags + tags.append(["nonce", "0", String(targetBits)]) + + var iter: UInt64 = 0 + while true { + var solved = false + var idHexOut = "" + // Use an autorelease pool periodically to keep memory stable on iOS + autoreleasepool { + // Update nonce tag value (second element) + tags[tags.count - 1][1] = String(nonce) + let (idHex, idData) = eventIDHash(pubkey: pubkey, createdAt: createdAt, kind: kind, tags: tags, content: content) + if leadingZeroBits(idData) >= targetBits { + solved = true + idHexOut = idHex + } + } + if solved { + return (nonce, idHexOut) + } + nonce &+= 1 + iter &+= 1 + if iter & 0x3FFF == 0 { /* yield every ~16k iterations */ _ = sched_yield() } + } + } + + private static func randomNonce() -> UInt64 { + var n: UInt64 = 0 + withUnsafeMutableBytes(of: &n) { ptr in + _ = SecRandomCopyBytes(kSecRandomDefault, ptr.count, ptr.baseAddress!) + } + return n + } +} diff --git a/bitchat/Protocols/BitchatProtocol.swift b/bitchat/Protocols/BitchatProtocol.swift index 758b0a61..977c53ac 100644 --- a/bitchat/Protocols/BitchatProtocol.swift +++ b/bitchat/Protocols/BitchatProtocol.swift @@ -409,6 +409,8 @@ class BitchatMessage: Codable { let senderPeerID: String? let mentions: [String]? // Array of mentioned nicknames var deliveryStatus: DeliveryStatus? // Delivery tracking + // Mining state for Nostr geohash PoW (UI hint: hide timestamp, animate) + var isMiningPow: Bool? // default nil/false // Cached formatted text (not included in Codable) private var _cachedFormattedText: [String: AttributedString] = [:] @@ -421,13 +423,18 @@ class BitchatMessage: Codable { _cachedFormattedText["\(isDark)-\(isSelf)"] = text } + func clearCachedFormattedText() { + _cachedFormattedText.removeAll() + } + // Codable implementation enum CodingKeys: String, CodingKey { case id, sender, content, timestamp, isRelay, originalSender case isPrivate, recipientNickname, senderPeerID, mentions, deliveryStatus + case isMiningPow } - init(id: String? = nil, sender: String, content: String, timestamp: Date, isRelay: Bool, originalSender: String? = nil, isPrivate: Bool = false, recipientNickname: String? = nil, senderPeerID: String? = nil, mentions: [String]? = nil, deliveryStatus: DeliveryStatus? = nil) { + init(id: String? = nil, sender: String, content: String, timestamp: Date, isRelay: Bool, originalSender: String? = nil, isPrivate: Bool = false, recipientNickname: String? = nil, senderPeerID: String? = nil, mentions: [String]? = nil, deliveryStatus: DeliveryStatus? = nil, isMiningPow: Bool? = nil) { self.id = id ?? UUID().uuidString self.sender = sender self.content = content @@ -439,6 +446,7 @@ class BitchatMessage: Codable { self.senderPeerID = senderPeerID self.mentions = mentions self.deliveryStatus = deliveryStatus ?? (isPrivate ? .sending : nil) + self.isMiningPow = isMiningPow } } @@ -455,7 +463,8 @@ extension BitchatMessage: Equatable { lhs.recipientNickname == rhs.recipientNickname && lhs.senderPeerID == rhs.senderPeerID && lhs.mentions == rhs.mentions && - lhs.deliveryStatus == rhs.deliveryStatus + lhs.deliveryStatus == rhs.deliveryStatus && + (lhs.isMiningPow ?? false) == (rhs.isMiningPow ?? false) } } diff --git a/bitchat/Services/PowPolicy.swift b/bitchat/Services/PowPolicy.swift new file mode 100644 index 00000000..2f954e57 --- /dev/null +++ b/bitchat/Services/PowPolicy.swift @@ -0,0 +1,17 @@ +import Foundation + +/// Simple default policy for NIP-13 difficulty on geohash chats. +/// Progressive by geohash precision; adjustable later by live activity. +enum PowPolicy { + /// Determine target bits for a given geohash string. + /// Clamped to a sane range to keep UX responsive on phones. + static func requiredBits(forGeohash geohash: String) -> Int { + let precision = geohash.count + // Start at 16 and go down with higher precision (smaller areas) + switch precision { + case ...5: return 10 + case 6: return 9 + default: return 8 + } + } +} diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 8a1d121d..76be9d71 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -796,6 +796,17 @@ class ChatViewModel: ObservableObject, BitchatDelegate { guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return } if self.processedNostrEvents.contains(event.id) { return } self.processedNostrEvents.insert(event.id) + // PoW filter: require local difficulty for this geohash + let ghFromTag: String? = event.tags.first(where: { $0.first?.lowercased() == "g" && $0.count >= 2 })?.dropFirst().first + let ghValue = ghFromTag ?? ch.geohash + if let idData = Data(hexString: event.id) { + let powBits = NostrPoW.leadingZeroBits(idData) + let required = PowPolicy.requiredBits(forGeohash: ghValue) + if powBits < required { + // Drop low PoW event + return + } + } if let gh = self.currentGeohash, let myGeoIdentity = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh), myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() { @@ -813,8 +824,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate { let key8 = "nostr:" + String(event.pubkey.prefix(TransportConfig.nostrShortKeyDisplayLength)) self.nostrKeyMapping[key8] = event.pubkey - // Update participants last-seen for this pubkey - self.recordGeoParticipant(pubkeyHex: event.pubkey) + // Update participants last-seen for this pubkey with observed PoW bits + if let idData = Data(hexString: event.id) { + let powBits = NostrPoW.leadingZeroBits(idData) + self.recordGeoParticipant(pubkeyHex: event.pubkey, geohash: ch.geohash, powBits: powBits) + } // Track teleported tag (only our format ["t","teleport"]) for icon state let hasTeleportTag = event.tags.contains(where: { tag in @@ -1320,7 +1334,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate { isPrivate: false, recipientNickname: nil, senderPeerID: localSenderPeerID, - mentions: mentions.isEmpty ? nil : mentions + mentions: mentions.isEmpty ? nil : mentions, + deliveryStatus: nil, + isMiningPow: { + if case .location(_) = activeChannel { return true } else { return nil } + }() ) // Add to main messages immediately for user feedback @@ -1353,17 +1371,42 @@ class ChatViewModel: ObservableObject, BitchatDelegate { } if case .location(let ch) = activeChannel { - // Send to geohash channel via Nostr ephemeral - Task { @MainActor in + // Send to geohash channel via Nostr ephemeral with PoW + let msgID = message.id + let contentToSend = trimmed + Task {@MainActor in do { let identity = try NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) - let event = try NostrProtocol.createEphemeralGeohashEvent( - content: trimmed, - geohash: ch.geohash, - senderIdentity: identity, - nickname: self.nickname, - teleported: LocationChannelManager.shared.teleported + let createdAt = Int(message.timestamp.timeIntervalSince1970) + var baseTags = [["g", ch.geohash]] + if !self.nickname.isEmpty { baseTags.append(["n", self.nickname]) } + if LocationChannelManager.shared.teleported { baseTags.append(["t", "teleport"]) } + let bits = PowPolicy.requiredBits(forGeohash: ch.geohash) + // Mine off the main thread + let (nonce, _) = await withCheckedContinuation { (cont: CheckedContinuation<(UInt64, String), Never>) in + DispatchQueue.global(qos: .utility).async { + let res = NostrPoW.mine(pubkey: identity.publicKeyHex, + createdAt: createdAt, + kind: NostrProtocol.EventKind.ephemeralEvent.rawValue, + baseTags: baseTags, + content: contentToSend, + targetBits: bits) + cont.resume(returning: (res.nonce, res.idHex)) + } + } + // Build final event with nonce tag and sign + var tags = baseTags + tags.append(["nonce", String(nonce), String(bits)]) + var event = NostrEvent( + pubkey: identity.publicKeyHex, + createdAt: message.timestamp, + kind: .ephemeralEvent, + tags: tags, + content: contentToSend ) + let schnorrKey = try identity.schnorrSigningKey() + event = try event.sign(with: schnorrKey) + let targetRelays = GeoRelayDirectory.shared.closestRelays( toGeohash: ch.geohash, count: TransportConfig.nostrGeoRelayCount @@ -1373,12 +1416,22 @@ class ChatViewModel: ObservableObject, BitchatDelegate { } else { NostrRelayManager.shared.sendEvent(event, to: targetRelays) } - // Track ourselves as active participant - self.recordGeoParticipant(pubkeyHex: identity.publicKeyHex) - SecureLogger.log("GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(LocationChannelManager.shared.teleported)", - category: SecureLogger.session, level: .debug) - // If we tagged this as teleported, also mark our pubkey in teleportedGeo for UI - // Only when not in our regional set (and regional list is known) + // Track ourselves as active participant with PoW record + if let idData = Data(hexString: event.id) { + let powBits = NostrPoW.leadingZeroBits(idData) + self.recordGeoParticipant(pubkeyHex: identity.publicKeyHex, geohash: ch.geohash, powBits: powBits) + } else { + self.recordGeoParticipant(pubkeyHex: identity.publicKeyHex) + } + SecureLogger.log("Geo: sent geo event id=\(event.id.prefix(12))… bits=\(bits) nonce=\(String(nonce))", category: SecureLogger.session, level: .debug) + // Reveal timestamp / stop animation + if let idx = self.messages.firstIndex(where: { $0.id == msgID }) { + self.messages[idx].isMiningPow = false + self.messages[idx].clearCachedFormattedText() + // Trigger UI refresh so timestamp appears + self.objectWillChange.send() + } + // If teleported and outside regional set, mark self teleported let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash } if LocationChannelManager.shared.teleported && hasRegional && !inRegional { @@ -1390,6 +1443,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate { } catch { SecureLogger.log("❌ Failed to send geohash message: \(error)", category: SecureLogger.session, level: .error) self.addSystemMessage("failed to send to location channel") + if let idx = self.messages.firstIndex(where: { $0.id == msgID }) { + self.messages[idx].isMiningPow = false + self.messages[idx].clearCachedFormattedText() + self.objectWillChange.send() + } } } } else { @@ -1491,6 +1549,16 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // Deduplicate if self.processedNostrEvents.contains(event.id) { return } self.recordProcessedEvent(event.id) + // PoW filter for geohash events + let ghFromTag: String? = event.tags.first(where: { $0.first?.lowercased() == "g" && $0.count >= 2 })?.dropFirst().first + let ghValue = ghFromTag ?? ch.geohash + if let idData = Data(hexString: event.id) { + let powBits = NostrPoW.leadingZeroBits(idData) + let required = PowPolicy.requiredBits(forGeohash: ghValue) + if powBits < required { + return + } + } // Log incoming tags for diagnostics let tagSummary = event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ",") SecureLogger.log("GeoTeleport: recv pub=\(event.pubkey.prefix(8))… tags=\(tagSummary)", @@ -1537,8 +1605,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate { self.nostrKeyMapping[key16] = event.pubkey let key8 = "nostr:" + String(event.pubkey.prefix(TransportConfig.nostrShortKeyDisplayLength)) self.nostrKeyMapping[key8] = event.pubkey - // Update participants last-seen for this pubkey - self.recordGeoParticipant(pubkeyHex: event.pubkey) + // Update participants last-seen for this pubkey with observed PoW bits + if let idData = Data(hexString: event.id) { + let powBits = NostrPoW.leadingZeroBits(idData) + self.recordGeoParticipant(pubkeyHex: event.pubkey, geohash: ch.geohash, powBits: powBits) + } let senderName = self.displayNameForNostrPubkey(event.pubkey) let content = event.content @@ -1723,6 +1794,24 @@ class ChatViewModel: ObservableObject, BitchatDelegate { } } + // Track last observed PoW bits per participant per geohash + private var geoParticipantPowBits: [String: [String: Int]] = [:] // geohash -> (pubkeyLower -> bits) + + private func recordGeoParticipant(pubkeyHex: String, geohash: String, powBits: Int?) { + let key = pubkeyHex.lowercased() + var map = geoParticipants[geohash] ?? [:] + map[key] = Date() + geoParticipants[geohash] = map + if let bits = powBits { + var powMap = geoParticipantPowBits[geohash] ?? [:] + powMap[key] = bits + geoParticipantPowBits[geohash] = powMap + } + if currentGeohash == geohash { + refreshGeohashPeople() + } + } + private func refreshGeohashPeople() { guard let gh = currentGeohash else { geohashPeople = []; return } let cutoff = Date().addingTimeInterval(-TransportConfig.uiRecentCutoffFiveMinutesSeconds) @@ -1731,6 +1820,17 @@ class ChatViewModel: ObservableObject, BitchatDelegate { map = map.filter { $0.value >= cutoff } // Remove blocked Nostr pubkeys map = map.filter { !SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: $0.key) } + // Enforce PoW threshold for peer list as well + let required = PowPolicy.requiredBits(forGeohash: gh) + let myHex: String? = (try? NostrIdentityBridge.deriveIdentity(forGeohash: gh))?.publicKeyHex.lowercased() + let powMap = geoParticipantPowBits[gh] ?? [:] + map = map.filter { (pub, _) in + if let bits = powMap[pub] { return bits >= required } + // Always allow self even if no PoW record yet + if let me = myHex, pub == me { return true } + // Otherwise, if no PoW info, hide until we see an acceptable event + return false + } geoParticipants[gh] = map // Build display list let people = map @@ -1761,9 +1861,17 @@ class ChatViewModel: ObservableObject, BitchatDelegate { func visibleGeohashPeople() -> [GeoPerson] { guard let gh = currentGeohash else { return [] } let cutoff = Date().addingTimeInterval(-TransportConfig.uiRecentCutoffFiveMinutesSeconds) + let required = PowPolicy.requiredBits(forGeohash: gh) + let myHex: String? = (try? NostrIdentityBridge.deriveIdentity(forGeohash: gh))?.publicKeyHex.lowercased() + let powMap = geoParticipantPowBits[gh] ?? [:] let map = (geoParticipants[gh] ?? [:]) .filter { $0.value >= cutoff } .filter { !SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: $0.key) } + .filter { (pub, _) in + if let bits = powMap[pub] { return bits >= required } + if let me = myHex, pub == me { return true } + return false + } let people = map .map { (pub, seen) in GeoPerson(id: pub, displayName: displayNameForNostrPubkey(pub), lastSeen: seen) } .sorted { $0.lastSeen > $1.lastSeen } @@ -1865,6 +1973,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate { NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in guard let self = self else { return } guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return } + // Enforce PoW for sampled geohashes too + if let idData = Data(hexString: event.id) { + let powBits = NostrPoW.leadingZeroBits(idData) + let required = PowPolicy.requiredBits(forGeohash: gh) + if powBits < required { return } + } // Compute current participant count (5-minute window) BEFORE updating with this event let cutoff = Date().addingTimeInterval(-TransportConfig.uiRecentCutoffFiveMinutesSeconds) let existingCount: Int = { @@ -1872,7 +1986,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate { return map.values.filter { $0 >= cutoff }.count }() // Update participants for this specific geohash - self.recordGeoParticipant(pubkeyHex: event.pubkey, geohash: gh) + if let idData = Data(hexString: event.id) { + let powBits = NostrPoW.leadingZeroBits(idData) + self.recordGeoParticipant(pubkeyHex: event.pubkey, geohash: gh, powBits: powBits) + } else { + self.recordGeoParticipant(pubkeyHex: event.pubkey, geohash: gh) + } // Notify only on rising-edge: previously zero people, now someone sends a chat let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines) guard !content.isEmpty else { return } @@ -3137,6 +3256,59 @@ class ChatViewModel: ObservableObject, BitchatDelegate { return processedContent } + + // MARK: - Helpers for mining UI + @MainActor + func isSelfMessage(_ message: BitchatMessage) -> Bool { + let isSelf: Bool = { + if let spid = message.senderPeerID { + if spid == meshService.myPeerID { return true } + if case .location(let ch) = activeChannel, + let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) { + if spid.hasPrefix("nostr:") || spid.hasPrefix("nostr_") { + let bare: String = spid.hasPrefix("nostr:") ? String(spid.dropFirst(6)) : String(spid.dropFirst(6)) + return bare.lowercased().hasPrefix(id.publicKeyHex.lowercased().prefix(TransportConfig.nostrShortKeyDisplayLength)) + } + } + return spid == meshService.myPeerID + } + if message.sender == nickname { return true } + if message.sender.hasPrefix(nickname + "#") { return true } + return false + }() + return isSelf + } + + @MainActor + func baseColorForMessage(_ message: BitchatMessage, colorScheme: ColorScheme) -> Color { + let isDark = colorScheme == .dark + return isSelfMessage(message) ? .orange : peerColor(for: message, isDark: isDark) + } + + @MainActor + func formatSenderPrefix(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString { + let isSelf = isSelfMessage(message) + let isDark = colorScheme == .dark + let baseColor: Color = isSelf ? .orange : peerColor(for: message, isDark: isDark) + let (baseName, suffix) = splitSuffix(from: message.sender) + var senderStyle = AttributeContainer() + senderStyle.foregroundColor = baseColor + let fontWeight: Font.Weight = isSelf ? .bold : .medium + senderStyle.font = .system(size: 14, weight: fontWeight, design: .monospaced) + if let spid = message.senderPeerID, let url = URL(string: "bitchat://user/\(spid.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? spid)") { + senderStyle.link = url + } + var prefix = AttributedString() + prefix.append(AttributedString("<@").mergingAttributes(senderStyle)) + prefix.append(AttributedString(baseName).mergingAttributes(senderStyle)) + if !suffix.isEmpty { + var suffixStyle = senderStyle + suffixStyle.foregroundColor = baseColor.opacity(0.6) + prefix.append(AttributedString(suffix).mergingAttributes(suffixStyle)) + } + prefix.append(AttributedString("> ").mergingAttributes(senderStyle)) + return prefix + } @MainActor func formatMessageAsText(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString { @@ -3443,12 +3615,15 @@ class ChatViewModel: ObservableObject, BitchatDelegate { } } - // Add timestamp at the end (smaller, light grey) - let timestamp = AttributedString(" [\(formatTimestamp(message.timestamp))]") - var timestampStyle = AttributeContainer() - timestampStyle.foregroundColor = Color.gray.opacity(0.7) - timestampStyle.font = .system(size: 10, design: .monospaced) - result.append(timestamp.mergingAttributes(timestampStyle)) + // Add timestamp at the end (hide while PoW mining) + let hideTimestamp = (message.isMiningPow ?? false) + if !hideTimestamp { + let timestamp = AttributedString(" [\(formatTimestamp(message.timestamp))]") + var timestampStyle = AttributeContainer() + timestampStyle.foregroundColor = Color.gray.opacity(0.7) + timestampStyle.font = .system(size: 10, design: .monospaced) + result.append(timestamp.mergingAttributes(timestampStyle)) + } } else { // System message var contentStyle = AttributeContainer() diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 779a9d92..7a70de09 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -303,10 +303,21 @@ struct ContentView: View { HStack(alignment: .top, spacing: 0) { let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuTokens.isEmpty let isExpanded = expandedMessageIDs.contains(message.id) - Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme)) - .fixedSize(horizontal: false, vertical: true) + let formatted = viewModel.formatMessageAsText(message, colorScheme: colorScheme) + let prefixAttr = viewModel.formatSenderPrefix(message, colorScheme: colorScheme) + let baseColor = viewModel.baseColorForMessage(message, colorScheme: colorScheme) + let isSelfMsg = viewModel.isSelfMessage(message) + if (message.isMiningPow ?? false) { + // Single Text combines prefix + animated body, so wrapping matches final layout + CombinedMiningText(prefix: prefixAttr, original: message.content, color: baseColor, isSelf: isSelfMsg) .lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil) .frame(maxWidth: .infinity, alignment: .leading) + } else { + Text(formatted) + .fixedSize(horizontal: false, vertical: true) + .lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil) + .frame(maxWidth: .infinity, alignment: .leading) + } // Delivery status indicator for private messages if message.isPrivate && message.sender == viewModel.nickname, @@ -1387,6 +1398,53 @@ struct ContentView: View { } +// MARK: - Mining UI helpers (local to ensure inclusion in target) +fileprivate struct CombinedMiningText: View { + let prefix: AttributedString + let original: String + let color: Color + let isSelf: Bool + let fontSize: CGFloat = 14 + let interval: TimeInterval = 0.06 + @State private var display: String = "" + @State private var timer: Timer? = nil + + private let charset: [Character] = Array("abcdefghijklmnopqrstuvwxyz0123456789@#$%&*+-") + + var body: some View { + Text(combinedAttributed) + .fixedSize(horizontal: false, vertical: true) + .onAppear { + display = scramble(original) + timer?.invalidate() + timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { _ in + display = scramble(original) + } + } + .onDisappear { timer?.invalidate(); timer = nil } + } + + private var combinedAttributed: AttributedString { + var res = AttributedString() + res.append(prefix) + var style = AttributeContainer() + style.foregroundColor = color + style.font = .system(size: fontSize, weight: isSelf ? .bold : .regular, design: .monospaced) + res.append(AttributedString(display).mergingAttributes(style)) + return res + } + + private func scramble(_ s: String) -> String { + var out = String() + out.reserveCapacity(s.count) + for ch in s { + if ch.isWhitespace || ch.isNewline { out.append(ch); continue } + out.append(charset.randomElement() ?? ch) + } + return out + } +} + // MARK: - Helper Views // Rounded payment chip button diff --git a/bitchat/Views/MiningAnimation.swift b/bitchat/Views/MiningAnimation.swift new file mode 100644 index 00000000..fa86984e --- /dev/null +++ b/bitchat/Views/MiningAnimation.swift @@ -0,0 +1,87 @@ +import SwiftUI + +/// Mining body animation: scramble characters (matrix-like) until PoW completes. +struct MiningScrambleText: View { + let original: String + let color: Color + let isSelf: Bool + let fontSize: CGFloat = 14 + let interval: TimeInterval = 0.06 + @State private var display: String = "" + @State private var timer: Timer? = nil + + private let charset: [Character] = Array("abcdefghijklmnopqrstuvwxyz0123456789@#$%&*+-") + + var body: some View { + Text(display) + .font(.system(size: fontSize, weight: isSelf ? .bold : .regular, design: .monospaced)) + .foregroundColor(color) + .onAppear { + display = scramble(original) + timer?.invalidate() + timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { _ in + display = scramble(original) + } + } + .onDisappear { + timer?.invalidate(); timer = nil + } +} + +/// Combined prefix+body single-Text variant to ensure identical wrapping +struct MiningCombinedText: View { + let prefix: AttributedString + let original: String + let color: Color + let isSelf: Bool + let fontSize: CGFloat = 14 + let interval: TimeInterval = 0.06 + @State private var display: String = "" + @State private var timer: Timer? = nil + + private let charset: [Character] = Array("abcdefghijklmnopqrstuvwxyz0123456789@#$%&*+-") + + var body: some View { + Text(combinedAttributed) + .fixedSize(horizontal: false, vertical: true) + .onAppear { + display = scramble(original) + timer?.invalidate() + timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { _ in + display = scramble(original) + } + } + .onDisappear { timer?.invalidate(); timer = nil } + } + + private var combinedAttributed: AttributedString { + var res = AttributedString() + res.append(prefix) + var style = AttributeContainer() + style.foregroundColor = color + style.font = .system(size: fontSize, weight: isSelf ? .bold : .regular, design: .monospaced) + res.append(AttributedString(display).mergingAttributes(style)) + return res + } + + private func scramble(_ s: String) -> String { + var out = String() + out.reserveCapacity(s.count) + for ch in s { + if ch.isWhitespace || ch.isNewline { out.append(ch); continue } + out.append(charset.randomElement() ?? ch) + } + return out + } +} + + private func scramble(_ s: String) -> String { + var out = String() + out.reserveCapacity(s.count) + for ch in s { + if ch.isWhitespace || ch.isNewline { out.append(ch); continue } + out.append(charset.randomElement() ?? ch) + } + return out + } +} diff --git a/bitchatTests/Info.plist b/bitchatTests/Info.plist index b85afdda..720fbe9e 100644 --- a/bitchatTests/Info.plist +++ b/bitchatTests/Info.plist @@ -2,17 +2,17 @@ - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - $(PRODUCT_BUNDLE_PACKAGE_TYPE) - CFBundleShortVersionString - 1.0 - CFBundleVersion - 1 + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 - \ No newline at end of file + diff --git a/bitchatTests/NostrPoWTests.swift b/bitchatTests/NostrPoWTests.swift new file mode 100644 index 00000000..fd2530d8 --- /dev/null +++ b/bitchatTests/NostrPoWTests.swift @@ -0,0 +1,32 @@ +import XCTest +import CryptoKit +@testable import bitchat + +final class NostrPoWTests: XCTestCase { + func testLeadingZeroBits() { + // 0x00 -> 8, 0x00 -> 8, 0xF0 -> 0 leading zeros + let data = Data([0x00, 0x00, 0xF0]) + Data(repeating: 0x00, count: 29) + let lz = NostrPoW.leadingZeroBits(data) + XCTAssertEqual(lz, 16) + } + + func testMineLowDifficulty() { + let pubkey = String(repeating: "a", count: 64) + let createdAt = Int(Date().timeIntervalSince1970) + let kind = 20000 + let tags = [["g", "u4pruydqqvj"]] + let content = "hello" + let targetBits = 8 // keep it very low for test speed + let (nonce, idHex) = NostrPoW.mine(pubkey: pubkey, createdAt: createdAt, kind: kind, baseTags: tags, content: content, targetBits: targetBits) + XCTAssertGreaterThan(nonce, 0) + // Verify difficulty + let powTags = tags + [["nonce", String(nonce), String(targetBits)]] + // Recompute ID + let serialized: [Any] = [0, pubkey, createdAt, kind, powTags, content] + let json = try! JSONSerialization.data(withJSONObject: serialized, options: [.withoutEscapingSlashes]) + let digest = CryptoKit.SHA256.hash(data: json) + let idData = Data(digest) + XCTAssertEqual(idHex.count, 64) + XCTAssertGreaterThanOrEqual(NostrPoW.leadingZeroBits(idData), targetBits) + } +}