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