mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 17:45:19 +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>
223 lines
8.6 KiB
Swift
223 lines
8.6 KiB
Swift
//
|
|
// 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))
|
|
}
|
|
}
|