mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 22:45:20 +00:00
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>
This commit is contained in:
co-authored by
jack
Claude Fable 5
parent
276cde44e7
commit
ede6368296
@@ -0,0 +1,215 @@
|
||||
import BitFoundation
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
/// NIP-13 proof-of-work for Nostr events.
|
||||
///
|
||||
/// Outgoing kind-20000 geohash messages mine a `["nonce", "<value>", "<target>"]`
|
||||
/// tag so the event ID carries at least `target` leading zero bits. Inbound
|
||||
/// events are scored (never hard-rejected — the network has clients that do
|
||||
/// not mine): validated PoW at or above `rateLimitBypassBits` relaxes the
|
||||
/// per-sender public rate limit, everything else keeps the strict limits.
|
||||
enum NostrPoW {
|
||||
|
||||
// MARK: - Tuning
|
||||
|
||||
/// Difficulty (leading zero bits of the event ID) mined onto outgoing
|
||||
/// geohash messages. 8 bits is ~256 hash attempts — typically well under
|
||||
/// 100 ms on any supported device.
|
||||
static let targetBits = 8
|
||||
|
||||
/// Inbound events whose validated NIP-13 difficulty is at least this many
|
||||
/// bits skip the per-sender rate-limit bucket (the content-flood bucket
|
||||
/// still applies). See `MessageRateLimiter.allow`.
|
||||
static let rateLimitBypassBits = 8
|
||||
|
||||
/// Hard cap on mining wall-clock time. When it hits, the committed target
|
||||
/// steps down until a difficulty reachable in a small extra budget is
|
||||
/// found and the message is sent anyway — mining never blocks sending.
|
||||
static let miningTimeCap: TimeInterval = 2.0
|
||||
|
||||
/// Budget for each stepped-down attempt after the main cap (or a task
|
||||
/// cancellation) hits.
|
||||
private static let fallbackTimeCap: TimeInterval = 0.15
|
||||
|
||||
/// The hot loop checks the deadline and task cancellation every this many
|
||||
/// hash attempts.
|
||||
private static let checkInterval: UInt64 = 1024
|
||||
|
||||
/// The nonce value is a fixed-width hex counter so the serialized event
|
||||
/// template can be mutated in place without reallocation.
|
||||
private static let nonceLength = 16
|
||||
|
||||
// MARK: - Scoring
|
||||
|
||||
/// Number of leading zero bits in a byte sequence (NIP-13 difficulty of
|
||||
/// an event-ID hash).
|
||||
static func leadingZeroBits<Bytes: Sequence<UInt8>>(_ bytes: Bytes) -> Int {
|
||||
var total = 0
|
||||
for byte in bytes {
|
||||
if byte == 0 {
|
||||
total += 8
|
||||
} else {
|
||||
total += byte.leadingZeroBitCount
|
||||
break
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
/// Validated NIP-13 difficulty of an inbound event.
|
||||
///
|
||||
/// The committed target in the nonce tag is what counts: the actual
|
||||
/// leading zero bits of the ID must meet it (otherwise the claim is void
|
||||
/// and the event scores 0), and work beyond the commitment earns no extra
|
||||
/// credit — this stops spammers who mine a low target from getting lucky
|
||||
/// high scores. Events without a well-formed commitment score 0.
|
||||
static func validatedDifficulty(idHex: String, tags: [[String]]) -> Int {
|
||||
guard let nonceTag = tags.last(where: { $0.first == "nonce" }),
|
||||
nonceTag.count >= 3,
|
||||
let committed = Int(nonceTag[2]),
|
||||
committed > 0, committed <= 256,
|
||||
let idData = Data(hexString: idHex)
|
||||
else {
|
||||
return 0
|
||||
}
|
||||
return leadingZeroBits(idData) >= committed ? committed : 0
|
||||
}
|
||||
|
||||
// MARK: - Mining
|
||||
|
||||
/// Mine a `["nonce", value, target]` tag for the given unsigned-event
|
||||
/// fields. Nonisolated async: runs off the calling actor.
|
||||
///
|
||||
/// Bounded by `miningTimeCap`: when the cap hits — or the surrounding
|
||||
/// task is cancelled — the committed target steps down (halving to 0,
|
||||
/// which any hash satisfies) so the event still ships promptly with an
|
||||
/// honest commitment at the difficulty actually reached. Returns nil only
|
||||
/// if canonical serialization fails; the caller then sends unmined.
|
||||
static func mineNonceTag(
|
||||
pubkey: String,
|
||||
createdAt: Int,
|
||||
kind: Int,
|
||||
tags: [[String]],
|
||||
content: String,
|
||||
targetBits: Int = NostrPoW.targetBits
|
||||
) async -> [String]? {
|
||||
var target = min(max(targetBits, 0), 256)
|
||||
var budget = miningTimeCap
|
||||
while true {
|
||||
if let tag = mineAttempt(
|
||||
pubkey: pubkey,
|
||||
createdAt: createdAt,
|
||||
kind: kind,
|
||||
baseTags: tags,
|
||||
content: content,
|
||||
targetBits: target,
|
||||
budget: budget
|
||||
) {
|
||||
return tag
|
||||
}
|
||||
// Target 0 succeeds on the first hash, so reaching it with nil
|
||||
// means serialization itself failed — give up on mining.
|
||||
if target == 0 { return nil }
|
||||
target /= 2
|
||||
budget = fallbackTimeCap
|
||||
}
|
||||
}
|
||||
|
||||
/// One bounded mining pass at a fixed committed target. Allocation-light:
|
||||
/// the canonical serialization is built once and only the fixed-width
|
||||
/// nonce bytes are rewritten per attempt (the event ID is recomputed for
|
||||
/// every attempt, per NIP-13). Returns nil on timeout/cancellation or if
|
||||
/// the template could not be built.
|
||||
private static func mineAttempt(
|
||||
pubkey: String,
|
||||
createdAt: Int,
|
||||
kind: Int,
|
||||
baseTags: [[String]],
|
||||
content: String,
|
||||
targetBits: Int,
|
||||
budget: TimeInterval
|
||||
) -> [String]? {
|
||||
let targetString = String(targetBits)
|
||||
guard let template = serializedTemplate(
|
||||
pubkey: pubkey,
|
||||
createdAt: createdAt,
|
||||
kind: kind,
|
||||
baseTags: baseTags,
|
||||
content: content,
|
||||
targetString: targetString
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
var buffer = template.buffer
|
||||
let nonceRange = template.nonceRange
|
||||
|
||||
let deadline = DispatchTime.now().uptimeNanoseconds &+ UInt64(budget * 1_000_000_000)
|
||||
let hexDigits = [UInt8]("0123456789abcdef".utf8)
|
||||
var nonce = UInt64.random(in: .min ... .max)
|
||||
var attempts: UInt64 = 0
|
||||
|
||||
while true {
|
||||
// Write the nonce as 16 lowercase hex chars, in place.
|
||||
var value = nonce
|
||||
var index = nonceRange.upperBound
|
||||
while index > nonceRange.lowerBound {
|
||||
index -= 1
|
||||
buffer[index] = hexDigits[Int(value & 0xF)]
|
||||
value >>= 4
|
||||
}
|
||||
|
||||
if leadingZeroBits(SHA256.hash(data: buffer)) >= targetBits {
|
||||
// Identical to the bytes just written into the buffer.
|
||||
return ["nonce", String(format: "%016llx", nonce), targetString]
|
||||
}
|
||||
|
||||
nonce &+= 1
|
||||
attempts &+= 1
|
||||
if attempts % checkInterval == 0,
|
||||
Task.isCancelled || DispatchTime.now().uptimeNanoseconds >= deadline {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical NIP-01 serialization of the event with a placeholder nonce,
|
||||
/// plus the byte range of the nonce value inside it.
|
||||
///
|
||||
/// The range is located by serializing twice with two same-length
|
||||
/// placeholders and diffing the buffers — the only differing bytes are
|
||||
/// the nonce value, so this stays correct however `JSONSerialization`
|
||||
/// escapes the surrounding fields (and even if the content contains the
|
||||
/// placeholder text itself).
|
||||
private static func serializedTemplate(
|
||||
pubkey: String,
|
||||
createdAt: Int,
|
||||
kind: Int,
|
||||
baseTags: [[String]],
|
||||
content: String,
|
||||
targetString: String
|
||||
) -> (buffer: Data, nonceRange: Range<Int>)? {
|
||||
func serialize(noncePlaceholder: String) -> Data? {
|
||||
var tags = baseTags
|
||||
tags.append(["nonce", noncePlaceholder, targetString])
|
||||
let serialized: [Any] = [0, pubkey, createdAt, kind, tags, content]
|
||||
return try? JSONSerialization.data(withJSONObject: serialized, options: [.withoutEscapingSlashes])
|
||||
}
|
||||
|
||||
guard let zeros = serialize(noncePlaceholder: String(repeating: "0", count: nonceLength)),
|
||||
let effs = serialize(noncePlaceholder: String(repeating: "f", count: nonceLength)),
|
||||
zeros.count == effs.count
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var firstDiff = -1
|
||||
var lastDiff = -1
|
||||
for index in 0..<zeros.count where zeros[index] != effs[index] {
|
||||
if firstDiff < 0 { firstDiff = index }
|
||||
lastDiff = index
|
||||
}
|
||||
guard firstDiff >= 0, lastDiff - firstDiff + 1 == nonceLength else { return nil }
|
||||
return (zeros, firstDiff..<(firstDiff + nonceLength))
|
||||
}
|
||||
}
|
||||
@@ -170,6 +170,63 @@ struct NostrProtocol {
|
||||
nickname: String? = nil,
|
||||
teleported: Bool = false
|
||||
) throws -> NostrEvent {
|
||||
let event = NostrEvent(
|
||||
pubkey: senderIdentity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .ephemeralEvent,
|
||||
tags: ephemeralGeohashTags(geohash: geohash, nickname: nickname, teleported: teleported),
|
||||
content: content
|
||||
)
|
||||
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
||||
return try event.sign(with: schnorrKey)
|
||||
}
|
||||
|
||||
/// Create a kind-20000 geohash message carrying a NIP-13 proof-of-work
|
||||
/// nonce tag (see `NostrPoW`). Mining runs off the calling actor and is
|
||||
/// bounded by `NostrPoW.miningTimeCap`; when the cap hits (or the
|
||||
/// surrounding task is cancelled) the event ships at the highest
|
||||
/// committed difficulty still met, and if mining is impossible it ships
|
||||
/// unmined — sending is never blocked.
|
||||
static func createMinedEphemeralGeohashEvent(
|
||||
content: String,
|
||||
geohash: String,
|
||||
senderIdentity: NostrIdentity,
|
||||
nickname: String? = nil,
|
||||
teleported: Bool = false,
|
||||
powTargetBits: Int = NostrPoW.targetBits
|
||||
) async throws -> NostrEvent {
|
||||
var tags = ephemeralGeohashTags(geohash: geohash, nickname: nickname, teleported: teleported)
|
||||
// Fix created_at up front: the mined nonce commits to the full
|
||||
// serialized event, so the signed event must reuse the exact value.
|
||||
let createdAt = Int(Date().timeIntervalSince1970)
|
||||
if let nonceTag = await NostrPoW.mineNonceTag(
|
||||
pubkey: senderIdentity.publicKeyHex,
|
||||
createdAt: createdAt,
|
||||
kind: EventKind.ephemeralEvent.rawValue,
|
||||
tags: tags,
|
||||
content: content,
|
||||
targetBits: powTargetBits
|
||||
) {
|
||||
tags.append(nonceTag)
|
||||
}
|
||||
let event = NostrEvent(
|
||||
pubkey: senderIdentity.publicKeyHex,
|
||||
createdAt: Date(timeIntervalSince1970: TimeInterval(createdAt)),
|
||||
kind: .ephemeralEvent,
|
||||
tags: tags,
|
||||
content: content
|
||||
)
|
||||
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
||||
return try event.sign(with: schnorrKey)
|
||||
}
|
||||
|
||||
/// Tags for a kind-20000 geohash message (shared by the plain and mined
|
||||
/// variants).
|
||||
private static func ephemeralGeohashTags(
|
||||
geohash: String,
|
||||
nickname: String?,
|
||||
teleported: Bool
|
||||
) -> [[String]] {
|
||||
var tags = [["g", geohash]]
|
||||
if let nickname = nickname?.trimmedOrNilIfEmpty {
|
||||
tags.append(["n", nickname])
|
||||
@@ -177,15 +234,7 @@ struct NostrProtocol {
|
||||
if teleported {
|
||||
tags.append(["t", "teleport"])
|
||||
}
|
||||
let event = NostrEvent(
|
||||
pubkey: senderIdentity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .ephemeralEvent,
|
||||
tags: tags,
|
||||
content: content
|
||||
)
|
||||
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
||||
return try event.sign(with: schnorrKey)
|
||||
return tags
|
||||
}
|
||||
|
||||
/// Create a geohash presence heartbeat (kind 20001)
|
||||
|
||||
@@ -324,7 +324,7 @@ private extension ChatLifecycleCoordinator {
|
||||
|
||||
do {
|
||||
let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash)
|
||||
let event = try NostrProtocol.createEphemeralGeohashEvent(
|
||||
let event = try await NostrProtocol.createMinedEphemeralGeohashEvent(
|
||||
content: message,
|
||||
geohash: channel.geohash,
|
||||
senderIdentity: identity,
|
||||
|
||||
@@ -68,10 +68,22 @@ extension ChatViewModel: ChatOutgoingContext {
|
||||
final class ChatOutgoingCoordinator {
|
||||
private unowned let context: any ChatOutgoingContext
|
||||
|
||||
/// In-flight NIP-13 mining for the most recent geohash send. A newer send
|
||||
/// (or leaving the channel) cancels it, which only expedites the mining —
|
||||
/// the message still goes out at the difficulty already reached.
|
||||
/// (Read access is internal so tests can await the send's completion.)
|
||||
private(set) var geohashMiningTask: Task<Void, Never>?
|
||||
|
||||
init(context: any ChatOutgoingContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
/// Finish any in-flight geohash PoW mining early (the pending message
|
||||
/// still sends, at whatever committed difficulty it reached).
|
||||
func expeditePendingGeohashMining() {
|
||||
geohashMiningTask?.cancel()
|
||||
}
|
||||
|
||||
func sendMessage(_ content: String) {
|
||||
guard let trimmed = content.trimmedOrNilIfEmpty else { return }
|
||||
|
||||
@@ -92,120 +104,117 @@ final class ChatOutgoingCoordinator {
|
||||
}
|
||||
|
||||
let mentions = context.parseMentions(from: content)
|
||||
let preparedMessage = preparePublicMessage(content: content, trimmed: trimmed, mentions: mentions)
|
||||
guard let preparedMessage else { return }
|
||||
|
||||
appendLocalEcho(preparedMessage.message)
|
||||
routePublicMessage(
|
||||
originalContent: content,
|
||||
mentions: mentions,
|
||||
geoContext: preparedMessage.geoContext,
|
||||
messageID: preparedMessage.message.id,
|
||||
timestamp: preparedMessage.message.timestamp
|
||||
)
|
||||
switch context.activeChannel {
|
||||
case .mesh:
|
||||
sendMeshPublicMessage(originalContent: content, trimmed: trimmed, mentions: mentions)
|
||||
case .location(let channel):
|
||||
sendGeohashPublicMessage(trimmed, mentions: mentions, channel: channel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension ChatOutgoingCoordinator {
|
||||
func preparePublicMessage(
|
||||
content: String,
|
||||
trimmed: String,
|
||||
mentions: [String]
|
||||
) -> (message: BitchatMessage, geoContext: ChatViewModel.GeoOutgoingContext?)? {
|
||||
var geoContext: ChatViewModel.GeoOutgoingContext?
|
||||
var displaySender = context.nickname
|
||||
var localSenderPeerID = context.myPeerID
|
||||
var messageID: String?
|
||||
var messageTimestamp = Date()
|
||||
func sendMeshPublicMessage(originalContent: String, trimmed: String, mentions: [String]) {
|
||||
let message = BitchatMessage(
|
||||
sender: context.nickname,
|
||||
content: trimmed,
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
senderPeerID: context.myPeerID,
|
||||
mentions: mentions.isEmpty ? nil : mentions
|
||||
)
|
||||
|
||||
switch context.activeChannel {
|
||||
case .mesh:
|
||||
break
|
||||
appendLocalEcho(message, to: .mesh)
|
||||
context.recordPublicActivity(forChannelKey: "mesh")
|
||||
context.sendMeshMessage(
|
||||
originalContent,
|
||||
mentions: mentions,
|
||||
messageID: message.id,
|
||||
timestamp: message.timestamp
|
||||
)
|
||||
}
|
||||
|
||||
case .location(let channel):
|
||||
/// Geohash sends mine a NIP-13 nonce tag first (off the main actor, see
|
||||
/// `NostrPoW`), so the whole echo-and-send runs in a task once the signed
|
||||
/// event — whose ID is also the local message ID — exists. Typical mining
|
||||
/// at the default target is well under 100 ms and hard-capped at
|
||||
/// `NostrPoW.miningTimeCap`, so sending is never meaningfully delayed.
|
||||
func sendGeohashPublicMessage(_ trimmed: String, mentions: [String], channel: GeohashChannel) {
|
||||
let identity: NostrIdentity
|
||||
do {
|
||||
identity = try context.deriveNostrIdentity(forGeohash: channel.geohash)
|
||||
} catch {
|
||||
SecureLogger.error("❌ Failed to prepare geohash message: \(error)", category: .session)
|
||||
context.addSystemMessage(
|
||||
String(localized: "system.location.send_failed", comment: "System message when a location channel send fails")
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let displaySender = context.nickname + "#" + String(identity.publicKeyHex.suffix(4))
|
||||
let senderPeerID = PeerID(nostr: identity.publicKeyHex)
|
||||
let teleported = context.isTeleported
|
||||
let nickname = context.nickname
|
||||
|
||||
// Serialize geohash sends: each send awaits the previous send's task
|
||||
// before it appends + relays, so user-visible order always matches
|
||||
// send order even when an earlier message mines longer than a later
|
||||
// one. Cancelling the previous task only *expedites* its mining (the
|
||||
// NIP-13 target is polled, not aborted), so it still finishes and
|
||||
// sends — and it finishes fast, so awaiting it never stacks mining
|
||||
// delays or blocks a send beyond `NostrPoW.miningTimeCap`.
|
||||
let previousSend = geohashMiningTask
|
||||
previousSend?.cancel()
|
||||
geohashMiningTask = Task { @MainActor [weak context = self.context] in
|
||||
await previousSend?.value
|
||||
|
||||
let event: NostrEvent
|
||||
do {
|
||||
let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash)
|
||||
let suffix = String(identity.publicKeyHex.suffix(4))
|
||||
displaySender = context.nickname + "#" + suffix
|
||||
localSenderPeerID = PeerID(nostr: identity.publicKeyHex)
|
||||
|
||||
let teleported = context.isTeleported
|
||||
let event = try NostrProtocol.createEphemeralGeohashEvent(
|
||||
event = try await NostrProtocol.createMinedEphemeralGeohashEvent(
|
||||
content: trimmed,
|
||||
geohash: channel.geohash,
|
||||
senderIdentity: identity,
|
||||
nickname: context.nickname,
|
||||
teleported: teleported
|
||||
)
|
||||
|
||||
messageID = event.id
|
||||
messageTimestamp = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||
geoContext = (
|
||||
channel: channel,
|
||||
event: event,
|
||||
identity: identity,
|
||||
nickname: nickname,
|
||||
teleported: teleported
|
||||
)
|
||||
} catch {
|
||||
SecureLogger.error("❌ Failed to prepare geohash message: \(error)", category: .session)
|
||||
context.addSystemMessage(
|
||||
String(localized: "system.location.send_failed", comment: "System message when a location channel send fails")
|
||||
)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
let message = BitchatMessage(
|
||||
id: messageID,
|
||||
sender: displaySender,
|
||||
content: trimmed,
|
||||
timestamp: messageTimestamp,
|
||||
isRelay: false,
|
||||
senderPeerID: localSenderPeerID,
|
||||
mentions: mentions.isEmpty ? nil : mentions
|
||||
)
|
||||
|
||||
return (message, geoContext)
|
||||
}
|
||||
|
||||
func appendLocalEcho(_ message: BitchatMessage) {
|
||||
context.appendPublicMessage(message, to: ConversationID(channelID: context.activeChannel))
|
||||
|
||||
let contentKey = context.normalizedContentKey(message.content)
|
||||
context.recordContentKey(contentKey, timestamp: message.timestamp)
|
||||
}
|
||||
|
||||
func routePublicMessage(
|
||||
originalContent: String,
|
||||
mentions: [String],
|
||||
geoContext: ChatViewModel.GeoOutgoingContext?,
|
||||
messageID: String,
|
||||
timestamp: Date
|
||||
) {
|
||||
switch context.activeChannel {
|
||||
case .mesh:
|
||||
context.recordPublicActivity(forChannelKey: "mesh")
|
||||
context.sendMeshMessage(
|
||||
originalContent,
|
||||
mentions: mentions,
|
||||
messageID: messageID,
|
||||
timestamp: timestamp
|
||||
)
|
||||
|
||||
case .location(let channel):
|
||||
context.recordPublicActivity(forChannelKey: "geo:\(channel.geohash)")
|
||||
|
||||
guard let geoContext, geoContext.channel.geohash == channel.geohash else {
|
||||
SecureLogger.error("Geo: missing send context for \(channel.geohash)", category: .session)
|
||||
context.addSystemMessage(
|
||||
context?.addSystemMessage(
|
||||
String(localized: "system.location.send_failed", comment: "System message when a location channel send fails")
|
||||
)
|
||||
return
|
||||
}
|
||||
guard let context else { return }
|
||||
|
||||
Task { @MainActor [weak context = self.context] in
|
||||
context?.sendGeohash(context: geoContext)
|
||||
}
|
||||
let message = BitchatMessage(
|
||||
id: event.id,
|
||||
sender: displaySender,
|
||||
content: trimmed,
|
||||
timestamp: Date(timeIntervalSince1970: TimeInterval(event.created_at)),
|
||||
isRelay: false,
|
||||
senderPeerID: senderPeerID,
|
||||
mentions: mentions.isEmpty ? nil : mentions
|
||||
)
|
||||
|
||||
context.appendPublicMessage(message, to: ConversationID(channelID: .location(channel)))
|
||||
let contentKey = context.normalizedContentKey(message.content)
|
||||
context.recordContentKey(contentKey, timestamp: message.timestamp)
|
||||
|
||||
context.recordPublicActivity(forChannelKey: "geo:\(channel.geohash)")
|
||||
context.sendGeohash(context: (
|
||||
channel: channel,
|
||||
event: event,
|
||||
identity: identity,
|
||||
teleported: teleported
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
func appendLocalEcho(_ message: BitchatMessage, to conversationID: ConversationID) {
|
||||
context.appendPublicMessage(message, to: conversationID)
|
||||
|
||||
let contentKey = context.normalizedContentKey(message.content)
|
||||
context.recordContentKey(contentKey, timestamp: message.timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +82,9 @@ protocol ChatPublicConversationContext: AnyObject {
|
||||
// MARK: Inbound public message processing
|
||||
func processActionMessage(_ message: BitchatMessage) -> BitchatMessage
|
||||
func isMessageBlocked(_ message: BitchatMessage) -> Bool
|
||||
func allowPublicMessage(senderKey: String, contentKey: String) -> Bool
|
||||
/// `powBits` is the validated NIP-13 difficulty of the source Nostr event
|
||||
/// (0 for mesh messages); sufficient PoW relaxes the per-sender bucket.
|
||||
func allowPublicMessage(senderKey: String, contentKey: String, powBits: Int) -> Bool
|
||||
/// Buffers a visible-channel message for the batched (~80 ms) pipeline
|
||||
/// flush, which commits it to `conversationID` in the store.
|
||||
func enqueuePublicMessage(_ message: BitchatMessage, to conversationID: ConversationID)
|
||||
@@ -137,8 +139,8 @@ extension ChatViewModel: ChatPublicConversationContext {
|
||||
meshService.sendMessage(content, mentions: mentions, messageID: messageID, timestamp: timestamp)
|
||||
}
|
||||
|
||||
func allowPublicMessage(senderKey: String, contentKey: String) -> Bool {
|
||||
publicRateLimiter.allow(senderKey: senderKey, contentKey: contentKey)
|
||||
func allowPublicMessage(senderKey: String, contentKey: String, powBits: Int) -> Bool {
|
||||
publicRateLimiter.allow(senderKey: senderKey, contentKey: contentKey, powBits: powBits)
|
||||
}
|
||||
|
||||
func enqueuePublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) {
|
||||
@@ -367,7 +369,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
|
||||
guard let context else { return }
|
||||
do {
|
||||
let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash)
|
||||
let event = try NostrProtocol.createEphemeralGeohashEvent(
|
||||
let event = try await NostrProtocol.createMinedEphemeralGeohashEvent(
|
||||
content: content,
|
||||
geohash: channel.geohash,
|
||||
senderIdentity: identity,
|
||||
@@ -395,7 +397,11 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
|
||||
)
|
||||
}
|
||||
|
||||
func handlePublicMessage(_ message: BitchatMessage) {
|
||||
/// - Parameter powBits: validated NIP-13 difficulty of the source Nostr
|
||||
/// event (0 for mesh messages). Sufficient PoW relaxes the per-sender
|
||||
/// rate limit; low/no-PoW events keep the strict limits so old clients
|
||||
/// still get through at normal rates.
|
||||
func handlePublicMessage(_ message: BitchatMessage, powBits: Int = 0) {
|
||||
let finalMessage = context.processActionMessage(message)
|
||||
if context.isMessageBlocked(finalMessage) { return }
|
||||
|
||||
@@ -405,7 +411,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
|
||||
if shouldRateLimit {
|
||||
let senderKey = normalizedSenderKey(for: finalMessage)
|
||||
let contentKey = context.normalizedContentKey(finalMessage.content)
|
||||
if !context.allowPublicMessage(senderKey: senderKey, contentKey: contentKey) {
|
||||
if !context.allowPublicMessage(senderKey: senderKey, contentKey: contentKey, powBits: powBits) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,6 +311,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
get { conversations.activeChannel }
|
||||
set {
|
||||
guard conversations.activeChannel != newValue else { return }
|
||||
// Leaving a channel expedites any in-flight NIP-13 mining: the
|
||||
// pending message still sends, at the difficulty already reached.
|
||||
outgoingCoordinator.expeditePendingGeohashMining()
|
||||
conversations.setActiveChannel(newValue)
|
||||
visibleMessagesCache = nil
|
||||
objectWillChange.send()
|
||||
@@ -1683,6 +1686,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
publicConversationCoordinator.handlePublicMessage(message)
|
||||
}
|
||||
|
||||
/// Handle an incoming public Nostr message with its validated NIP-13
|
||||
/// difficulty; sufficient PoW relaxes the per-sender rate limit.
|
||||
@MainActor
|
||||
func handlePublicMessage(_ message: BitchatMessage, powBits: Int) {
|
||||
publicConversationCoordinator.handlePublicMessage(message, powBits: powBits)
|
||||
}
|
||||
|
||||
/// Check for mentions and send notifications
|
||||
func checkForMentions(_ message: BitchatMessage) {
|
||||
publicConversationCoordinator.checkForMentions(message)
|
||||
|
||||
@@ -48,15 +48,25 @@ struct MessageRateLimiter {
|
||||
self.contentRefill = contentRefillPerSec
|
||||
}
|
||||
|
||||
mutating func allow(senderKey: String, contentKey: String, now: Date = Date()) -> Bool {
|
||||
var senderBucket = senderBuckets[senderKey] ?? TokenBucket(
|
||||
capacity: senderCapacity,
|
||||
tokens: senderCapacity,
|
||||
refillPerSec: senderRefill,
|
||||
lastRefill: now
|
||||
)
|
||||
let senderAllowed = senderBucket.allow(now: now)
|
||||
senderBuckets[senderKey] = senderBucket
|
||||
/// - Parameter powBits: validated NIP-13 difficulty of the event
|
||||
/// (`NostrPoW.validatedDifficulty`; 0 for mesh or no-PoW events).
|
||||
/// At or above `NostrPoW.rateLimitBypassBits` the per-sender bucket is
|
||||
/// skipped entirely — each such message paid for itself with work — but
|
||||
/// the per-content flood bucket still applies.
|
||||
mutating func allow(senderKey: String, contentKey: String, powBits: Int = 0, now: Date = Date()) -> Bool {
|
||||
let senderAllowed: Bool
|
||||
if powBits >= NostrPoW.rateLimitBypassBits {
|
||||
senderAllowed = true
|
||||
} else {
|
||||
var senderBucket = senderBuckets[senderKey] ?? TokenBucket(
|
||||
capacity: senderCapacity,
|
||||
tokens: senderCapacity,
|
||||
refillPerSec: senderRefill,
|
||||
lastRefill: now
|
||||
)
|
||||
senderAllowed = senderBucket.allow(now: now)
|
||||
senderBuckets[senderKey] = senderBucket
|
||||
}
|
||||
|
||||
var contentBucket = contentBuckets[contentKey] ?? TokenBucket(
|
||||
capacity: contentCapacity,
|
||||
|
||||
@@ -32,7 +32,10 @@ protocol NostrInboundPipelineContext: AnyObject {
|
||||
func recordGeoParticipant(pubkeyHex: String)
|
||||
|
||||
// MARK: Inbound public messages
|
||||
func handlePublicMessage(_ message: BitchatMessage)
|
||||
/// `powBits` is the validated NIP-13 difficulty of the source event
|
||||
/// (`NostrPoW.validatedDifficulty`); it relaxes the per-sender rate limit
|
||||
/// downstream.
|
||||
func handlePublicMessage(_ message: BitchatMessage, powBits: Int)
|
||||
func checkForMentions(_ message: BitchatMessage)
|
||||
func sendHapticFeedback(for message: BitchatMessage)
|
||||
func parseMentions(from content: String) -> [String]
|
||||
@@ -152,6 +155,7 @@ final class NostrInboundPipeline {
|
||||
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||
let timestamp = min(rawTs, Date())
|
||||
let mentions = context.parseMentions(from: content)
|
||||
let powBits = NostrPoW.validatedDifficulty(idHex: event.id, tags: event.tags)
|
||||
let message = BitchatMessage(
|
||||
id: event.id,
|
||||
sender: senderName,
|
||||
@@ -165,7 +169,7 @@ final class NostrInboundPipeline {
|
||||
Task { @MainActor [weak context] in
|
||||
guard let context else { return }
|
||||
let isBlocked = context.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased())
|
||||
context.handlePublicMessage(message)
|
||||
context.handlePublicMessage(message, powBits: powBits)
|
||||
if !isBlocked {
|
||||
context.checkForMentions(message)
|
||||
context.sendHapticFeedback(for: message)
|
||||
@@ -187,10 +191,12 @@ final class NostrInboundPipeline {
|
||||
guard event.isValidSignature() else { return }
|
||||
context.recordProcessedNostrEvent(event.id)
|
||||
|
||||
let powBits = NostrPoW.validatedDifficulty(idHex: event.id, tags: event.tags)
|
||||
|
||||
// Sampled: fires for every geo event and floods dev logs in busy geohashes.
|
||||
geoEventLogCount += 1
|
||||
if geoEventLogCount == 1 || geoEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) {
|
||||
SecureLogger.debug("GeoTeleport: recv #\(geoEventLogCount) pub=\(event.pubkey.prefix(8))… tags=\(event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ","))", category: .session)
|
||||
SecureLogger.debug("GeoTeleport: recv #\(geoEventLogCount) pub=\(event.pubkey.prefix(8))… pow=\(powBits) tags=\(event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ","))", category: .session)
|
||||
}
|
||||
|
||||
if context.isNostrBlocked(pubkeyHexLowercased: event.pubkey) {
|
||||
@@ -255,7 +261,7 @@ final class NostrInboundPipeline {
|
||||
|
||||
Task { @MainActor [weak context] in
|
||||
guard let context else { return }
|
||||
context.handlePublicMessage(message)
|
||||
context.handlePublicMessage(message, powBits: powBits)
|
||||
context.checkForMentions(message)
|
||||
context.sendHapticFeedback(for: message)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user