mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 21:45:20 +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> * 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>
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)
|
|
}
|
|
}
|