Skip to content

Commit 7e40774

Browse files
feat: add NIP-13 Proof of Work enforcement scenarios and implementation
1 parent 3543d6c commit 7e40774

2 files changed

Lines changed: 248 additions & 0 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
@nip13
2+
Feature: NIP-13 Proof of Work enforcement
3+
Scenario: Event ID PoW disabled accepts event
4+
Given someone called Alice
5+
And NIP-13 event ID minimum leading zero bits is 0
6+
And NIP-13 pubkey minimum leading zero bits is 0
7+
When Alice sends a plain text_note event with content "event-id-disabled" and records the command result
8+
Then Alice receives a successful NIP-13 command result
9+
When Alice subscribes to author Alice
10+
Then Alice receives a text_note event from Alice with content "event-id-disabled"
11+
12+
Scenario: Event ID PoW rejects insufficient proof of work
13+
Given someone called Alice
14+
And NIP-13 event ID minimum leading zero bits is 10
15+
And NIP-13 pubkey minimum leading zero bits is 0
16+
When Alice sends a text_note event with content "event-id-fail" and event ID PoW below the required threshold
17+
Then Alice receives an unsuccessful NIP-13 event ID PoW result
18+
19+
Scenario: Event ID PoW accepts sufficient proof of work
20+
Given someone called Alice
21+
And NIP-13 event ID minimum leading zero bits is 10
22+
And NIP-13 pubkey minimum leading zero bits is 0
23+
When Alice sends a text_note event with content "event-id-pass" and event ID PoW at least the required threshold
24+
Then Alice receives a successful NIP-13 command result
25+
When Alice subscribes to author Alice
26+
Then Alice receives a text_note event from Alice with content "event-id-pass"
27+
28+
Scenario: Pubkey PoW rejects insufficient proof of work
29+
Given someone called Alice
30+
And NIP-13 event ID minimum leading zero bits is 0
31+
And NIP-13 pubkey minimum leading zero bits is 10
32+
When Alice sends a text_note event with content "pubkey-fail" and pubkey PoW below the required threshold
33+
Then Alice receives an unsuccessful NIP-13 pubkey PoW result
34+
35+
Scenario: Pubkey PoW accepts sufficient proof of work
36+
Given someone called Alice
37+
And NIP-13 event ID minimum leading zero bits is 0
38+
And NIP-13 pubkey minimum leading zero bits is 10
39+
When Alice sends a text_note event with content "pubkey-pass" and pubkey PoW at least the required threshold
40+
Then Alice receives a successful NIP-13 command result
41+
When Alice subscribes to author Alice
42+
Then Alice receives a text_note event from Alice with content "pubkey-pass"
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
import * as secp256k1 from '@noble/secp256k1'
2+
import { After, Given, Then, When, World } from '@cucumber/cucumber'
3+
import { expect } from 'chai'
4+
import { createHash } from 'crypto'
5+
import WebSocket from 'ws'
6+
7+
import { Event } from '../../../../src/@types/event'
8+
import { SettingsStatic } from '../../../../src/utils/settings'
9+
import { getEventProofOfWork, getPubkeyProofOfWork } from '../../../../src/utils/event'
10+
import { createEvent, waitForCommand } from '../helpers'
11+
12+
type PowMode = 'below' | 'at least'
13+
type Identity = { name: string; privkey: string; pubkey: string }
14+
type Nip13CommandResult = [string, string, boolean, string?]
15+
16+
const MAX_MINING_ATTEMPTS = 200_000
17+
18+
const ensureNip13State = (world: World<Record<string, any>>) => {
19+
world.parameters.nip13 = world.parameters.nip13 ?? {}
20+
world.parameters.nip13.commands = world.parameters.nip13.commands ?? {}
21+
}
22+
23+
const snapshotSettingsIfNeeded = (world: World<Record<string, any>>) => {
24+
ensureNip13State(world)
25+
if (!world.parameters.nip13.previousSettings) {
26+
world.parameters.nip13.previousSettings = structuredClone(SettingsStatic._settings as any)
27+
}
28+
}
29+
30+
const setPowLimit = (world: World<Record<string, any>>, type: 'eventId' | 'pubkey', bits: number) => {
31+
snapshotSettingsIfNeeded(world)
32+
33+
const settings = structuredClone(SettingsStatic._settings as any)
34+
settings.limits = settings.limits ?? {}
35+
settings.limits.event = settings.limits.event ?? {}
36+
settings.limits.event[type] = {
37+
...(settings.limits.event[type] ?? {}),
38+
minLeadingZeroBits: bits,
39+
}
40+
41+
SettingsStatic._settings = settings as any
42+
}
43+
44+
const getRequiredBits = (type: 'eventId' | 'pubkey') => {
45+
return ((SettingsStatic._settings as any)?.limits?.event?.[type]?.minLeadingZeroBits ?? 0) as number
46+
}
47+
48+
const computePubkey = (privkey: string) => {
49+
return Buffer.from(secp256k1.getPublicKey(privkey, true)).toString('hex').substring(2)
50+
}
51+
52+
const mineIdentityForPow = (name: string, minLeadingZeroBits: number, mode: PowMode): Identity => {
53+
for (let i = 0; i < MAX_MINING_ATTEMPTS; i++) {
54+
const privkey = createHash('sha256').update(`nip13:${name}:${mode}:${minLeadingZeroBits}:${i}`).digest('hex')
55+
56+
try {
57+
const pubkey = computePubkey(privkey)
58+
const pow = getPubkeyProofOfWork(pubkey)
59+
if ((mode === 'below' && pow < minLeadingZeroBits) || (mode === 'at least' && pow >= minLeadingZeroBits)) {
60+
return { name, privkey, pubkey }
61+
}
62+
} catch {
63+
continue
64+
}
65+
}
66+
67+
throw new Error(`Unable to mine pubkey PoW ${mode} ${minLeadingZeroBits}`)
68+
}
69+
70+
const mineEventForPow = async (
71+
pubkey: string,
72+
privkey: string,
73+
baseContent: string,
74+
minLeadingZeroBits: number,
75+
mode: PowMode,
76+
): Promise<{ event: Event; pow: number }> => {
77+
const createdAt = Math.floor(Date.now() / 1000)
78+
79+
for (let i = 0; i < MAX_MINING_ATTEMPTS; i++) {
80+
const event: Event = await createEvent(
81+
{
82+
pubkey,
83+
kind: 1,
84+
content: baseContent,
85+
tags: [['nonce', String(i)]],
86+
created_at: createdAt,
87+
},
88+
privkey,
89+
)
90+
91+
const pow = getEventProofOfWork(event.id)
92+
if ((mode === 'below' && pow < minLeadingZeroBits) || (mode === 'at least' && pow >= minLeadingZeroBits)) {
93+
return { event, pow }
94+
}
95+
}
96+
97+
throw new Error(`Unable to mine event ID PoW ${mode} ${minLeadingZeroBits}`)
98+
}
99+
100+
const sendEventAndCaptureCommand = async (ws: WebSocket, event: Event): Promise<Nip13CommandResult> => {
101+
const commandPromise = waitForCommand(ws)
102+
103+
await new Promise<void>((resolve, reject) => {
104+
ws.send(JSON.stringify(['EVENT', event]), (error?: Error) => {
105+
if (error) {
106+
reject(error)
107+
} else {
108+
resolve()
109+
}
110+
})
111+
})
112+
113+
return (await commandPromise) as Nip13CommandResult
114+
}
115+
116+
const storeCommand = (world: World<Record<string, any>>, name: string, command: Nip13CommandResult) => {
117+
ensureNip13State(world)
118+
world.parameters.nip13.commands[name] = command
119+
}
120+
121+
Given(/^NIP-13 event ID minimum leading zero bits is (\d+)$/, function (this: World<Record<string, any>>, bits: string) {
122+
setPowLimit(this, 'eventId', Number(bits))
123+
})
124+
125+
Given(/^NIP-13 pubkey minimum leading zero bits is (\d+)$/, function (this: World<Record<string, any>>, bits: string) {
126+
setPowLimit(this, 'pubkey', Number(bits))
127+
})
128+
129+
When(
130+
/^(\w+) sends a plain text_note event with content "([^"]+)" and records the command result$/,
131+
async function (this: World<Record<string, any>>, name: string, content: string) {
132+
const ws = this.parameters.clients[name] as WebSocket
133+
const { pubkey, privkey } = this.parameters.identities[name]
134+
const event: Event = await createEvent({ pubkey, kind: 1, content }, privkey)
135+
136+
const command = await sendEventAndCaptureCommand(ws, event)
137+
storeCommand(this, name, command)
138+
},
139+
)
140+
141+
When(
142+
/^(\w+) sends a text_note event with content "([^"]+)" and event ID PoW (below|at least) the required threshold$/,
143+
{ timeout: 20_000 },
144+
async function (this: World<Record<string, any>>, name: string, content: string, mode: PowMode) {
145+
const ws = this.parameters.clients[name] as WebSocket
146+
const { pubkey, privkey } = this.parameters.identities[name]
147+
const requiredBits = getRequiredBits('eventId')
148+
149+
const { event, pow } = await mineEventForPow(pubkey, privkey, content, requiredBits, mode)
150+
const command = await sendEventAndCaptureCommand(ws, event)
151+
storeCommand(this, name, command)
152+
153+
this.parameters.nip13.expectedEventIdReason = `pow: difficulty ${pow}<${requiredBits}`
154+
},
155+
)
156+
157+
When(
158+
/^(\w+) sends a text_note event with content "([^"]+)" and pubkey PoW (below|at least) the required threshold$/,
159+
{ timeout: 20_000 },
160+
async function (this: World<Record<string, any>>, name: string, content: string, mode: PowMode) {
161+
const ws = this.parameters.clients[name] as WebSocket
162+
const requiredBits = getRequiredBits('pubkey')
163+
164+
const identity = mineIdentityForPow(name, requiredBits, mode)
165+
this.parameters.identities[name] = identity
166+
167+
const event: Event = await createEvent({ pubkey: identity.pubkey, kind: 1, content }, identity.privkey)
168+
const command = await sendEventAndCaptureCommand(ws, event)
169+
storeCommand(this, name, command)
170+
171+
const pubkeyPow = getPubkeyProofOfWork(identity.pubkey)
172+
this.parameters.nip13.expectedPubkeyReason = `pow: pubkey difficulty ${pubkeyPow}<${requiredBits}`
173+
},
174+
)
175+
176+
Then(/^(\w+) receives a successful NIP-13 command result$/, function (this: World<Record<string, any>>, name: string) {
177+
const command = this.parameters.nip13.commands[name] as Nip13CommandResult
178+
179+
expect(command[0]).to.equal('OK')
180+
expect(command[2]).to.equal(true)
181+
})
182+
183+
Then(/^(\w+) receives an unsuccessful NIP-13 event ID PoW result$/, function (this: World<Record<string, any>>, name: string) {
184+
const command = this.parameters.nip13.commands[name] as Nip13CommandResult
185+
186+
expect(command[0]).to.equal('OK')
187+
expect(command[2]).to.equal(false)
188+
expect(command[3]).to.equal(this.parameters.nip13.expectedEventIdReason)
189+
})
190+
191+
Then(/^(\w+) receives an unsuccessful NIP-13 pubkey PoW result$/, function (this: World<Record<string, any>>, name: string) {
192+
const command = this.parameters.nip13.commands[name] as Nip13CommandResult
193+
194+
expect(command[0]).to.equal('OK')
195+
expect(command[2]).to.equal(false)
196+
expect(command[3]).to.equal(this.parameters.nip13.expectedPubkeyReason)
197+
})
198+
199+
After({ tags: '@nip13' }, function (this: World<Record<string, any>>) {
200+
const previousSettings = this.parameters.nip13?.previousSettings
201+
if (previousSettings) {
202+
SettingsStatic._settings = previousSettings
203+
}
204+
205+
this.parameters.nip13 = undefined
206+
})

0 commit comments

Comments
 (0)