mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 16:05:19 +00:00
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>
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,
|
nickname: String? = nil,
|
||||||
teleported: Bool = false
|
teleported: Bool = false
|
||||||
) throws -> NostrEvent {
|
) 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]]
|
var tags = [["g", geohash]]
|
||||||
if let nickname = nickname?.trimmedOrNilIfEmpty {
|
if let nickname = nickname?.trimmedOrNilIfEmpty {
|
||||||
tags.append(["n", nickname])
|
tags.append(["n", nickname])
|
||||||
@@ -177,15 +234,7 @@ struct NostrProtocol {
|
|||||||
if teleported {
|
if teleported {
|
||||||
tags.append(["t", "teleport"])
|
tags.append(["t", "teleport"])
|
||||||
}
|
}
|
||||||
let event = NostrEvent(
|
return tags
|
||||||
pubkey: senderIdentity.publicKeyHex,
|
|
||||||
createdAt: Date(),
|
|
||||||
kind: .ephemeralEvent,
|
|
||||||
tags: tags,
|
|
||||||
content: content
|
|
||||||
)
|
|
||||||
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
|
||||||
return try event.sign(with: schnorrKey)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a geohash presence heartbeat (kind 20001)
|
/// Create a geohash presence heartbeat (kind 20001)
|
||||||
|
|||||||
@@ -324,7 +324,7 @@ private extension ChatLifecycleCoordinator {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash)
|
let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash)
|
||||||
let event = try NostrProtocol.createEphemeralGeohashEvent(
|
let event = try await NostrProtocol.createMinedEphemeralGeohashEvent(
|
||||||
content: message,
|
content: message,
|
||||||
geohash: channel.geohash,
|
geohash: channel.geohash,
|
||||||
senderIdentity: identity,
|
senderIdentity: identity,
|
||||||
|
|||||||
@@ -68,10 +68,22 @@ extension ChatViewModel: ChatOutgoingContext {
|
|||||||
final class ChatOutgoingCoordinator {
|
final class ChatOutgoingCoordinator {
|
||||||
private unowned let context: any ChatOutgoingContext
|
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) {
|
init(context: any ChatOutgoingContext) {
|
||||||
self.context = context
|
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) {
|
func sendMessage(_ content: String) {
|
||||||
guard let trimmed = content.trimmedOrNilIfEmpty else { return }
|
guard let trimmed = content.trimmedOrNilIfEmpty else { return }
|
||||||
|
|
||||||
@@ -92,120 +104,109 @@ final class ChatOutgoingCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mentions = context.parseMentions(from: content)
|
let mentions = context.parseMentions(from: content)
|
||||||
let preparedMessage = preparePublicMessage(content: content, trimmed: trimmed, mentions: mentions)
|
|
||||||
guard let preparedMessage else { return }
|
|
||||||
|
|
||||||
appendLocalEcho(preparedMessage.message)
|
switch context.activeChannel {
|
||||||
routePublicMessage(
|
case .mesh:
|
||||||
originalContent: content,
|
sendMeshPublicMessage(originalContent: content, trimmed: trimmed, mentions: mentions)
|
||||||
mentions: mentions,
|
case .location(let channel):
|
||||||
geoContext: preparedMessage.geoContext,
|
sendGeohashPublicMessage(trimmed, mentions: mentions, channel: channel)
|
||||||
messageID: preparedMessage.message.id,
|
}
|
||||||
timestamp: preparedMessage.message.timestamp
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension ChatOutgoingCoordinator {
|
private extension ChatOutgoingCoordinator {
|
||||||
func preparePublicMessage(
|
func sendMeshPublicMessage(originalContent: String, trimmed: String, mentions: [String]) {
|
||||||
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()
|
|
||||||
|
|
||||||
switch context.activeChannel {
|
|
||||||
case .mesh:
|
|
||||||
break
|
|
||||||
|
|
||||||
case .location(let channel):
|
|
||||||
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(
|
|
||||||
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,
|
|
||||||
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(
|
let message = BitchatMessage(
|
||||||
id: messageID,
|
sender: context.nickname,
|
||||||
sender: displaySender,
|
|
||||||
content: trimmed,
|
content: trimmed,
|
||||||
timestamp: messageTimestamp,
|
timestamp: Date(),
|
||||||
isRelay: false,
|
isRelay: false,
|
||||||
senderPeerID: localSenderPeerID,
|
senderPeerID: context.myPeerID,
|
||||||
mentions: mentions.isEmpty ? nil : mentions
|
mentions: mentions.isEmpty ? nil : mentions
|
||||||
)
|
)
|
||||||
|
|
||||||
return (message, geoContext)
|
appendLocalEcho(message, to: .mesh)
|
||||||
}
|
|
||||||
|
|
||||||
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.recordPublicActivity(forChannelKey: "mesh")
|
||||||
context.sendMeshMessage(
|
context.sendMeshMessage(
|
||||||
originalContent,
|
originalContent,
|
||||||
mentions: mentions,
|
mentions: mentions,
|
||||||
messageID: messageID,
|
messageID: message.id,
|
||||||
timestamp: timestamp
|
timestamp: message.timestamp
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
case .location(let channel):
|
/// Geohash sends mine a NIP-13 nonce tag first (off the main actor, see
|
||||||
context.recordPublicActivity(forChannelKey: "geo:\(channel.geohash)")
|
/// `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
|
||||||
guard let geoContext, geoContext.channel.geohash == channel.geohash else {
|
/// at the default target is well under 100 ms and hard-capped at
|
||||||
SecureLogger.error("Geo: missing send context for \(channel.geohash)", category: .session)
|
/// `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(
|
context.addSystemMessage(
|
||||||
String(localized: "system.location.send_failed", comment: "System message when a location channel send fails")
|
String(localized: "system.location.send_failed", comment: "System message when a location channel send fails")
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
Task { @MainActor [weak context = self.context] in
|
let displaySender = context.nickname + "#" + String(identity.publicKeyHex.suffix(4))
|
||||||
context?.sendGeohash(context: geoContext)
|
let senderPeerID = PeerID(nostr: identity.publicKeyHex)
|
||||||
|
let teleported = context.isTeleported
|
||||||
|
let nickname = context.nickname
|
||||||
|
|
||||||
|
// A newer send expedites the previous one's mining so sends keep
|
||||||
|
// their order and mining delays never stack.
|
||||||
|
geohashMiningTask?.cancel()
|
||||||
|
geohashMiningTask = Task { @MainActor [weak context = self.context] in
|
||||||
|
let event: NostrEvent
|
||||||
|
do {
|
||||||
|
event = try await NostrProtocol.createMinedEphemeralGeohashEvent(
|
||||||
|
content: trimmed,
|
||||||
|
geohash: channel.geohash,
|
||||||
|
senderIdentity: 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
|
||||||
|
}
|
||||||
|
guard let context else { return }
|
||||||
|
|
||||||
|
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
|
// MARK: Inbound public message processing
|
||||||
func processActionMessage(_ message: BitchatMessage) -> BitchatMessage
|
func processActionMessage(_ message: BitchatMessage) -> BitchatMessage
|
||||||
func isMessageBlocked(_ message: BitchatMessage) -> Bool
|
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
|
/// Buffers a visible-channel message for the batched (~80 ms) pipeline
|
||||||
/// flush, which commits it to `conversationID` in the store.
|
/// flush, which commits it to `conversationID` in the store.
|
||||||
func enqueuePublicMessage(_ message: BitchatMessage, to conversationID: ConversationID)
|
func enqueuePublicMessage(_ message: BitchatMessage, to conversationID: ConversationID)
|
||||||
@@ -137,8 +139,8 @@ extension ChatViewModel: ChatPublicConversationContext {
|
|||||||
meshService.sendMessage(content, mentions: mentions, messageID: messageID, timestamp: timestamp)
|
meshService.sendMessage(content, mentions: mentions, messageID: messageID, timestamp: timestamp)
|
||||||
}
|
}
|
||||||
|
|
||||||
func allowPublicMessage(senderKey: String, contentKey: String) -> Bool {
|
func allowPublicMessage(senderKey: String, contentKey: String, powBits: Int) -> Bool {
|
||||||
publicRateLimiter.allow(senderKey: senderKey, contentKey: contentKey)
|
publicRateLimiter.allow(senderKey: senderKey, contentKey: contentKey, powBits: powBits)
|
||||||
}
|
}
|
||||||
|
|
||||||
func enqueuePublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) {
|
func enqueuePublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) {
|
||||||
@@ -367,7 +369,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
|
|||||||
guard let context else { return }
|
guard let context else { return }
|
||||||
do {
|
do {
|
||||||
let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash)
|
let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash)
|
||||||
let event = try NostrProtocol.createEphemeralGeohashEvent(
|
let event = try await NostrProtocol.createMinedEphemeralGeohashEvent(
|
||||||
content: content,
|
content: content,
|
||||||
geohash: channel.geohash,
|
geohash: channel.geohash,
|
||||||
senderIdentity: identity,
|
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)
|
let finalMessage = context.processActionMessage(message)
|
||||||
if context.isMessageBlocked(finalMessage) { return }
|
if context.isMessageBlocked(finalMessage) { return }
|
||||||
|
|
||||||
@@ -405,7 +411,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
|
|||||||
if shouldRateLimit {
|
if shouldRateLimit {
|
||||||
let senderKey = normalizedSenderKey(for: finalMessage)
|
let senderKey = normalizedSenderKey(for: finalMessage)
|
||||||
let contentKey = context.normalizedContentKey(finalMessage.content)
|
let contentKey = context.normalizedContentKey(finalMessage.content)
|
||||||
if !context.allowPublicMessage(senderKey: senderKey, contentKey: contentKey) {
|
if !context.allowPublicMessage(senderKey: senderKey, contentKey: contentKey, powBits: powBits) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -311,6 +311,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
get { conversations.activeChannel }
|
get { conversations.activeChannel }
|
||||||
set {
|
set {
|
||||||
guard conversations.activeChannel != newValue else { return }
|
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)
|
conversations.setActiveChannel(newValue)
|
||||||
visibleMessagesCache = nil
|
visibleMessagesCache = nil
|
||||||
objectWillChange.send()
|
objectWillChange.send()
|
||||||
@@ -1683,6 +1686,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
publicConversationCoordinator.handlePublicMessage(message)
|
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
|
/// Check for mentions and send notifications
|
||||||
func checkForMentions(_ message: BitchatMessage) {
|
func checkForMentions(_ message: BitchatMessage) {
|
||||||
publicConversationCoordinator.checkForMentions(message)
|
publicConversationCoordinator.checkForMentions(message)
|
||||||
|
|||||||
@@ -48,15 +48,25 @@ struct MessageRateLimiter {
|
|||||||
self.contentRefill = contentRefillPerSec
|
self.contentRefill = contentRefillPerSec
|
||||||
}
|
}
|
||||||
|
|
||||||
mutating func allow(senderKey: String, contentKey: String, now: Date = Date()) -> Bool {
|
/// - 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(
|
var senderBucket = senderBuckets[senderKey] ?? TokenBucket(
|
||||||
capacity: senderCapacity,
|
capacity: senderCapacity,
|
||||||
tokens: senderCapacity,
|
tokens: senderCapacity,
|
||||||
refillPerSec: senderRefill,
|
refillPerSec: senderRefill,
|
||||||
lastRefill: now
|
lastRefill: now
|
||||||
)
|
)
|
||||||
let senderAllowed = senderBucket.allow(now: now)
|
senderAllowed = senderBucket.allow(now: now)
|
||||||
senderBuckets[senderKey] = senderBucket
|
senderBuckets[senderKey] = senderBucket
|
||||||
|
}
|
||||||
|
|
||||||
var contentBucket = contentBuckets[contentKey] ?? TokenBucket(
|
var contentBucket = contentBuckets[contentKey] ?? TokenBucket(
|
||||||
capacity: contentCapacity,
|
capacity: contentCapacity,
|
||||||
|
|||||||
@@ -32,7 +32,10 @@ protocol NostrInboundPipelineContext: AnyObject {
|
|||||||
func recordGeoParticipant(pubkeyHex: String)
|
func recordGeoParticipant(pubkeyHex: String)
|
||||||
|
|
||||||
// MARK: Inbound public messages
|
// 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 checkForMentions(_ message: BitchatMessage)
|
||||||
func sendHapticFeedback(for message: BitchatMessage)
|
func sendHapticFeedback(for message: BitchatMessage)
|
||||||
func parseMentions(from content: String) -> [String]
|
func parseMentions(from content: String) -> [String]
|
||||||
@@ -152,6 +155,7 @@ final class NostrInboundPipeline {
|
|||||||
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||||
let timestamp = min(rawTs, Date())
|
let timestamp = min(rawTs, Date())
|
||||||
let mentions = context.parseMentions(from: content)
|
let mentions = context.parseMentions(from: content)
|
||||||
|
let powBits = NostrPoW.validatedDifficulty(idHex: event.id, tags: event.tags)
|
||||||
let message = BitchatMessage(
|
let message = BitchatMessage(
|
||||||
id: event.id,
|
id: event.id,
|
||||||
sender: senderName,
|
sender: senderName,
|
||||||
@@ -165,7 +169,7 @@ final class NostrInboundPipeline {
|
|||||||
Task { @MainActor [weak context] in
|
Task { @MainActor [weak context] in
|
||||||
guard let context else { return }
|
guard let context else { return }
|
||||||
let isBlocked = context.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased())
|
let isBlocked = context.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased())
|
||||||
context.handlePublicMessage(message)
|
context.handlePublicMessage(message, powBits: powBits)
|
||||||
if !isBlocked {
|
if !isBlocked {
|
||||||
context.checkForMentions(message)
|
context.checkForMentions(message)
|
||||||
context.sendHapticFeedback(for: message)
|
context.sendHapticFeedback(for: message)
|
||||||
@@ -187,10 +191,12 @@ final class NostrInboundPipeline {
|
|||||||
guard event.isValidSignature() else { return }
|
guard event.isValidSignature() else { return }
|
||||||
context.recordProcessedNostrEvent(event.id)
|
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.
|
// Sampled: fires for every geo event and floods dev logs in busy geohashes.
|
||||||
geoEventLogCount += 1
|
geoEventLogCount += 1
|
||||||
if geoEventLogCount == 1 || geoEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) {
|
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) {
|
if context.isNostrBlocked(pubkeyHexLowercased: event.pubkey) {
|
||||||
@@ -255,7 +261,7 @@ final class NostrInboundPipeline {
|
|||||||
|
|
||||||
Task { @MainActor [weak context] in
|
Task { @MainActor [weak context] in
|
||||||
guard let context else { return }
|
guard let context else { return }
|
||||||
context.handlePublicMessage(message)
|
context.handlePublicMessage(message, powBits: powBits)
|
||||||
context.checkForMentions(message)
|
context.checkForMentions(message)
|
||||||
context.sendHapticFeedback(for: message)
|
context.sendHapticFeedback(for: message)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ private final class MockChatNostrContext: ChatNostrContext {
|
|||||||
private(set) var hapticMessageIDs: [String] = []
|
private(set) var hapticMessageIDs: [String] = []
|
||||||
|
|
||||||
func handlePublicMessage(_ message: BitchatMessage) { handledPublicMessages.append(message) }
|
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 checkForMentions(_ message: BitchatMessage) { mentionCheckedMessageIDs.append(message.id) }
|
||||||
func sendHapticFeedback(for message: BitchatMessage) { hapticMessageIDs.append(message.id) }
|
func sendHapticFeedback(for message: BitchatMessage) { hapticMessageIDs.append(message.id) }
|
||||||
func parseMentions(from content: String) -> [String] { [] }
|
func parseMentions(from content: String) -> [String] { [] }
|
||||||
|
|||||||
@@ -192,6 +192,9 @@ struct ChatOutgoingCoordinatorContextTests {
|
|||||||
context.isTeleported = true
|
context.isTeleported = true
|
||||||
|
|
||||||
coordinator.sendMessage("hello geo")
|
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()
|
await drainMainActorTasks()
|
||||||
|
|
||||||
// Local echo carries the geohash sender suffix (#last-4-of-pubkey) and
|
// Local echo carries the geohash sender suffix (#last-4-of-pubkey) and
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ private final class MockChatPublicConversationContext: ChatPublicConversationCon
|
|||||||
// Inbound public message processing
|
// Inbound public message processing
|
||||||
var blockedMessageIDs: Set<String> = []
|
var blockedMessageIDs: Set<String> = []
|
||||||
var rateLimitAllowed = true
|
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)] = []
|
private(set) var enqueuedMessages: [(messageID: String, conversationID: ConversationID)] = []
|
||||||
var enqueuedMessageIDs: [String] { enqueuedMessages.map(\.messageID) }
|
var enqueuedMessageIDs: [String] { enqueuedMessages.map(\.messageID) }
|
||||||
var stablePeerIDs: [PeerID: PeerID] = [:]
|
var stablePeerIDs: [PeerID: PeerID] = [:]
|
||||||
@@ -199,8 +199,8 @@ private final class MockChatPublicConversationContext: ChatPublicConversationCon
|
|||||||
blockedMessageIDs.contains(message.id)
|
blockedMessageIDs.contains(message.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func allowPublicMessage(senderKey: String, contentKey: String) -> Bool {
|
func allowPublicMessage(senderKey: String, contentKey: String, powBits: Int) -> Bool {
|
||||||
rateLimitChecks.append((senderKey, contentKey))
|
rateLimitChecks.append((senderKey, contentKey, powBits))
|
||||||
return rateLimitAllowed
|
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
|
private(set) var handledPublicMessageCount = 0
|
||||||
func handlePublicMessage(_ message: BitchatMessage) { handledPublicMessageCount += 1 }
|
func handlePublicMessage(_ message: BitchatMessage) { handledPublicMessageCount += 1 }
|
||||||
|
func handlePublicMessage(_ message: BitchatMessage, powBits: Int) { handledPublicMessageCount += 1 }
|
||||||
func checkForMentions(_ message: BitchatMessage) {}
|
func checkForMentions(_ message: BitchatMessage) {}
|
||||||
func sendHapticFeedback(for message: BitchatMessage) {}
|
func sendHapticFeedback(for message: BitchatMessage) {}
|
||||||
func parseMentions(from content: String) -> [String] {
|
func parseMentions(from content: String) -> [String] {
|
||||||
|
|||||||
Reference in New Issue
Block a user