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:
jack
2026-07-07 14:12:37 +02:00
committed by GitHub
co-authored by jack Claude Fable 5
parent 276cde44e7
commit ede6368296
14 changed files with 808 additions and 126 deletions
+58 -9
View File
@@ -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)