From 7e40774ef5e2a1782f73fcf62f67f699a17e58f7 Mon Sep 17 00:00:00 2001 From: Mahmoud Khedr Date: Sat, 18 Apr 2026 22:29:28 +0200 Subject: [PATCH 1/3] feat: add NIP-13 Proof of Work enforcement scenarios and implementation --- .../features/nip-13/nip-13.feature | 42 ++++ .../features/nip-13/nip-13.feature.ts | 206 ++++++++++++++++++ 2 files changed, 248 insertions(+) create mode 100644 test/integration/features/nip-13/nip-13.feature create mode 100644 test/integration/features/nip-13/nip-13.feature.ts diff --git a/test/integration/features/nip-13/nip-13.feature b/test/integration/features/nip-13/nip-13.feature new file mode 100644 index 00000000..f830492c --- /dev/null +++ b/test/integration/features/nip-13/nip-13.feature @@ -0,0 +1,42 @@ +@nip13 +Feature: NIP-13 Proof of Work enforcement + Scenario: Event ID PoW disabled accepts event + Given someone called Alice + And NIP-13 event ID minimum leading zero bits is 0 + And NIP-13 pubkey minimum leading zero bits is 0 + When Alice sends a plain text_note event with content "event-id-disabled" and records the command result + Then Alice receives a successful NIP-13 command result + When Alice subscribes to author Alice + Then Alice receives a text_note event from Alice with content "event-id-disabled" + + Scenario: Event ID PoW rejects insufficient proof of work + Given someone called Alice + And NIP-13 event ID minimum leading zero bits is 10 + And NIP-13 pubkey minimum leading zero bits is 0 + When Alice sends a text_note event with content "event-id-fail" and event ID PoW below the required threshold + Then Alice receives an unsuccessful NIP-13 event ID PoW result + + Scenario: Event ID PoW accepts sufficient proof of work + Given someone called Alice + And NIP-13 event ID minimum leading zero bits is 10 + And NIP-13 pubkey minimum leading zero bits is 0 + When Alice sends a text_note event with content "event-id-pass" and event ID PoW at least the required threshold + Then Alice receives a successful NIP-13 command result + When Alice subscribes to author Alice + Then Alice receives a text_note event from Alice with content "event-id-pass" + + Scenario: Pubkey PoW rejects insufficient proof of work + Given someone called Alice + And NIP-13 event ID minimum leading zero bits is 0 + And NIP-13 pubkey minimum leading zero bits is 10 + When Alice sends a text_note event with content "pubkey-fail" and pubkey PoW below the required threshold + Then Alice receives an unsuccessful NIP-13 pubkey PoW result + + Scenario: Pubkey PoW accepts sufficient proof of work + Given someone called Alice + And NIP-13 event ID minimum leading zero bits is 0 + And NIP-13 pubkey minimum leading zero bits is 10 + When Alice sends a text_note event with content "pubkey-pass" and pubkey PoW at least the required threshold + Then Alice receives a successful NIP-13 command result + When Alice subscribes to author Alice + Then Alice receives a text_note event from Alice with content "pubkey-pass" diff --git a/test/integration/features/nip-13/nip-13.feature.ts b/test/integration/features/nip-13/nip-13.feature.ts new file mode 100644 index 00000000..618f0861 --- /dev/null +++ b/test/integration/features/nip-13/nip-13.feature.ts @@ -0,0 +1,206 @@ +import * as secp256k1 from '@noble/secp256k1' +import { After, Given, Then, When, World } from '@cucumber/cucumber' +import { expect } from 'chai' +import { createHash } from 'crypto' +import WebSocket from 'ws' + +import { Event } from '../../../../src/@types/event' +import { SettingsStatic } from '../../../../src/utils/settings' +import { getEventProofOfWork, getPubkeyProofOfWork } from '../../../../src/utils/event' +import { createEvent, waitForCommand } from '../helpers' + +type PowMode = 'below' | 'at least' +type Identity = { name: string; privkey: string; pubkey: string } +type Nip13CommandResult = [string, string, boolean, string?] + +const MAX_MINING_ATTEMPTS = 200_000 + +const ensureNip13State = (world: World>) => { + world.parameters.nip13 = world.parameters.nip13 ?? {} + world.parameters.nip13.commands = world.parameters.nip13.commands ?? {} +} + +const snapshotSettingsIfNeeded = (world: World>) => { + ensureNip13State(world) + if (!world.parameters.nip13.previousSettings) { + world.parameters.nip13.previousSettings = structuredClone(SettingsStatic._settings as any) + } +} + +const setPowLimit = (world: World>, type: 'eventId' | 'pubkey', bits: number) => { + snapshotSettingsIfNeeded(world) + + const settings = structuredClone(SettingsStatic._settings as any) + settings.limits = settings.limits ?? {} + settings.limits.event = settings.limits.event ?? {} + settings.limits.event[type] = { + ...(settings.limits.event[type] ?? {}), + minLeadingZeroBits: bits, + } + + SettingsStatic._settings = settings as any +} + +const getRequiredBits = (type: 'eventId' | 'pubkey') => { + return ((SettingsStatic._settings as any)?.limits?.event?.[type]?.minLeadingZeroBits ?? 0) as number +} + +const computePubkey = (privkey: string) => { + return Buffer.from(secp256k1.getPublicKey(privkey, true)).toString('hex').substring(2) +} + +const mineIdentityForPow = (name: string, minLeadingZeroBits: number, mode: PowMode): Identity => { + for (let i = 0; i < MAX_MINING_ATTEMPTS; i++) { + const privkey = createHash('sha256').update(`nip13:${name}:${mode}:${minLeadingZeroBits}:${i}`).digest('hex') + + try { + const pubkey = computePubkey(privkey) + const pow = getPubkeyProofOfWork(pubkey) + if ((mode === 'below' && pow < minLeadingZeroBits) || (mode === 'at least' && pow >= minLeadingZeroBits)) { + return { name, privkey, pubkey } + } + } catch { + continue + } + } + + throw new Error(`Unable to mine pubkey PoW ${mode} ${minLeadingZeroBits}`) +} + +const mineEventForPow = async ( + pubkey: string, + privkey: string, + baseContent: string, + minLeadingZeroBits: number, + mode: PowMode, +): Promise<{ event: Event; pow: number }> => { + const createdAt = Math.floor(Date.now() / 1000) + + for (let i = 0; i < MAX_MINING_ATTEMPTS; i++) { + const event: Event = await createEvent( + { + pubkey, + kind: 1, + content: baseContent, + tags: [['nonce', String(i)]], + created_at: createdAt, + }, + privkey, + ) + + const pow = getEventProofOfWork(event.id) + if ((mode === 'below' && pow < minLeadingZeroBits) || (mode === 'at least' && pow >= minLeadingZeroBits)) { + return { event, pow } + } + } + + throw new Error(`Unable to mine event ID PoW ${mode} ${minLeadingZeroBits}`) +} + +const sendEventAndCaptureCommand = async (ws: WebSocket, event: Event): Promise => { + const commandPromise = waitForCommand(ws) + + await new Promise((resolve, reject) => { + ws.send(JSON.stringify(['EVENT', event]), (error?: Error) => { + if (error) { + reject(error) + } else { + resolve() + } + }) + }) + + return (await commandPromise) as Nip13CommandResult +} + +const storeCommand = (world: World>, name: string, command: Nip13CommandResult) => { + ensureNip13State(world) + world.parameters.nip13.commands[name] = command +} + +Given(/^NIP-13 event ID minimum leading zero bits is (\d+)$/, function (this: World>, bits: string) { + setPowLimit(this, 'eventId', Number(bits)) +}) + +Given(/^NIP-13 pubkey minimum leading zero bits is (\d+)$/, function (this: World>, bits: string) { + setPowLimit(this, 'pubkey', Number(bits)) +}) + +When( + /^(\w+) sends a plain text_note event with content "([^"]+)" and records the command result$/, + async function (this: World>, name: string, content: string) { + const ws = this.parameters.clients[name] as WebSocket + const { pubkey, privkey } = this.parameters.identities[name] + const event: Event = await createEvent({ pubkey, kind: 1, content }, privkey) + + const command = await sendEventAndCaptureCommand(ws, event) + storeCommand(this, name, command) + }, +) + +When( + /^(\w+) sends a text_note event with content "([^"]+)" and event ID PoW (below|at least) the required threshold$/, + { timeout: 20_000 }, + async function (this: World>, name: string, content: string, mode: PowMode) { + const ws = this.parameters.clients[name] as WebSocket + const { pubkey, privkey } = this.parameters.identities[name] + const requiredBits = getRequiredBits('eventId') + + const { event, pow } = await mineEventForPow(pubkey, privkey, content, requiredBits, mode) + const command = await sendEventAndCaptureCommand(ws, event) + storeCommand(this, name, command) + + this.parameters.nip13.expectedEventIdReason = `pow: difficulty ${pow}<${requiredBits}` + }, +) + +When( + /^(\w+) sends a text_note event with content "([^"]+)" and pubkey PoW (below|at least) the required threshold$/, + { timeout: 20_000 }, + async function (this: World>, name: string, content: string, mode: PowMode) { + const ws = this.parameters.clients[name] as WebSocket + const requiredBits = getRequiredBits('pubkey') + + const identity = mineIdentityForPow(name, requiredBits, mode) + this.parameters.identities[name] = identity + + const event: Event = await createEvent({ pubkey: identity.pubkey, kind: 1, content }, identity.privkey) + const command = await sendEventAndCaptureCommand(ws, event) + storeCommand(this, name, command) + + const pubkeyPow = getPubkeyProofOfWork(identity.pubkey) + this.parameters.nip13.expectedPubkeyReason = `pow: pubkey difficulty ${pubkeyPow}<${requiredBits}` + }, +) + +Then(/^(\w+) receives a successful NIP-13 command result$/, function (this: World>, name: string) { + const command = this.parameters.nip13.commands[name] as Nip13CommandResult + + expect(command[0]).to.equal('OK') + expect(command[2]).to.equal(true) +}) + +Then(/^(\w+) receives an unsuccessful NIP-13 event ID PoW result$/, function (this: World>, name: string) { + const command = this.parameters.nip13.commands[name] as Nip13CommandResult + + expect(command[0]).to.equal('OK') + expect(command[2]).to.equal(false) + expect(command[3]).to.equal(this.parameters.nip13.expectedEventIdReason) +}) + +Then(/^(\w+) receives an unsuccessful NIP-13 pubkey PoW result$/, function (this: World>, name: string) { + const command = this.parameters.nip13.commands[name] as Nip13CommandResult + + expect(command[0]).to.equal('OK') + expect(command[2]).to.equal(false) + expect(command[3]).to.equal(this.parameters.nip13.expectedPubkeyReason) +}) + +After({ tags: '@nip13' }, function (this: World>) { + const previousSettings = this.parameters.nip13?.previousSettings + if (previousSettings) { + SettingsStatic._settings = previousSettings + } + + this.parameters.nip13 = undefined +}) From 6a75bc65eb1ec67e62eba355020f0dfd22f2e705 Mon Sep 17 00:00:00 2001 From: Mahmoud Khedr Date: Sat, 18 Apr 2026 22:43:43 +0200 Subject: [PATCH 2/3] chore: add empty changeset for integration test PR --- .changeset/old-toys-stare.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .changeset/old-toys-stare.md diff --git a/.changeset/old-toys-stare.md b/.changeset/old-toys-stare.md new file mode 100644 index 00000000..a845151c --- /dev/null +++ b/.changeset/old-toys-stare.md @@ -0,0 +1,2 @@ +--- +--- From c6ce32d757468a59f14e559a974a567b00f9377c Mon Sep 17 00:00:00 2001 From: Mahmoud Khedr Date: Sat, 18 Apr 2026 22:59:25 +0200 Subject: [PATCH 3/3] test(nip-13): reuse sendEvent for id-scoped OK handling --- .../features/nip-13/nip-13.feature.ts | 86 ++++++++++--------- 1 file changed, 45 insertions(+), 41 deletions(-) diff --git a/test/integration/features/nip-13/nip-13.feature.ts b/test/integration/features/nip-13/nip-13.feature.ts index 618f0861..d41b9704 100644 --- a/test/integration/features/nip-13/nip-13.feature.ts +++ b/test/integration/features/nip-13/nip-13.feature.ts @@ -7,17 +7,16 @@ import WebSocket from 'ws' import { Event } from '../../../../src/@types/event' import { SettingsStatic } from '../../../../src/utils/settings' import { getEventProofOfWork, getPubkeyProofOfWork } from '../../../../src/utils/event' -import { createEvent, waitForCommand } from '../helpers' +import { createEvent, sendEvent } from '../helpers' type PowMode = 'below' | 'at least' type Identity = { name: string; privkey: string; pubkey: string } -type Nip13CommandResult = [string, string, boolean, string?] const MAX_MINING_ATTEMPTS = 200_000 const ensureNip13State = (world: World>) => { world.parameters.nip13 = world.parameters.nip13 ?? {} - world.parameters.nip13.commands = world.parameters.nip13.commands ?? {} + world.parameters.nip13.results = world.parameters.nip13.results ?? {} } const snapshotSettingsIfNeeded = (world: World>) => { @@ -97,25 +96,19 @@ const mineEventForPow = async ( throw new Error(`Unable to mine event ID PoW ${mode} ${minLeadingZeroBits}`) } -const sendEventAndCaptureCommand = async (ws: WebSocket, event: Event): Promise => { - const commandPromise = waitForCommand(ws) - - await new Promise((resolve, reject) => { - ws.send(JSON.stringify(['EVENT', event]), (error?: Error) => { - if (error) { - reject(error) - } else { - resolve() - } - }) - }) - - return (await commandPromise) as Nip13CommandResult +const storeResult = (world: World>, name: string, result: { success: boolean; error?: string }) => { + ensureNip13State(world) + world.parameters.nip13.results[name] = result } -const storeCommand = (world: World>, name: string, command: Nip13CommandResult) => { - ensureNip13State(world) - world.parameters.nip13.commands[name] = command +const sendEventExpectFailure = async (ws: WebSocket, event: Event): Promise => { + try { + await sendEvent(ws, event, true) + } catch (error) { + return (error as Error).message + } + + throw new Error('Expected event publication to fail, but it succeeded') } Given(/^NIP-13 event ID minimum leading zero bits is (\d+)$/, function (this: World>, bits: string) { @@ -133,8 +126,8 @@ When( const { pubkey, privkey } = this.parameters.identities[name] const event: Event = await createEvent({ pubkey, kind: 1, content }, privkey) - const command = await sendEventAndCaptureCommand(ws, event) - storeCommand(this, name, command) + await sendEvent(ws, event, true) + storeResult(this, name, { success: true }) }, ) @@ -147,10 +140,17 @@ When( const requiredBits = getRequiredBits('eventId') const { event, pow } = await mineEventForPow(pubkey, privkey, content, requiredBits, mode) - const command = await sendEventAndCaptureCommand(ws, event) - storeCommand(this, name, command) + const expectedReason = `pow: difficulty ${pow}<${requiredBits}` + this.parameters.nip13.expectedEventIdReason = expectedReason - this.parameters.nip13.expectedEventIdReason = `pow: difficulty ${pow}<${requiredBits}` + if (mode === 'below') { + const error = await sendEventExpectFailure(ws, event) + storeResult(this, name, { success: false, error }) + return + } + + await sendEvent(ws, event, true) + storeResult(this, name, { success: true }) }, ) @@ -165,35 +165,39 @@ When( this.parameters.identities[name] = identity const event: Event = await createEvent({ pubkey: identity.pubkey, kind: 1, content }, identity.privkey) - const command = await sendEventAndCaptureCommand(ws, event) - storeCommand(this, name, command) - const pubkeyPow = getPubkeyProofOfWork(identity.pubkey) - this.parameters.nip13.expectedPubkeyReason = `pow: pubkey difficulty ${pubkeyPow}<${requiredBits}` + const expectedReason = `pow: pubkey difficulty ${pubkeyPow}<${requiredBits}` + this.parameters.nip13.expectedPubkeyReason = expectedReason + + if (mode === 'below') { + const error = await sendEventExpectFailure(ws, event) + storeResult(this, name, { success: false, error }) + return + } + + await sendEvent(ws, event, true) + storeResult(this, name, { success: true }) }, ) Then(/^(\w+) receives a successful NIP-13 command result$/, function (this: World>, name: string) { - const command = this.parameters.nip13.commands[name] as Nip13CommandResult - - expect(command[0]).to.equal('OK') - expect(command[2]).to.equal(true) + const result = this.parameters.nip13.results[name] as { success: boolean; error?: string } + expect(result.success).to.equal(true) + expect(result.error).to.be.undefined }) Then(/^(\w+) receives an unsuccessful NIP-13 event ID PoW result$/, function (this: World>, name: string) { - const command = this.parameters.nip13.commands[name] as Nip13CommandResult + const result = this.parameters.nip13.results[name] as { success: boolean; error?: string } - expect(command[0]).to.equal('OK') - expect(command[2]).to.equal(false) - expect(command[3]).to.equal(this.parameters.nip13.expectedEventIdReason) + expect(result.success).to.equal(false) + expect(result.error).to.equal(this.parameters.nip13.expectedEventIdReason) }) Then(/^(\w+) receives an unsuccessful NIP-13 pubkey PoW result$/, function (this: World>, name: string) { - const command = this.parameters.nip13.commands[name] as Nip13CommandResult + const result = this.parameters.nip13.results[name] as { success: boolean; error?: string } - expect(command[0]).to.equal('OK') - expect(command[2]).to.equal(false) - expect(command[3]).to.equal(this.parameters.nip13.expectedPubkeyReason) + expect(result.success).to.equal(false) + expect(result.error).to.equal(this.parameters.nip13.expectedPubkeyReason) }) After({ tags: '@nip13' }, function (this: World>) {