Skip to content

Commit f5093cb

Browse files
feat: add NIP-13 Proof of Work enforcement scenarios and implementation (#513)
* feat: add NIP-13 Proof of Work enforcement scenarios and implementation * chore: add empty changeset for integration test PR * test(nip-13): reuse sendEvent for id-scoped OK handling
1 parent 78e32bd commit f5093cb

3 files changed

Lines changed: 254 additions & 0 deletions

File tree

.changeset/old-toys-stare.md

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

0 commit comments

Comments
 (0)