Skip to content

Commit 26fe725

Browse files
committed
chore: add integration for NIP-03 (#105)
1 parent 0877d4f commit 26fe725

2 files changed

Lines changed: 145 additions & 0 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
Feature: NIP-03 OpenTimestamps
2+
Scenario: Alice publishes a valid OpenTimestamps attestation for her text note
3+
Given someone called Alice
4+
When Alice sends a text_note event with content "anchor this note"
5+
And Alice sends a valid OpenTimestamps attestation for her last text_note event
6+
And Alice subscribes to OpenTimestamps events from Alice
7+
Then Alice receives an OpenTimestamps attestation from Alice for her last text_note event
8+
9+
Scenario: Alice cannot publish an attestation whose OTS digest does not match the e tag
10+
Given someone called Alice
11+
When Alice sends a text_note event with content "wrong digest"
12+
And Alice sends an OpenTimestamps attestation with mismatching OTS digest for her last text_note event
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { Then, When } from '@cucumber/cucumber'
2+
import { expect } from 'chai'
3+
import WebSocket from 'ws'
4+
5+
import { createEvent, createSubscription, sendEvent, waitForNextEvent } from '../helpers'
6+
import { Event } from '../../../../src/@types/event'
7+
import { Tag } from '../../../../src/@types/base'
8+
import { EventKinds, EventTags } from '../../../../src/constants/base'
9+
10+
// Minimal OpenTimestamps v1 proof (SHA-256 file hash + Bitcoin block attestation), aligned with
11+
// `test/unit/utils/nip03.spec.ts` — exercises the same parser path the relay accepts in production.
12+
13+
const MAGIC = Buffer.from([
14+
0x00, 0x4f, 0x70, 0x65, 0x6e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x73, 0x00, 0x00, 0x50, 0x72,
15+
0x6f, 0x6f, 0x66, 0x00, 0xbf, 0x89, 0xe2, 0xe8, 0x84, 0xe8, 0x92, 0x94,
16+
])
17+
18+
const BITCOIN_TAG = Buffer.from([0x05, 0x88, 0x96, 0x0d, 0x73, 0xd7, 0x19, 0x01])
19+
const OP_SHA256 = 0x08
20+
const TAG_ATTESTATION = 0x00
21+
22+
function writeVarUint(n: number): Buffer {
23+
if (n === 0) {
24+
return Buffer.from([0])
25+
}
26+
const out: number[] = []
27+
let v = n
28+
while (v !== 0) {
29+
let b = v & 0x7f
30+
v = Math.floor(v / 128)
31+
if (v !== 0) {
32+
b |= 0x80
33+
}
34+
out.push(b)
35+
}
36+
return Buffer.from(out)
37+
}
38+
39+
function writeVarBytes(buf: Buffer): Buffer {
40+
return Buffer.concat([writeVarUint(buf.length), buf])
41+
}
42+
43+
function bitcoinAttestation(height: number): Buffer {
44+
const payload = writeVarUint(height)
45+
return Buffer.concat([Buffer.from([TAG_ATTESTATION]), BITCOIN_TAG, writeVarBytes(payload)])
46+
}
47+
48+
/** Base64-encoded .ots whose SHA-256 file digest equals `digestHex` (the attested event id). */
49+
function buildMinimalOtsBase64(digestHex: string, blockHeight = 810391): string {
50+
const digest = Buffer.from(digestHex, 'hex')
51+
return Buffer.concat([
52+
MAGIC,
53+
writeVarUint(1),
54+
Buffer.from([OP_SHA256]),
55+
digest,
56+
bitcoinAttestation(blockHeight),
57+
]).toString('base64')
58+
}
59+
60+
function lastTextNoteFor(events: Event[] | undefined): Event | undefined {
61+
if (!events?.length) {
62+
return undefined
63+
}
64+
for (let i = events.length - 1; i >= 0; i--) {
65+
if (events[i].kind === 1) {
66+
return events[i]
67+
}
68+
}
69+
return undefined
70+
}
71+
72+
When(/^(\w+) sends a valid OpenTimestamps attestation for her last text_note event$/, async function (name: string) {
73+
const ws = this.parameters.clients[name] as WebSocket
74+
const { pubkey, privkey } = this.parameters.identities[name]
75+
const note = lastTextNoteFor(this.parameters.events[name] as Event[])
76+
expect(note, 'last text_note').to.exist
77+
78+
const content = buildMinimalOtsBase64(note!.id)
79+
const tags: Tag[] = [
80+
[EventTags.Event, note!.id, 'wss://localhost:18808'],
81+
[EventTags.Kind, String(1)],
82+
]
83+
const event: Event = await createEvent({ pubkey, kind: EventKinds.OPEN_TIMESTAMPS, content, tags }, privkey)
84+
await sendEvent(ws, event)
85+
this.parameters.events[name].push(event)
86+
})
87+
88+
When(
89+
/^(\w+) sends an OpenTimestamps attestation with mismatching OTS digest for her last text_note event$/,
90+
async function (name: string) {
91+
const ws = this.parameters.clients[name] as WebSocket
92+
const { pubkey, privkey } = this.parameters.identities[name]
93+
const note = lastTextNoteFor(this.parameters.events[name] as Event[])
94+
expect(note, 'last text_note').to.exist
95+
96+
const content = buildMinimalOtsBase64('0'.repeat(64))
97+
const tags: Tag[] = [
98+
[EventTags.Event, note!.id, 'wss://localhost:18808'],
99+
[EventTags.Kind, String(1)],
100+
]
101+
const event: Event = await createEvent({ pubkey, kind: EventKinds.OPEN_TIMESTAMPS, content, tags }, privkey)
102+
await sendEvent(ws, event, false)
103+
},
104+
)
105+
106+
When(/^(\w+) subscribes to OpenTimestamps events from (\w+)$/, async function (name: string, author: string) {
107+
const ws = this.parameters.clients[name] as WebSocket
108+
const pubkey = this.parameters.identities[author].pubkey
109+
const subscription = {
110+
name: `test-${Math.random()}`,
111+
filters: [{ kinds: [EventKinds.OPEN_TIMESTAMPS], authors: [pubkey] }],
112+
}
113+
this.parameters.subscriptions[name].push(subscription)
114+
await createSubscription(ws, subscription.name, subscription.filters)
115+
})
116+
117+
Then(
118+
/^(\w+) receives an OpenTimestamps attestation from (\w+) for her last text_note event$/,
119+
async function (recipient: string, author: string) {
120+
const ws = this.parameters.clients[recipient] as WebSocket
121+
const subscription = this.parameters.subscriptions[recipient][this.parameters.subscriptions[recipient].length - 1]
122+
const received = (await waitForNextEvent(ws, subscription.name)) as Event
123+
const note = lastTextNoteFor(this.parameters.events[author] as Event[])
124+
125+
expect(received.kind).to.equal(EventKinds.OPEN_TIMESTAMPS)
126+
expect(received.pubkey).to.equal(this.parameters.identities[author].pubkey)
127+
const eTags = received.tags.filter((t) => t[0] === EventTags.Event && t.length >= 2)
128+
expect(eTags.length).to.equal(1)
129+
expect(eTags[0][1]).to.equal(note?.id)
130+
const kTag = received.tags.find((t) => t[0] === EventTags.Kind)
131+
expect(kTag?.[1]).to.equal('1')
132+
},
133+
)

0 commit comments

Comments
 (0)