NIP-13 proof-of-work for geohash channels: mine on send, relax rate limits for PoW senders (#1382)

* NIP-13 proof-of-work for geohash channels: mine on send, relax rate limits for PoW senders

Outgoing kind-20000 geohash messages mine a NIP-13 nonce tag (8 leading
zero bits, ~256 hashes, typically <1 ms) off the main actor before
signing. Mining is hard-capped at 2 s and cancellable (newer send or
channel switch): on cap/cancel the committed target steps down so the
message still ships promptly with an honest commitment - sending is
never blocked and nothing is dropped. The hot loop serializes the
canonical event once and rewrites only the fixed-width nonce bytes.

Inbound kind-20000 events are scored per NIP-13 commitment semantics
(committed target counts; the ID must actually meet it, extra work
earns nothing) and never hard-rejected: validated PoW >= 8 bits skips
the per-sender rate-limit bucket while the per-content flood bucket
still applies, so old non-mining clients keep working under today's
strict limits while bulk spam gets expensive.

Presence heartbeats (kind 20001), kind-1 notes, and DMs are unchanged;
no UI beyond a pow= field in an existing sampled debug log.

Reimplemented from scratch rather than cherry-picking the stale
feature/pow-geohash-mining-ui branch (unbounded loop, hard receive
filtering, mining UI, XCTest, force unwraps).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Geohash: serialize PoW sends so order matches send order

Two location-channel sends back-to-back only cancelled the previous
mining task and started a new one. Cancellation merely *expedites* NIP-13
mining (the target is polled and steps down; it never aborts the send),
so the cancelled task still appended + relayed once mining returned. Both
tasks ran concurrently and the second (shorter to mine) could finish
first, reordering messages in the timeline and on relays.

Chain the mining tasks: each geohash send captures the previous send's
task, cancels it (to expedite, so delays never stack), and awaits its
completion before it echoes and relays. Order is now always send order.
The >2s mining cap is preserved: cancellation expedites the awaited task,
so a send is never blocked beyond NostrPoW.miningTimeCap.

Test: two rapid sends where the first mines longer (larger content) still
land in send order for both the local echo and the relayed events.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-07-07 14:12:37 +02:00
committed by GitHub
co-authored by jack Claude Fable 5
parent 276cde44e7
commit ede6368296
14 changed files with 808 additions and 126 deletions
+215
View File
@@ -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))
}
}
+58 -9
View File
@@ -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)
@@ -324,7 +324,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,
+103 -94
View File
@@ -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) {
@@ -367,7 +369,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,
@@ -395,7 +397,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 }
@@ -405,7 +411,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
}
}
+10
View File
@@ -311,6 +311,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()
@@ -1683,6 +1686,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)
+19 -9
View File
@@ -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,
+10 -4
View File
@@ -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
}
+119
View File
@@ -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)
}
}
+222
View File
@@ -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] {