mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 23:45:18 +00:00
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>
120 lines
4.1 KiB
Swift
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)
|
|
}
|
|
}
|