mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 23:45:18 +00:00
Merge remote-tracking branch 'origin/feat/geohash-pow' into feat/integration-all
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
import BitFoundation
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
/// NIP-13 proof-of-work for Nostr events.
|
||||
///
|
||||
/// Outgoing kind-20000 geohash messages mine a `["nonce", "<value>", "<target>"]`
|
||||
/// tag so the event ID carries at least `target` leading zero bits. Inbound
|
||||
/// events are scored (never hard-rejected — the network has clients that do
|
||||
/// not mine): validated PoW at or above `rateLimitBypassBits` relaxes the
|
||||
/// per-sender public rate limit, everything else keeps the strict limits.
|
||||
enum NostrPoW {
|
||||
|
||||
// MARK: - Tuning
|
||||
|
||||
/// Difficulty (leading zero bits of the event ID) mined onto outgoing
|
||||
/// geohash messages. 8 bits is ~256 hash attempts — typically well under
|
||||
/// 100 ms on any supported device.
|
||||
static let targetBits = 8
|
||||
|
||||
/// Inbound events whose validated NIP-13 difficulty is at least this many
|
||||
/// bits skip the per-sender rate-limit bucket (the content-flood bucket
|
||||
/// still applies). See `MessageRateLimiter.allow`.
|
||||
static let rateLimitBypassBits = 8
|
||||
|
||||
/// Hard cap on mining wall-clock time. When it hits, the committed target
|
||||
/// steps down until a difficulty reachable in a small extra budget is
|
||||
/// found and the message is sent anyway — mining never blocks sending.
|
||||
static let miningTimeCap: TimeInterval = 2.0
|
||||
|
||||
/// Budget for each stepped-down attempt after the main cap (or a task
|
||||
/// cancellation) hits.
|
||||
private static let fallbackTimeCap: TimeInterval = 0.15
|
||||
|
||||
/// The hot loop checks the deadline and task cancellation every this many
|
||||
/// hash attempts.
|
||||
private static let checkInterval: UInt64 = 1024
|
||||
|
||||
/// The nonce value is a fixed-width hex counter so the serialized event
|
||||
/// template can be mutated in place without reallocation.
|
||||
private static let nonceLength = 16
|
||||
|
||||
// MARK: - Scoring
|
||||
|
||||
/// Number of leading zero bits in a byte sequence (NIP-13 difficulty of
|
||||
/// an event-ID hash).
|
||||
static func leadingZeroBits<Bytes: Sequence<UInt8>>(_ bytes: Bytes) -> Int {
|
||||
var total = 0
|
||||
for byte in bytes {
|
||||
if byte == 0 {
|
||||
total += 8
|
||||
} else {
|
||||
total += byte.leadingZeroBitCount
|
||||
break
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
/// Validated NIP-13 difficulty of an inbound event.
|
||||
///
|
||||
/// The committed target in the nonce tag is what counts: the actual
|
||||
/// leading zero bits of the ID must meet it (otherwise the claim is void
|
||||
/// and the event scores 0), and work beyond the commitment earns no extra
|
||||
/// credit — this stops spammers who mine a low target from getting lucky
|
||||
/// high scores. Events without a well-formed commitment score 0.
|
||||
static func validatedDifficulty(idHex: String, tags: [[String]]) -> Int {
|
||||
guard let nonceTag = tags.last(where: { $0.first == "nonce" }),
|
||||
nonceTag.count >= 3,
|
||||
let committed = Int(nonceTag[2]),
|
||||
committed > 0, committed <= 256,
|
||||
let idData = Data(hexString: idHex)
|
||||
else {
|
||||
return 0
|
||||
}
|
||||
return leadingZeroBits(idData) >= committed ? committed : 0
|
||||
}
|
||||
|
||||
// MARK: - Mining
|
||||
|
||||
/// Mine a `["nonce", value, target]` tag for the given unsigned-event
|
||||
/// fields. Nonisolated async: runs off the calling actor.
|
||||
///
|
||||
/// Bounded by `miningTimeCap`: when the cap hits — or the surrounding
|
||||
/// task is cancelled — the committed target steps down (halving to 0,
|
||||
/// which any hash satisfies) so the event still ships promptly with an
|
||||
/// honest commitment at the difficulty actually reached. Returns nil only
|
||||
/// if canonical serialization fails; the caller then sends unmined.
|
||||
static func mineNonceTag(
|
||||
pubkey: String,
|
||||
createdAt: Int,
|
||||
kind: Int,
|
||||
tags: [[String]],
|
||||
content: String,
|
||||
targetBits: Int = NostrPoW.targetBits
|
||||
) async -> [String]? {
|
||||
var target = min(max(targetBits, 0), 256)
|
||||
var budget = miningTimeCap
|
||||
while true {
|
||||
if let tag = mineAttempt(
|
||||
pubkey: pubkey,
|
||||
createdAt: createdAt,
|
||||
kind: kind,
|
||||
baseTags: tags,
|
||||
content: content,
|
||||
targetBits: target,
|
||||
budget: budget
|
||||
) {
|
||||
return tag
|
||||
}
|
||||
// Target 0 succeeds on the first hash, so reaching it with nil
|
||||
// means serialization itself failed — give up on mining.
|
||||
if target == 0 { return nil }
|
||||
target /= 2
|
||||
budget = fallbackTimeCap
|
||||
}
|
||||
}
|
||||
|
||||
/// One bounded mining pass at a fixed committed target. Allocation-light:
|
||||
/// the canonical serialization is built once and only the fixed-width
|
||||
/// nonce bytes are rewritten per attempt (the event ID is recomputed for
|
||||
/// every attempt, per NIP-13). Returns nil on timeout/cancellation or if
|
||||
/// the template could not be built.
|
||||
private static func mineAttempt(
|
||||
pubkey: String,
|
||||
createdAt: Int,
|
||||
kind: Int,
|
||||
baseTags: [[String]],
|
||||
content: String,
|
||||
targetBits: Int,
|
||||
budget: TimeInterval
|
||||
) -> [String]? {
|
||||
let targetString = String(targetBits)
|
||||
guard let template = serializedTemplate(
|
||||
pubkey: pubkey,
|
||||
createdAt: createdAt,
|
||||
kind: kind,
|
||||
baseTags: baseTags,
|
||||
content: content,
|
||||
targetString: targetString
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
var buffer = template.buffer
|
||||
let nonceRange = template.nonceRange
|
||||
|
||||
let deadline = DispatchTime.now().uptimeNanoseconds &+ UInt64(budget * 1_000_000_000)
|
||||
let hexDigits = [UInt8]("0123456789abcdef".utf8)
|
||||
var nonce = UInt64.random(in: .min ... .max)
|
||||
var attempts: UInt64 = 0
|
||||
|
||||
while true {
|
||||
// Write the nonce as 16 lowercase hex chars, in place.
|
||||
var value = nonce
|
||||
var index = nonceRange.upperBound
|
||||
while index > nonceRange.lowerBound {
|
||||
index -= 1
|
||||
buffer[index] = hexDigits[Int(value & 0xF)]
|
||||
value >>= 4
|
||||
}
|
||||
|
||||
if leadingZeroBits(SHA256.hash(data: buffer)) >= targetBits {
|
||||
// Identical to the bytes just written into the buffer.
|
||||
return ["nonce", String(format: "%016llx", nonce), targetString]
|
||||
}
|
||||
|
||||
nonce &+= 1
|
||||
attempts &+= 1
|
||||
if attempts % checkInterval == 0,
|
||||
Task.isCancelled || DispatchTime.now().uptimeNanoseconds >= deadline {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical NIP-01 serialization of the event with a placeholder nonce,
|
||||
/// plus the byte range of the nonce value inside it.
|
||||
///
|
||||
/// The range is located by serializing twice with two same-length
|
||||
/// placeholders and diffing the buffers — the only differing bytes are
|
||||
/// the nonce value, so this stays correct however `JSONSerialization`
|
||||
/// escapes the surrounding fields (and even if the content contains the
|
||||
/// placeholder text itself).
|
||||
private static func serializedTemplate(
|
||||
pubkey: String,
|
||||
createdAt: Int,
|
||||
kind: Int,
|
||||
baseTags: [[String]],
|
||||
content: String,
|
||||
targetString: String
|
||||
) -> (buffer: Data, nonceRange: Range<Int>)? {
|
||||
func serialize(noncePlaceholder: String) -> Data? {
|
||||
var tags = baseTags
|
||||
tags.append(["nonce", noncePlaceholder, targetString])
|
||||
let serialized: [Any] = [0, pubkey, createdAt, kind, tags, content]
|
||||
return try? JSONSerialization.data(withJSONObject: serialized, options: [.withoutEscapingSlashes])
|
||||
}
|
||||
|
||||
guard let zeros = serialize(noncePlaceholder: String(repeating: "0", count: nonceLength)),
|
||||
let effs = serialize(noncePlaceholder: String(repeating: "f", count: nonceLength)),
|
||||
zeros.count == effs.count
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var firstDiff = -1
|
||||
var lastDiff = -1
|
||||
for index in 0..<zeros.count where zeros[index] != effs[index] {
|
||||
if firstDiff < 0 { firstDiff = index }
|
||||
lastDiff = index
|
||||
}
|
||||
guard firstDiff >= 0, lastDiff - firstDiff + 1 == nonceLength else { return nil }
|
||||
return (zeros, firstDiff..<(firstDiff + nonceLength))
|
||||
}
|
||||
}
|
||||
@@ -170,6 +170,63 @@ struct NostrProtocol {
|
||||
nickname: String? = nil,
|
||||
teleported: Bool = false
|
||||
) throws -> NostrEvent {
|
||||
let event = NostrEvent(
|
||||
pubkey: senderIdentity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .ephemeralEvent,
|
||||
tags: ephemeralGeohashTags(geohash: geohash, nickname: nickname, teleported: teleported),
|
||||
content: content
|
||||
)
|
||||
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
||||
return try event.sign(with: schnorrKey)
|
||||
}
|
||||
|
||||
/// Create a kind-20000 geohash message carrying a NIP-13 proof-of-work
|
||||
/// nonce tag (see `NostrPoW`). Mining runs off the calling actor and is
|
||||
/// bounded by `NostrPoW.miningTimeCap`; when the cap hits (or the
|
||||
/// surrounding task is cancelled) the event ships at the highest
|
||||
/// committed difficulty still met, and if mining is impossible it ships
|
||||
/// unmined — sending is never blocked.
|
||||
static func createMinedEphemeralGeohashEvent(
|
||||
content: String,
|
||||
geohash: String,
|
||||
senderIdentity: NostrIdentity,
|
||||
nickname: String? = nil,
|
||||
teleported: Bool = false,
|
||||
powTargetBits: Int = NostrPoW.targetBits
|
||||
) async throws -> NostrEvent {
|
||||
var tags = ephemeralGeohashTags(geohash: geohash, nickname: nickname, teleported: teleported)
|
||||
// Fix created_at up front: the mined nonce commits to the full
|
||||
// serialized event, so the signed event must reuse the exact value.
|
||||
let createdAt = Int(Date().timeIntervalSince1970)
|
||||
if let nonceTag = await NostrPoW.mineNonceTag(
|
||||
pubkey: senderIdentity.publicKeyHex,
|
||||
createdAt: createdAt,
|
||||
kind: EventKind.ephemeralEvent.rawValue,
|
||||
tags: tags,
|
||||
content: content,
|
||||
targetBits: powTargetBits
|
||||
) {
|
||||
tags.append(nonceTag)
|
||||
}
|
||||
let event = NostrEvent(
|
||||
pubkey: senderIdentity.publicKeyHex,
|
||||
createdAt: Date(timeIntervalSince1970: TimeInterval(createdAt)),
|
||||
kind: .ephemeralEvent,
|
||||
tags: tags,
|
||||
content: content
|
||||
)
|
||||
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
||||
return try event.sign(with: schnorrKey)
|
||||
}
|
||||
|
||||
/// Tags for a kind-20000 geohash message (shared by the plain and mined
|
||||
/// variants).
|
||||
private static func ephemeralGeohashTags(
|
||||
geohash: String,
|
||||
nickname: String?,
|
||||
teleported: Bool
|
||||
) -> [[String]] {
|
||||
var tags = [["g", geohash]]
|
||||
if let nickname = nickname?.trimmedOrNilIfEmpty {
|
||||
tags.append(["n", nickname])
|
||||
@@ -177,15 +234,7 @@ struct NostrProtocol {
|
||||
if teleported {
|
||||
tags.append(["t", "teleport"])
|
||||
}
|
||||
let event = NostrEvent(
|
||||
pubkey: senderIdentity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .ephemeralEvent,
|
||||
tags: tags,
|
||||
content: content
|
||||
)
|
||||
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
||||
return try event.sign(with: schnorrKey)
|
||||
return tags
|
||||
}
|
||||
|
||||
/// Create a geohash presence heartbeat (kind 20001)
|
||||
|
||||
@@ -331,7 +331,7 @@ private extension ChatLifecycleCoordinator {
|
||||
|
||||
do {
|
||||
let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash)
|
||||
let event = try NostrProtocol.createEphemeralGeohashEvent(
|
||||
let event = try await NostrProtocol.createMinedEphemeralGeohashEvent(
|
||||
content: message,
|
||||
geohash: channel.geohash,
|
||||
senderIdentity: identity,
|
||||
|
||||
@@ -68,10 +68,22 @@ extension ChatViewModel: ChatOutgoingContext {
|
||||
final class ChatOutgoingCoordinator {
|
||||
private unowned let context: any ChatOutgoingContext
|
||||
|
||||
/// In-flight NIP-13 mining for the most recent geohash send. A newer send
|
||||
/// (or leaving the channel) cancels it, which only expedites the mining —
|
||||
/// the message still goes out at the difficulty already reached.
|
||||
/// (Read access is internal so tests can await the send's completion.)
|
||||
private(set) var geohashMiningTask: Task<Void, Never>?
|
||||
|
||||
init(context: any ChatOutgoingContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
/// Finish any in-flight geohash PoW mining early (the pending message
|
||||
/// still sends, at whatever committed difficulty it reached).
|
||||
func expeditePendingGeohashMining() {
|
||||
geohashMiningTask?.cancel()
|
||||
}
|
||||
|
||||
func sendMessage(_ content: String) {
|
||||
guard let trimmed = content.trimmedOrNilIfEmpty else { return }
|
||||
|
||||
@@ -92,120 +104,117 @@ final class ChatOutgoingCoordinator {
|
||||
}
|
||||
|
||||
let mentions = context.parseMentions(from: content)
|
||||
let preparedMessage = preparePublicMessage(content: content, trimmed: trimmed, mentions: mentions)
|
||||
guard let preparedMessage else { return }
|
||||
|
||||
appendLocalEcho(preparedMessage.message)
|
||||
routePublicMessage(
|
||||
originalContent: content,
|
||||
mentions: mentions,
|
||||
geoContext: preparedMessage.geoContext,
|
||||
messageID: preparedMessage.message.id,
|
||||
timestamp: preparedMessage.message.timestamp
|
||||
)
|
||||
switch context.activeChannel {
|
||||
case .mesh:
|
||||
sendMeshPublicMessage(originalContent: content, trimmed: trimmed, mentions: mentions)
|
||||
case .location(let channel):
|
||||
sendGeohashPublicMessage(trimmed, mentions: mentions, channel: channel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension ChatOutgoingCoordinator {
|
||||
func preparePublicMessage(
|
||||
content: String,
|
||||
trimmed: String,
|
||||
mentions: [String]
|
||||
) -> (message: BitchatMessage, geoContext: ChatViewModel.GeoOutgoingContext?)? {
|
||||
var geoContext: ChatViewModel.GeoOutgoingContext?
|
||||
var displaySender = context.nickname
|
||||
var localSenderPeerID = context.myPeerID
|
||||
var messageID: String?
|
||||
var messageTimestamp = Date()
|
||||
func sendMeshPublicMessage(originalContent: String, trimmed: String, mentions: [String]) {
|
||||
let message = BitchatMessage(
|
||||
sender: context.nickname,
|
||||
content: trimmed,
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
senderPeerID: context.myPeerID,
|
||||
mentions: mentions.isEmpty ? nil : mentions
|
||||
)
|
||||
|
||||
switch context.activeChannel {
|
||||
case .mesh:
|
||||
break
|
||||
appendLocalEcho(message, to: .mesh)
|
||||
context.recordPublicActivity(forChannelKey: "mesh")
|
||||
context.sendMeshMessage(
|
||||
originalContent,
|
||||
mentions: mentions,
|
||||
messageID: message.id,
|
||||
timestamp: message.timestamp
|
||||
)
|
||||
}
|
||||
|
||||
case .location(let channel):
|
||||
/// Geohash sends mine a NIP-13 nonce tag first (off the main actor, see
|
||||
/// `NostrPoW`), so the whole echo-and-send runs in a task once the signed
|
||||
/// event — whose ID is also the local message ID — exists. Typical mining
|
||||
/// at the default target is well under 100 ms and hard-capped at
|
||||
/// `NostrPoW.miningTimeCap`, so sending is never meaningfully delayed.
|
||||
func sendGeohashPublicMessage(_ trimmed: String, mentions: [String], channel: GeohashChannel) {
|
||||
let identity: NostrIdentity
|
||||
do {
|
||||
identity = try context.deriveNostrIdentity(forGeohash: channel.geohash)
|
||||
} catch {
|
||||
SecureLogger.error("❌ Failed to prepare geohash message: \(error)", category: .session)
|
||||
context.addSystemMessage(
|
||||
String(localized: "system.location.send_failed", comment: "System message when a location channel send fails")
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let displaySender = context.nickname + "#" + String(identity.publicKeyHex.suffix(4))
|
||||
let senderPeerID = PeerID(nostr: identity.publicKeyHex)
|
||||
let teleported = context.isTeleported
|
||||
let nickname = context.nickname
|
||||
|
||||
// Serialize geohash sends: each send awaits the previous send's task
|
||||
// before it appends + relays, so user-visible order always matches
|
||||
// send order even when an earlier message mines longer than a later
|
||||
// one. Cancelling the previous task only *expedites* its mining (the
|
||||
// NIP-13 target is polled, not aborted), so it still finishes and
|
||||
// sends — and it finishes fast, so awaiting it never stacks mining
|
||||
// delays or blocks a send beyond `NostrPoW.miningTimeCap`.
|
||||
let previousSend = geohashMiningTask
|
||||
previousSend?.cancel()
|
||||
geohashMiningTask = Task { @MainActor [weak context = self.context] in
|
||||
await previousSend?.value
|
||||
|
||||
let event: NostrEvent
|
||||
do {
|
||||
let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash)
|
||||
let suffix = String(identity.publicKeyHex.suffix(4))
|
||||
displaySender = context.nickname + "#" + suffix
|
||||
localSenderPeerID = PeerID(nostr: identity.publicKeyHex)
|
||||
|
||||
let teleported = context.isTeleported
|
||||
let event = try NostrProtocol.createEphemeralGeohashEvent(
|
||||
event = try await NostrProtocol.createMinedEphemeralGeohashEvent(
|
||||
content: trimmed,
|
||||
geohash: channel.geohash,
|
||||
senderIdentity: identity,
|
||||
nickname: context.nickname,
|
||||
teleported: teleported
|
||||
)
|
||||
|
||||
messageID = event.id
|
||||
messageTimestamp = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||
geoContext = (
|
||||
channel: channel,
|
||||
event: event,
|
||||
identity: identity,
|
||||
nickname: nickname,
|
||||
teleported: teleported
|
||||
)
|
||||
} catch {
|
||||
SecureLogger.error("❌ Failed to prepare geohash message: \(error)", category: .session)
|
||||
context.addSystemMessage(
|
||||
String(localized: "system.location.send_failed", comment: "System message when a location channel send fails")
|
||||
)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
let message = BitchatMessage(
|
||||
id: messageID,
|
||||
sender: displaySender,
|
||||
content: trimmed,
|
||||
timestamp: messageTimestamp,
|
||||
isRelay: false,
|
||||
senderPeerID: localSenderPeerID,
|
||||
mentions: mentions.isEmpty ? nil : mentions
|
||||
)
|
||||
|
||||
return (message, geoContext)
|
||||
}
|
||||
|
||||
func appendLocalEcho(_ message: BitchatMessage) {
|
||||
context.appendPublicMessage(message, to: ConversationID(channelID: context.activeChannel))
|
||||
|
||||
let contentKey = context.normalizedContentKey(message.content)
|
||||
context.recordContentKey(contentKey, timestamp: message.timestamp)
|
||||
}
|
||||
|
||||
func routePublicMessage(
|
||||
originalContent: String,
|
||||
mentions: [String],
|
||||
geoContext: ChatViewModel.GeoOutgoingContext?,
|
||||
messageID: String,
|
||||
timestamp: Date
|
||||
) {
|
||||
switch context.activeChannel {
|
||||
case .mesh:
|
||||
context.recordPublicActivity(forChannelKey: "mesh")
|
||||
context.sendMeshMessage(
|
||||
originalContent,
|
||||
mentions: mentions,
|
||||
messageID: messageID,
|
||||
timestamp: timestamp
|
||||
)
|
||||
|
||||
case .location(let channel):
|
||||
context.recordPublicActivity(forChannelKey: "geo:\(channel.geohash)")
|
||||
|
||||
guard let geoContext, geoContext.channel.geohash == channel.geohash else {
|
||||
SecureLogger.error("Geo: missing send context for \(channel.geohash)", category: .session)
|
||||
context.addSystemMessage(
|
||||
context?.addSystemMessage(
|
||||
String(localized: "system.location.send_failed", comment: "System message when a location channel send fails")
|
||||
)
|
||||
return
|
||||
}
|
||||
guard let context else { return }
|
||||
|
||||
Task { @MainActor [weak context = self.context] in
|
||||
context?.sendGeohash(context: geoContext)
|
||||
}
|
||||
let message = BitchatMessage(
|
||||
id: event.id,
|
||||
sender: displaySender,
|
||||
content: trimmed,
|
||||
timestamp: Date(timeIntervalSince1970: TimeInterval(event.created_at)),
|
||||
isRelay: false,
|
||||
senderPeerID: senderPeerID,
|
||||
mentions: mentions.isEmpty ? nil : mentions
|
||||
)
|
||||
|
||||
context.appendPublicMessage(message, to: ConversationID(channelID: .location(channel)))
|
||||
let contentKey = context.normalizedContentKey(message.content)
|
||||
context.recordContentKey(contentKey, timestamp: message.timestamp)
|
||||
|
||||
context.recordPublicActivity(forChannelKey: "geo:\(channel.geohash)")
|
||||
context.sendGeohash(context: (
|
||||
channel: channel,
|
||||
event: event,
|
||||
identity: identity,
|
||||
teleported: teleported
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
func appendLocalEcho(_ message: BitchatMessage, to conversationID: ConversationID) {
|
||||
context.appendPublicMessage(message, to: conversationID)
|
||||
|
||||
let contentKey = context.normalizedContentKey(message.content)
|
||||
context.recordContentKey(contentKey, timestamp: message.timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +82,9 @@ protocol ChatPublicConversationContext: AnyObject {
|
||||
// MARK: Inbound public message processing
|
||||
func processActionMessage(_ message: BitchatMessage) -> BitchatMessage
|
||||
func isMessageBlocked(_ message: BitchatMessage) -> Bool
|
||||
func allowPublicMessage(senderKey: String, contentKey: String) -> Bool
|
||||
/// `powBits` is the validated NIP-13 difficulty of the source Nostr event
|
||||
/// (0 for mesh messages); sufficient PoW relaxes the per-sender bucket.
|
||||
func allowPublicMessage(senderKey: String, contentKey: String, powBits: Int) -> Bool
|
||||
/// Buffers a visible-channel message for the batched (~80 ms) pipeline
|
||||
/// flush, which commits it to `conversationID` in the store.
|
||||
func enqueuePublicMessage(_ message: BitchatMessage, to conversationID: ConversationID)
|
||||
@@ -137,8 +139,8 @@ extension ChatViewModel: ChatPublicConversationContext {
|
||||
meshService.sendMessage(content, mentions: mentions, messageID: messageID, timestamp: timestamp)
|
||||
}
|
||||
|
||||
func allowPublicMessage(senderKey: String, contentKey: String) -> Bool {
|
||||
publicRateLimiter.allow(senderKey: senderKey, contentKey: contentKey)
|
||||
func allowPublicMessage(senderKey: String, contentKey: String, powBits: Int) -> Bool {
|
||||
publicRateLimiter.allow(senderKey: senderKey, contentKey: contentKey, powBits: powBits)
|
||||
}
|
||||
|
||||
func enqueuePublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) {
|
||||
@@ -377,7 +379,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
|
||||
guard let context else { return }
|
||||
do {
|
||||
let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash)
|
||||
let event = try NostrProtocol.createEphemeralGeohashEvent(
|
||||
let event = try await NostrProtocol.createMinedEphemeralGeohashEvent(
|
||||
content: content,
|
||||
geohash: channel.geohash,
|
||||
senderIdentity: identity,
|
||||
@@ -405,7 +407,11 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
|
||||
)
|
||||
}
|
||||
|
||||
func handlePublicMessage(_ message: BitchatMessage) {
|
||||
/// - Parameter powBits: validated NIP-13 difficulty of the source Nostr
|
||||
/// event (0 for mesh messages). Sufficient PoW relaxes the per-sender
|
||||
/// rate limit; low/no-PoW events keep the strict limits so old clients
|
||||
/// still get through at normal rates.
|
||||
func handlePublicMessage(_ message: BitchatMessage, powBits: Int = 0) {
|
||||
let finalMessage = context.processActionMessage(message)
|
||||
if context.isMessageBlocked(finalMessage) { return }
|
||||
|
||||
@@ -415,7 +421,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
|
||||
if shouldRateLimit {
|
||||
let senderKey = normalizedSenderKey(for: finalMessage)
|
||||
let contentKey = context.normalizedContentKey(finalMessage.content)
|
||||
if !context.allowPublicMessage(senderKey: senderKey, contentKey: contentKey) {
|
||||
if !context.allowPublicMessage(senderKey: senderKey, contentKey: contentKey, powBits: powBits) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,6 +317,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
get { conversations.activeChannel }
|
||||
set {
|
||||
guard conversations.activeChannel != newValue else { return }
|
||||
// Leaving a channel expedites any in-flight NIP-13 mining: the
|
||||
// pending message still sends, at the difficulty already reached.
|
||||
outgoingCoordinator.expeditePendingGeohashMining()
|
||||
conversations.setActiveChannel(newValue)
|
||||
visibleMessagesCache = nil
|
||||
objectWillChange.send()
|
||||
@@ -1748,6 +1751,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
publicConversationCoordinator.handlePublicMessage(message)
|
||||
}
|
||||
|
||||
/// Handle an incoming public Nostr message with its validated NIP-13
|
||||
/// difficulty; sufficient PoW relaxes the per-sender rate limit.
|
||||
@MainActor
|
||||
func handlePublicMessage(_ message: BitchatMessage, powBits: Int) {
|
||||
publicConversationCoordinator.handlePublicMessage(message, powBits: powBits)
|
||||
}
|
||||
|
||||
/// Check for mentions and send notifications
|
||||
func checkForMentions(_ message: BitchatMessage) {
|
||||
publicConversationCoordinator.checkForMentions(message)
|
||||
|
||||
@@ -48,15 +48,25 @@ struct MessageRateLimiter {
|
||||
self.contentRefill = contentRefillPerSec
|
||||
}
|
||||
|
||||
mutating func allow(senderKey: String, contentKey: String, now: Date = Date()) -> Bool {
|
||||
var senderBucket = senderBuckets[senderKey] ?? TokenBucket(
|
||||
capacity: senderCapacity,
|
||||
tokens: senderCapacity,
|
||||
refillPerSec: senderRefill,
|
||||
lastRefill: now
|
||||
)
|
||||
let senderAllowed = senderBucket.allow(now: now)
|
||||
senderBuckets[senderKey] = senderBucket
|
||||
/// - Parameter powBits: validated NIP-13 difficulty of the event
|
||||
/// (`NostrPoW.validatedDifficulty`; 0 for mesh or no-PoW events).
|
||||
/// At or above `NostrPoW.rateLimitBypassBits` the per-sender bucket is
|
||||
/// skipped entirely — each such message paid for itself with work — but
|
||||
/// the per-content flood bucket still applies.
|
||||
mutating func allow(senderKey: String, contentKey: String, powBits: Int = 0, now: Date = Date()) -> Bool {
|
||||
let senderAllowed: Bool
|
||||
if powBits >= NostrPoW.rateLimitBypassBits {
|
||||
senderAllowed = true
|
||||
} else {
|
||||
var senderBucket = senderBuckets[senderKey] ?? TokenBucket(
|
||||
capacity: senderCapacity,
|
||||
tokens: senderCapacity,
|
||||
refillPerSec: senderRefill,
|
||||
lastRefill: now
|
||||
)
|
||||
senderAllowed = senderBucket.allow(now: now)
|
||||
senderBuckets[senderKey] = senderBucket
|
||||
}
|
||||
|
||||
var contentBucket = contentBuckets[contentKey] ?? TokenBucket(
|
||||
capacity: contentCapacity,
|
||||
|
||||
@@ -32,7 +32,10 @@ protocol NostrInboundPipelineContext: AnyObject {
|
||||
func recordGeoParticipant(pubkeyHex: String)
|
||||
|
||||
// MARK: Inbound public messages
|
||||
func handlePublicMessage(_ message: BitchatMessage)
|
||||
/// `powBits` is the validated NIP-13 difficulty of the source event
|
||||
/// (`NostrPoW.validatedDifficulty`); it relaxes the per-sender rate limit
|
||||
/// downstream.
|
||||
func handlePublicMessage(_ message: BitchatMessage, powBits: Int)
|
||||
func checkForMentions(_ message: BitchatMessage)
|
||||
func sendHapticFeedback(for message: BitchatMessage)
|
||||
func parseMentions(from content: String) -> [String]
|
||||
@@ -152,6 +155,7 @@ final class NostrInboundPipeline {
|
||||
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||
let timestamp = min(rawTs, Date())
|
||||
let mentions = context.parseMentions(from: content)
|
||||
let powBits = NostrPoW.validatedDifficulty(idHex: event.id, tags: event.tags)
|
||||
let message = BitchatMessage(
|
||||
id: event.id,
|
||||
sender: senderName,
|
||||
@@ -165,7 +169,7 @@ final class NostrInboundPipeline {
|
||||
Task { @MainActor [weak context] in
|
||||
guard let context else { return }
|
||||
let isBlocked = context.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased())
|
||||
context.handlePublicMessage(message)
|
||||
context.handlePublicMessage(message, powBits: powBits)
|
||||
if !isBlocked {
|
||||
context.checkForMentions(message)
|
||||
context.sendHapticFeedback(for: message)
|
||||
@@ -187,10 +191,12 @@ final class NostrInboundPipeline {
|
||||
guard event.isValidSignature() else { return }
|
||||
context.recordProcessedNostrEvent(event.id)
|
||||
|
||||
let powBits = NostrPoW.validatedDifficulty(idHex: event.id, tags: event.tags)
|
||||
|
||||
// Sampled: fires for every geo event and floods dev logs in busy geohashes.
|
||||
geoEventLogCount += 1
|
||||
if geoEventLogCount == 1 || geoEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) {
|
||||
SecureLogger.debug("GeoTeleport: recv #\(geoEventLogCount) pub=\(event.pubkey.prefix(8))… tags=\(event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ","))", category: .session)
|
||||
SecureLogger.debug("GeoTeleport: recv #\(geoEventLogCount) pub=\(event.pubkey.prefix(8))… pow=\(powBits) tags=\(event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ","))", category: .session)
|
||||
}
|
||||
|
||||
if context.isNostrBlocked(pubkeyHexLowercased: event.pubkey) {
|
||||
@@ -255,7 +261,7 @@ final class NostrInboundPipeline {
|
||||
|
||||
Task { @MainActor [weak context] in
|
||||
guard let context else { return }
|
||||
context.handlePublicMessage(message)
|
||||
context.handlePublicMessage(message, powBits: powBits)
|
||||
context.checkForMentions(message)
|
||||
context.sendHapticFeedback(for: message)
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ private final class MockChatNostrContext: ChatNostrContext {
|
||||
private(set) var hapticMessageIDs: [String] = []
|
||||
|
||||
func handlePublicMessage(_ message: BitchatMessage) { handledPublicMessages.append(message) }
|
||||
func handlePublicMessage(_ message: BitchatMessage, powBits: Int) { handledPublicMessages.append(message) }
|
||||
func checkForMentions(_ message: BitchatMessage) { mentionCheckedMessageIDs.append(message.id) }
|
||||
func sendHapticFeedback(for message: BitchatMessage) { hapticMessageIDs.append(message.id) }
|
||||
func parseMentions(from content: String) -> [String] { [] }
|
||||
|
||||
@@ -192,6 +192,9 @@ struct ChatOutgoingCoordinatorContextTests {
|
||||
context.isTeleported = true
|
||||
|
||||
coordinator.sendMessage("hello geo")
|
||||
// Geohash sends mine a NIP-13 nonce tag off-main before echoing and
|
||||
// sending; await the send task, then drain the main queue.
|
||||
await coordinator.geohashMiningTask?.value
|
||||
await drainMainActorTasks()
|
||||
|
||||
// Local echo carries the geohash sender suffix (#last-4-of-pubkey) and
|
||||
@@ -215,4 +218,35 @@ struct ChatOutgoingCoordinatorContextTests {
|
||||
#expect(context.appendedPublicMessages.count == 1)
|
||||
#expect(context.sentGeohashContexts.count == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendMessage_onLocationChannel_serializesRapidSendsInSendOrder() async {
|
||||
let context = MockChatOutgoingContext()
|
||||
let coordinator = ChatOutgoingCoordinator(context: context)
|
||||
let channel = GeohashChannel(level: .city, geohash: "u4pruydq")
|
||||
context.activeChannel = .location(channel)
|
||||
|
||||
// Two back-to-back sends. The first carries much larger content, so
|
||||
// its NIP-13 mining hashes a bigger event per attempt and runs longer
|
||||
// than the second's. Without serialization the second (faster) task
|
||||
// could finish first and reorder both the local timeline and the
|
||||
// relayed events. The coordinator chains the mining tasks — each send
|
||||
// awaits the previous send's task before it echoes and relays — so the
|
||||
// visible order must always match the send order.
|
||||
let first = "first " + String(repeating: "x", count: 4000)
|
||||
let second = "second"
|
||||
coordinator.sendMessage(first)
|
||||
coordinator.sendMessage(second)
|
||||
|
||||
// The stored task is the second send, which awaits the first.
|
||||
await coordinator.geohashMiningTask?.value
|
||||
await drainMainActorTasks()
|
||||
|
||||
// Local echoes land in send order…
|
||||
#expect(context.appendedPublicMessages.map(\.message.content) == [first, second])
|
||||
// …and so do the relayed events (IDs match the echoes 1:1, in order).
|
||||
#expect(context.sentGeohashContexts.count == 2)
|
||||
#expect(context.sentGeohashContexts.map(\.event.id)
|
||||
== context.appendedPublicMessages.map(\.message.id))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,7 +186,7 @@ private final class MockChatPublicConversationContext: ChatPublicConversationCon
|
||||
// Inbound public message processing
|
||||
var blockedMessageIDs: Set<String> = []
|
||||
var rateLimitAllowed = true
|
||||
private(set) var rateLimitChecks: [(senderKey: String, contentKey: String)] = []
|
||||
private(set) var rateLimitChecks: [(senderKey: String, contentKey: String, powBits: Int)] = []
|
||||
private(set) var enqueuedMessages: [(messageID: String, conversationID: ConversationID)] = []
|
||||
var enqueuedMessageIDs: [String] { enqueuedMessages.map(\.messageID) }
|
||||
var stablePeerIDs: [PeerID: PeerID] = [:]
|
||||
@@ -199,8 +199,8 @@ private final class MockChatPublicConversationContext: ChatPublicConversationCon
|
||||
blockedMessageIDs.contains(message.id)
|
||||
}
|
||||
|
||||
func allowPublicMessage(senderKey: String, contentKey: String) -> Bool {
|
||||
rateLimitChecks.append((senderKey, contentKey))
|
||||
func allowPublicMessage(senderKey: String, contentKey: String, powBits: Int) -> Bool {
|
||||
rateLimitChecks.append((senderKey, contentKey, powBits))
|
||||
return rateLimitAllowed
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
//
|
||||
// MessageRateLimiterTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Tests for the public-intake token buckets, including the NIP-13
|
||||
// proof-of-work relaxation of the per-sender bucket.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
struct MessageRateLimiterTests {
|
||||
|
||||
private func makeLimiter(
|
||||
senderCapacity: Double = 2,
|
||||
contentCapacity: Double = 100
|
||||
) -> MessageRateLimiter {
|
||||
MessageRateLimiter(
|
||||
senderCapacity: senderCapacity,
|
||||
senderRefillPerSec: 0.0001,
|
||||
contentCapacity: contentCapacity,
|
||||
contentRefillPerSec: 0.0001
|
||||
)
|
||||
}
|
||||
|
||||
@Test func senderBucketBlocksAfterCapacity() {
|
||||
var limiter = makeLimiter()
|
||||
let now = Date()
|
||||
|
||||
let first = limiter.allow(senderKey: "s", contentKey: "c1", now: now)
|
||||
let second = limiter.allow(senderKey: "s", contentKey: "c2", now: now)
|
||||
let third = limiter.allow(senderKey: "s", contentKey: "c3", now: now)
|
||||
let otherSender = limiter.allow(senderKey: "other", contentKey: "c4", now: now)
|
||||
|
||||
#expect(first)
|
||||
#expect(second)
|
||||
#expect(!third)
|
||||
#expect(otherSender)
|
||||
}
|
||||
|
||||
@Test func validPoWBypassesExhaustedSenderBucket() {
|
||||
var limiter = makeLimiter()
|
||||
let now = Date()
|
||||
|
||||
// Exhaust the sender bucket with plain (no-PoW) messages.
|
||||
let first = limiter.allow(senderKey: "s", contentKey: "c1", now: now)
|
||||
let second = limiter.allow(senderKey: "s", contentKey: "c2", now: now)
|
||||
let exhausted = limiter.allow(senderKey: "s", contentKey: "c3", now: now)
|
||||
|
||||
// A message carrying sufficient validated PoW still passes, and so
|
||||
// does more-than-sufficient PoW; plain messages stay blocked.
|
||||
let powExact = limiter.allow(
|
||||
senderKey: "s",
|
||||
contentKey: "c4",
|
||||
powBits: NostrPoW.rateLimitBypassBits,
|
||||
now: now
|
||||
)
|
||||
let powHigh = limiter.allow(senderKey: "s", contentKey: "c5", powBits: 20, now: now)
|
||||
let plainAgain = limiter.allow(senderKey: "s", contentKey: "c6", now: now)
|
||||
|
||||
#expect(first)
|
||||
#expect(second)
|
||||
#expect(!exhausted)
|
||||
#expect(powExact)
|
||||
#expect(powHigh)
|
||||
#expect(!plainAgain)
|
||||
}
|
||||
|
||||
@Test func lowPoWDoesNotBypassSenderBucket() {
|
||||
var limiter = makeLimiter(senderCapacity: 1)
|
||||
let now = Date()
|
||||
|
||||
let first = limiter.allow(senderKey: "s", contentKey: "c1", now: now)
|
||||
let lowPow = limiter.allow(
|
||||
senderKey: "s",
|
||||
contentKey: "c2",
|
||||
powBits: NostrPoW.rateLimitBypassBits - 1,
|
||||
now: now
|
||||
)
|
||||
let zeroPow = limiter.allow(senderKey: "s", contentKey: "c3", powBits: 0, now: now)
|
||||
|
||||
#expect(first)
|
||||
#expect(!lowPow)
|
||||
#expect(!zeroPow)
|
||||
}
|
||||
|
||||
@Test func powDoesNotBypassContentFloodBucket() {
|
||||
var limiter = makeLimiter(senderCapacity: 100, contentCapacity: 1)
|
||||
let now = Date()
|
||||
|
||||
let first = limiter.allow(senderKey: "a", contentKey: "same", now: now)
|
||||
// Identical content spammed with PoW is still throttled by the
|
||||
// content bucket: PoW only relaxes the per-sender limit.
|
||||
let powSameContent = limiter.allow(senderKey: "b", contentKey: "same", powBits: 20, now: now)
|
||||
let powNewContent = limiter.allow(senderKey: "b", contentKey: "different", powBits: 20, now: now)
|
||||
|
||||
#expect(first)
|
||||
#expect(!powSameContent)
|
||||
#expect(powNewContent)
|
||||
}
|
||||
|
||||
@Test func powBypassDoesNotDrainSenderBucket() {
|
||||
var limiter = makeLimiter(senderCapacity: 1)
|
||||
let now = Date()
|
||||
|
||||
// PoW messages don't consume sender tokens, so a subsequent plain
|
||||
// message still has its full budget.
|
||||
let powFirst = limiter.allow(senderKey: "s", contentKey: "c1", powBits: 20, now: now)
|
||||
let powSecond = limiter.allow(senderKey: "s", contentKey: "c2", powBits: 20, now: now)
|
||||
let plain = limiter.allow(senderKey: "s", contentKey: "c3", now: now)
|
||||
let plainExhausted = limiter.allow(senderKey: "s", contentKey: "c4", now: now)
|
||||
|
||||
#expect(powFirst)
|
||||
#expect(powSecond)
|
||||
#expect(plain)
|
||||
#expect(!plainExhausted)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
//
|
||||
// NostrPoWTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Tests for NIP-13 proof-of-work: leading-zero-bit counting, commitment
|
||||
// semantics, and nonce-tag mining for geohash (kind 20000) events.
|
||||
//
|
||||
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import Testing
|
||||
import BitFoundation
|
||||
@testable import bitchat
|
||||
|
||||
struct NostrPoWTests {
|
||||
|
||||
// MARK: - Leading zero bits
|
||||
|
||||
@Test func leadingZeroBitsVectors() {
|
||||
#expect(NostrPoW.leadingZeroBits(Data()) == 0)
|
||||
#expect(NostrPoW.leadingZeroBits(Data([0x80])) == 0)
|
||||
#expect(NostrPoW.leadingZeroBits(Data([0xFF, 0x00])) == 0)
|
||||
#expect(NostrPoW.leadingZeroBits(Data([0x40])) == 1)
|
||||
#expect(NostrPoW.leadingZeroBits(Data([0x01])) == 7)
|
||||
#expect(NostrPoW.leadingZeroBits(Data([0x00, 0x00, 0xF0])) == 16)
|
||||
#expect(NostrPoW.leadingZeroBits(Data(repeating: 0x00, count: 32)) == 256)
|
||||
}
|
||||
|
||||
@Test func leadingZeroBitsExactByteBoundaries() {
|
||||
// Zero byte contributes exactly 8, then the next byte decides.
|
||||
#expect(NostrPoW.leadingZeroBits(Data([0x00, 0xFF])) == 8)
|
||||
#expect(NostrPoW.leadingZeroBits(Data([0x00, 0x80])) == 8)
|
||||
#expect(NostrPoW.leadingZeroBits(Data([0x00, 0x7F])) == 9)
|
||||
#expect(NostrPoW.leadingZeroBits(Data([0x00, 0x01])) == 15)
|
||||
#expect(NostrPoW.leadingZeroBits(Data([0x00, 0x00, 0x01])) == 23)
|
||||
}
|
||||
|
||||
@Test func leadingZeroBitsMatchesNIP13ExampleVector() throws {
|
||||
// Worked example from the NIP-13 spec: this event ID has 36 leading
|
||||
// zero bits.
|
||||
let idHex = "000000000e9d97a1ab09fc381030b346cdd7a142ad57e6df0b46dc9bef6c7e2d"
|
||||
let idData = try #require(Data(hexString: idHex))
|
||||
#expect(NostrPoW.leadingZeroBits(idData) == 36)
|
||||
}
|
||||
|
||||
// MARK: - Commitment semantics
|
||||
|
||||
/// An ID with exactly 16 leading zero bits.
|
||||
private let id16 = "0000f000" + String(repeating: "ab", count: 28)
|
||||
|
||||
@Test func committedTargetCountsNotActualDifficulty() {
|
||||
// Claimed < actual: only the committed target is credited, so lucky
|
||||
// extra zeroes earn nothing beyond the commitment.
|
||||
let tags = [["g", "u4pruy"], ["nonce", "12345", "8"]]
|
||||
#expect(NostrPoW.validatedDifficulty(idHex: id16, tags: tags) == 8)
|
||||
}
|
||||
|
||||
@Test func unmetCommitmentScoresZero() {
|
||||
// Actual < claimed: the commitment is not met, so the claim is void.
|
||||
let tags = [["nonce", "12345", "24"]]
|
||||
#expect(NostrPoW.validatedDifficulty(idHex: id16, tags: tags) == 0)
|
||||
}
|
||||
|
||||
@Test func exactCommitmentIsCredited() {
|
||||
let tags = [["nonce", "12345", "16"]]
|
||||
#expect(NostrPoW.validatedDifficulty(idHex: id16, tags: tags) == 16)
|
||||
}
|
||||
|
||||
@Test func missingOrMalformedNonceTagScoresZero() {
|
||||
// No nonce tag at all: leading zeroes without a commitment earn no
|
||||
// credit (old clients simply keep the strict rate limits).
|
||||
#expect(NostrPoW.validatedDifficulty(idHex: id16, tags: [["g", "u4pruy"]]) == 0)
|
||||
// Nonce tag without a committed target.
|
||||
#expect(NostrPoW.validatedDifficulty(idHex: id16, tags: [["nonce", "12345"]]) == 0)
|
||||
// Non-numeric or nonsensical targets.
|
||||
#expect(NostrPoW.validatedDifficulty(idHex: id16, tags: [["nonce", "1", "high"]]) == 0)
|
||||
#expect(NostrPoW.validatedDifficulty(idHex: id16, tags: [["nonce", "1", "0"]]) == 0)
|
||||
#expect(NostrPoW.validatedDifficulty(idHex: id16, tags: [["nonce", "1", "-4"]]) == 0)
|
||||
#expect(NostrPoW.validatedDifficulty(idHex: id16, tags: [["nonce", "1", "400"]]) == 0)
|
||||
// Malformed event ID.
|
||||
#expect(NostrPoW.validatedDifficulty(idHex: "not-hex", tags: [["nonce", "1", "8"]]) == 0)
|
||||
}
|
||||
|
||||
// MARK: - Mining
|
||||
|
||||
@Test func minedNonceTagMeetsCommittedDifficulty() async throws {
|
||||
let pubkey = String(repeating: "a", count: 64)
|
||||
let createdAt = 1_700_000_000
|
||||
let baseTags = [["g", "u4pruydq"], ["n", "tester"]]
|
||||
let content = "hello pow"
|
||||
|
||||
let nonceTag = try #require(await NostrPoW.mineNonceTag(
|
||||
pubkey: pubkey,
|
||||
createdAt: createdAt,
|
||||
kind: 20000,
|
||||
tags: baseTags,
|
||||
content: content,
|
||||
targetBits: 8
|
||||
))
|
||||
|
||||
#expect(nonceTag.count == 3)
|
||||
#expect(nonceTag.first == "nonce")
|
||||
#expect(nonceTag[2] == "8")
|
||||
|
||||
// Recompute the canonical NIP-01 event ID with the mined tag appended
|
||||
// and verify the committed difficulty is genuinely met.
|
||||
let idData = try Self.eventIDHash(
|
||||
pubkey: pubkey,
|
||||
createdAt: createdAt,
|
||||
kind: 20000,
|
||||
tags: baseTags + [nonceTag],
|
||||
content: content
|
||||
)
|
||||
#expect(NostrPoW.leadingZeroBits(idData) >= 8)
|
||||
let idHex = idData.map { String(format: "%02x", $0) }.joined()
|
||||
#expect(NostrPoW.validatedDifficulty(idHex: idHex, tags: baseTags + [nonceTag]) == 8)
|
||||
}
|
||||
|
||||
@Test func miningSurvivesContentThatNeedsEscaping() async throws {
|
||||
// The in-place template mutation must stay correct when the content
|
||||
// gets JSON-escaped — including content that contains hex runs that
|
||||
// look exactly like the internal nonce placeholder.
|
||||
let pubkey = String(repeating: "b", count: 64)
|
||||
let createdAt = 1_700_000_123
|
||||
let content = "she said \"hi\"\n0000000000000000 / ffffffffffffffff 😀\\"
|
||||
let baseTags = [["g", "9q8yy"]]
|
||||
|
||||
let nonceTag = try #require(await NostrPoW.mineNonceTag(
|
||||
pubkey: pubkey,
|
||||
createdAt: createdAt,
|
||||
kind: 20000,
|
||||
tags: baseTags,
|
||||
content: content,
|
||||
targetBits: 4
|
||||
))
|
||||
|
||||
let idData = try Self.eventIDHash(
|
||||
pubkey: pubkey,
|
||||
createdAt: createdAt,
|
||||
kind: 20000,
|
||||
tags: baseTags + [nonceTag],
|
||||
content: content
|
||||
)
|
||||
#expect(NostrPoW.leadingZeroBits(idData) >= 4)
|
||||
}
|
||||
|
||||
@Test func minedGeohashEventValidatesEndToEnd() async throws {
|
||||
let identity = try NostrIdentity.generate()
|
||||
let event = try await NostrProtocol.createMinedEphemeralGeohashEvent(
|
||||
content: "hello from a mined event",
|
||||
geohash: "u4pruydq",
|
||||
senderIdentity: identity,
|
||||
nickname: "miner",
|
||||
teleported: false
|
||||
)
|
||||
|
||||
// The signed event's own ID (recomputed by sign()) carries the work.
|
||||
#expect(event.isValidSignature())
|
||||
let idData = try #require(Data(hexString: event.id))
|
||||
#expect(NostrPoW.leadingZeroBits(idData) >= NostrPoW.targetBits)
|
||||
#expect(NostrPoW.validatedDifficulty(idHex: event.id, tags: event.tags) == NostrPoW.targetBits)
|
||||
|
||||
// Mining must not disturb the regular geohash tags.
|
||||
#expect(event.tags.contains(["g", "u4pruydq"]))
|
||||
#expect(event.tags.contains(["n", "miner"]))
|
||||
#expect(event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue)
|
||||
}
|
||||
|
||||
@Test func cancelledMiningStillProducesHonestCommitment() async throws {
|
||||
// Cancelling the surrounding task expedites mining: it steps the
|
||||
// committed target down and still returns a tag whose commitment the
|
||||
// hash actually meets — the message is never dropped or dishonest.
|
||||
let pubkey = String(repeating: "c", count: 64)
|
||||
let createdAt = 1_700_000_456
|
||||
let baseTags = [["g", "gbsuv"]]
|
||||
let content = "expedited"
|
||||
|
||||
let miningTask = Task {
|
||||
await NostrPoW.mineNonceTag(
|
||||
pubkey: pubkey,
|
||||
createdAt: createdAt,
|
||||
kind: 20000,
|
||||
tags: baseTags,
|
||||
content: content,
|
||||
targetBits: 240 // unreachable: forces the cap/cancel path
|
||||
)
|
||||
}
|
||||
miningTask.cancel()
|
||||
|
||||
let nonceTag = try #require(await miningTask.value)
|
||||
let committed = try #require(Int(nonceTag[2]))
|
||||
#expect(committed >= 0)
|
||||
#expect(committed < 240)
|
||||
|
||||
if committed > 0 {
|
||||
let idData = try Self.eventIDHash(
|
||||
pubkey: pubkey,
|
||||
createdAt: createdAt,
|
||||
kind: 20000,
|
||||
tags: baseTags + [nonceTag],
|
||||
content: content
|
||||
)
|
||||
#expect(NostrPoW.leadingZeroBits(idData) >= committed)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// Canonical NIP-01 event ID hash, computed independently of the
|
||||
/// production code path.
|
||||
private static func eventIDHash(
|
||||
pubkey: String,
|
||||
createdAt: Int,
|
||||
kind: Int,
|
||||
tags: [[String]],
|
||||
content: String
|
||||
) throws -> Data {
|
||||
let serialized: [Any] = [0, pubkey, createdAt, kind, tags, content]
|
||||
let json = try JSONSerialization.data(withJSONObject: serialized, options: [.withoutEscapingSlashes])
|
||||
return Data(SHA256.hash(data: json))
|
||||
}
|
||||
}
|
||||
@@ -611,6 +611,7 @@ private final class PerfNostrContext: ChatNostrContext {
|
||||
|
||||
private(set) var handledPublicMessageCount = 0
|
||||
func handlePublicMessage(_ message: BitchatMessage) { handledPublicMessageCount += 1 }
|
||||
func handlePublicMessage(_ message: BitchatMessage, powBits: Int) { handledPublicMessageCount += 1 }
|
||||
func checkForMentions(_ message: BitchatMessage) {}
|
||||
func sendHapticFeedback(for message: BitchatMessage) {}
|
||||
func parseMentions(from content: String) -> [String] {
|
||||
|
||||
Reference in New Issue
Block a user