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
@@ -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