Files
bitchat/bitchatTests/MessageRateLimiterTests.swift
ede6368296 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>
2026-07-07 14:12:37 +02:00

120 lines
4.1 KiB
Swift

//
// 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)
}
}