mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 14:05:18 +00:00
Merge remote-tracking branch 'origin/feat/geohash-pow' into feat/integration-all
This commit is contained in:
@@ -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