diff --git a/.changeset/strfry-packed-events.md b/.changeset/strfry-packed-events.md new file mode 100644 index 000000000..9bb80ade0 --- /dev/null +++ b/.changeset/strfry-packed-events.md @@ -0,0 +1,5 @@ +--- +"nostream": minor +--- + +feat(strfry): decode packed event records and verify them against strfry exports diff --git a/.gitignore b/.gitignore index 9648fc276..78facac11 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,7 @@ dist docker-compose.overrides.yml # Export output *.jsonl +!test/unit/data/*.jsonl # Code coverage report output coverage-report/ diff --git a/.knip.json b/.knip.json index 4151e7b65..c5928f077 100644 --- a/.knip.json +++ b/.knip.json @@ -5,6 +5,7 @@ "src/import-events.ts!", "src/cli/index.ts!", "src/scripts/benchmark-queries.ts!", + "src/scripts/verify-strfry-export.ts!", "knexfile.js!" ], "project": [ diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 0cdd83603..f5dfc18a2 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -119,6 +119,17 @@ pnpm db:verify-index-impact The hot-path index migration (`20260420_120000_add_hot_path_indexes.js`) uses `CREATE INDEX CONCURRENTLY`, so it can be applied to a running relay without taking `ACCESS EXCLUSIVE` locks on the `events` or `invoices` tables. +## Checking strfry's storage format + +`pnpm strfry:verify-export` checks that nostream reads strfry's packed event records the way strfry writes them. Export with strfry's `--fried` flag, which adds each event's packed record to the dump, and run the check on it: + +```sh +strfry export --fried > export.jsonl +pnpm strfry:verify-export export.jsonl +``` + +Every event's packed record must match the one nostream builds from the event JSON byte for byte, and its id and signature must be valid. `--skip-signatures` skips the slowest check, and `-` reads the export from stdin. + ## Tag filter scope Subscription filters support single-letter tag filters using the `#` key syntax (NIP-01). Both lowercase (`#a`–`#z`) and uppercase (`#A`–`#Z`) variants are accepted. diff --git a/package.json b/package.json index e6ef2dbf9..38db24bd4 100644 --- a/package.json +++ b/package.json @@ -91,6 +91,7 @@ "test:performance:message-rate-limit": "k6 run test/performance/message-limiting-k6.ts", "cover:integration": "nyc --report-dir .coverage/integration pnpm run test:integration -p cover", "export": "node --env-file-if-exists=.env -r ts-node/register src/scripts/export-events.ts", + "strfry:verify-export": "node -r ts-node/register src/scripts/verify-strfry-export.ts", "docker:compose:start": "pnpm run cli -- start", "docker:compose:stop": "pnpm run cli -- stop", "docker:compose:clean": "pnpm run cli -- clean", diff --git a/src/scripts/verify-strfry-export.ts b/src/scripts/verify-strfry-export.ts new file mode 100644 index 000000000..c4262f53c --- /dev/null +++ b/src/scripts/verify-strfry-export.ts @@ -0,0 +1,233 @@ +import fs from 'fs' +import readline from 'readline' +import { resolve } from 'path' +import { Readable } from 'stream' + +import { Event } from '../@types/event' +import { isEventIdValid, isEventSignatureValid } from '../utils/event' +import { PackedEventFields, PackedEventView, serializePackedEvent, toPackedEventFields } from '../utils/strfry' + +type VerifyCliOptions = { + filePath: string + checkSignatures: boolean + showHelp: boolean +} + +export type VerifyStats = { + checked: number + failed: number +} + +const MAX_REPORTED_FAILURES = 20 +const PROGRESS_INTERVAL = 100_000 + +const printUsage = (): void => { + console.log(`Usage: pnpm strfry:verify-export [--skip-signatures] + +Checks every event in a \`strfry export --fried\` dump against nostream's packed event +decoder, plus its id and signature. Use - to read from stdin.`) +} + +export const parseCliArgs = (args: string[]): VerifyCliOptions => { + if (args.includes('--help') || args.includes('-h')) { + return { filePath: '', checkSignatures: true, showHelp: true } + } + + let filePath: string | undefined + let checkSignatures = true + + for (const arg of args) { + if (arg === '--skip-signatures') { + checkSignatures = false + continue + } + + if (arg.startsWith('-') && arg !== '-') { + throw new Error(`Unknown option: ${arg}`) + } + + if (filePath) { + throw new Error(`Unexpected extra argument: ${arg}`) + } + + filePath = arg + } + + if (!filePath) { + throw new Error('Missing path to a strfry export (use - to read stdin)') + } + + return { filePath, checkSignatures, showHelp: false } +} + +const findFirstDifference = (a: Buffer, b: Buffer): number => { + const length = Math.min(a.length, b.length) + for (let i = 0; i < length; i++) { + if (a[i] !== b[i]) { + return i + } + } + + return length +} + +const describeTagMismatch = (decoded: PackedEventView, expected: PackedEventFields): string | undefined => { + const tags = decoded.tags() + if (tags.length !== expected.tags.length) { + return `Decoded ${tags.length} tags, expected ${expected.tags.length}` + } + + const index = tags.findIndex( + (tag, i) => tag.name !== expected.tags[i].name || !tag.value.equals(expected.tags[i].value), + ) + + return index === -1 ? undefined : `Decoded tag ${index} differs` +} + +// Returns why the line fails, or undefined if it passes. +export const verifyFriedLine = async (line: string, checkSignatures: boolean): Promise => { + let parsed: unknown + try { + parsed = JSON.parse(line) + } catch { + return 'Not valid JSON' + } + + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return 'Not a JSON object' + } + + const { fried, ...rest } = parsed as Record + if (typeof fried !== 'string') { + return 'No "fried" field, export with strfry export --fried' + } + + if (fried.length % 2 !== 0 || !/^[0-9a-f]*$/.test(fried)) { + return 'The "fried" field is not lowercase hex' + } + + const event = rest as unknown as Event + const record = Buffer.from(fried, 'hex') + + try { + const expected = toPackedEventFields(event) + const built = serializePackedEvent(expected) + if (!built.equals(record)) { + return `Packed record differs from strfry's at byte ${findFirstDifference(built, record)}` + } + + const decoded = new PackedEventView(record) + if (!decoded.id.equals(expected.id)) { + return 'Decoded id differs' + } + if (!decoded.pubkey.equals(expected.pubkey)) { + return 'Decoded pubkey differs' + } + if (decoded.createdAt !== expected.createdAt) { + return 'Decoded created_at differs' + } + if (decoded.kind !== expected.kind) { + return 'Decoded kind differs' + } + if (decoded.expiration !== expected.expiration) { + return 'Decoded expiration differs' + } + + const tagMismatch = describeTagMismatch(decoded, expected) + if (tagMismatch) { + return tagMismatch + } + + if (!(await isEventIdValid(event))) { + return 'Id does not match the event hash' + } + if (checkSignatures && !(await isEventSignatureValid(event))) { + return 'Invalid signature' + } + } catch (error) { + return error instanceof Error ? error.message : String(error) + } + + return undefined +} + +export const verifyFriedExport = async ( + input: Readable, + checkSignatures: boolean, + onFailure: (lineNumber: number, reason: string) => void, + onProgress?: (stats: VerifyStats) => void, +): Promise => { + const stats: VerifyStats = { checked: 0, failed: 0 } + const lines = readline.createInterface({ input, crlfDelay: Infinity }) + let lineNumber = 0 + + for await (const line of lines) { + lineNumber++ + if (!line.trim()) { + continue + } + + stats.checked++ + const reason = await verifyFriedLine(line, checkSignatures) + if (reason) { + stats.failed++ + onFailure(lineNumber, reason) + } + + if (onProgress && stats.checked % PROGRESS_INTERVAL === 0) { + onProgress(stats) + } + } + + return stats +} + +export const runVerifyStrfryExport = async (args: string[] = process.argv.slice(2)): Promise => { + const options = parseCliArgs(args) + if (options.showHelp) { + printUsage() + return 0 + } + + const input = options.filePath === '-' ? process.stdin : fs.createReadStream(resolve(options.filePath)) + let reported = 0 + + const stats = await verifyFriedExport( + input, + options.checkSignatures, + (lineNumber, reason) => { + if (reported < MAX_REPORTED_FAILURES) { + reported++ + console.error(`line ${lineNumber}: ${reason}`) + } + }, + (progress) => console.log(`Checked ${progress.checked.toLocaleString('en-US')} events...`), + ) + + if (stats.checked === 0) { + console.error('No events found in the export.') + return 1 + } + + const notes = [ + ...(options.checkSignatures ? [] : ['signatures skipped']), + ...(stats.failed > MAX_REPORTED_FAILURES ? [`first ${MAX_REPORTED_FAILURES} failures listed`] : []), + ] + const suffix = notes.length ? ` (${notes.join(', ')})` : '' + console.log( + `Checked ${stats.checked.toLocaleString('en-US')} events, ${stats.failed.toLocaleString('en-US')} failed${suffix}.`, + ) + + return stats.failed === 0 ? 0 : 1 +} + +if (require.main === module) { + runVerifyStrfryExport() + .then((code) => { + process.exitCode = code + }) + .catch((error) => { + console.error('Verification failed:', error.message) + process.exit(1) + }) +} diff --git a/src/utils/strfry.ts b/src/utils/strfry.ts new file mode 100644 index 000000000..bbcd63d97 --- /dev/null +++ b/src/utils/strfry.ts @@ -0,0 +1,208 @@ +import { Event } from '../@types/event' + +// strfry's packed event layout (PackedEvent.h), integers little-endian: +// 0 id (32) | 32 pubkey (32) | 64 created_at (8) | 72 kind (8) | 80 expiration (8) +// 88 tags: name (1), length (1), value +export const PACKED_EVENT_HEADER_SIZE = 88 + +const MAX_PACKED_TAG_VALUE_SIZE = 255 +const MAX_UINT64 = 0xffff_ffff_ffff_ffffn +const MIN_EXPIRATION = 100n +const EPHEMERAL_EXPIRATION = 1n + +export interface PackedTag { + name: string + value: Buffer +} + +// Throws instead of rounding above 2^53. +const readUInt53 = (buffer: Buffer, offset: number): number => { + const high = buffer.readUInt32LE(offset + 4) + if (high > 0x1f_ffff) { + throw new RangeError(`Packed event value at byte ${offset} is above 2^53`) + } + + return buffer.readUInt32LE(offset) + high * 0x1_0000_0000 +} + +export class PackedEventView { + public constructor(private readonly buffer: Buffer) { + if (buffer.length < PACKED_EVENT_HEADER_SIZE) { + throw new Error(`Packed event is ${buffer.length} bytes, expected at least ${PACKED_EVENT_HEADER_SIZE}`) + } + } + + public get id(): Buffer { + return this.buffer.subarray(0, 32) + } + + public get pubkey(): Buffer { + return this.buffer.subarray(32, 64) + } + + public get createdAt(): number { + return readUInt53(this.buffer, 64) + } + + public get kind(): number { + return readUInt53(this.buffer, 72) + } + + // 0 = never expires, 1 = ephemeral + public get expiration(): bigint { + return this.buffer.readBigUInt64LE(80) + } + + public tags(): PackedTag[] { + const tags: PackedTag[] = [] + let offset = PACKED_EVENT_HEADER_SIZE + + while (offset < this.buffer.length) { + if (offset + 2 > this.buffer.length) { + throw new Error(`Packed event ends inside a tag header at byte ${offset}`) + } + + const end = offset + 2 + this.buffer[offset + 1] + if (end > this.buffer.length) { + throw new Error(`Packed event tag at byte ${offset} runs past the end of the record`) + } + + tags.push({ name: String.fromCharCode(this.buffer[offset]), value: this.buffer.subarray(offset + 2, end) }) + offset = end + } + + return tags + } +} + +// Kind ranges from strfry's EventUtils.h. +const isReplaceableKind = (kind: number): boolean => + kind === 0 || kind === 3 || kind === 41 || (kind >= 10000 && kind < 20000) + +const isParameterizedReplaceableKind = (kind: number): boolean => kind >= 30000 && kind < 40000 + +const isEphemeralKind = (kind: number): boolean => kind >= 20000 && kind < 30000 + +// Like hoytech::from_hex: optional 0x prefix, any case, even length only. +const fromHex = (input: unknown): Buffer => { + if (typeof input !== 'string') { + throw new Error(`Expected a hex string, got ${typeof input}`) + } + + const hex = input.startsWith('0x') ? input.slice(2) : input + + if (hex.length % 2 !== 0 || !/^[0-9a-f]*$/i.test(hex)) { + throw new Error(`Invalid hex: ${input}`) + } + + return Buffer.from(hex, 'hex') +} + +const parseExpiration = (value: string): bigint => { + const expiration = /^\d+$/.test(value) ? BigInt(value) : -1n + if (expiration < MIN_EXPIRATION || expiration > MAX_UINT64) { + throw new Error(`Invalid expiration tag: ${value}`) + } + + return expiration +} + +const isUInt53 = (value: unknown): value is number => Number.isSafeInteger(value) && (value as number) >= 0 + +export interface PackedEventFields { + id: Buffer + pubkey: Buffer + createdAt: number + kind: number + expiration: bigint + tags: PackedTag[] +} + +// Mirrors nostrJsonToPackedEvent in strfry's events.cpp. +export const toPackedEventFields = (event: Event): PackedEventFields => { + const id = fromHex(event.id) + const pubkey = fromHex(event.pubkey) + if (id.length !== 32 || pubkey.length !== 32) { + throw new Error('Event id and pubkey must be 32 bytes') + } + + if (!isUInt53(event.created_at) || !isUInt53(event.kind)) { + throw new Error('Event created_at and kind must be non-negative integers') + } + + if (!Array.isArray(event.tags)) { + throw new Error('Event tags must be an array') + } + + const tags: PackedTag[] = [] + let expiration = 0n + + // strfry adds an empty d tag, first for replaceable kinds and last for parameterized ones. + if (isReplaceableKind(event.kind)) { + tags.push({ name: 'd', value: Buffer.alloc(0) }) + } + + for (const tag of event.tags) { + if (!Array.isArray(tag) || tag.length < 1 || typeof tag[0] !== 'string') { + throw new Error('Every tag needs a name') + } + + const name = tag[0] + const value = tag.length >= 2 ? tag[1] : '' + if (typeof value !== 'string') { + throw new Error(`Tag ${name} has a non-string value`) + } + + if (Buffer.byteLength(name) === 1) { + let packed: Buffer + if (name === 'e' || name === 'p') { + // Length is checked before decoding, so "0x" + 62 hex chars packs to 31 bytes. + if (Buffer.byteLength(value) !== 64) { + throw new Error(`Tag ${name} must be 64 hex characters`) + } + packed = fromHex(value) + } else { + packed = Buffer.from(value) + } + + if (packed.length <= MAX_PACKED_TAG_VALUE_SIZE) { + tags.push({ name, value: packed }) + } + } else if (name === 'expiration' && expiration === 0n) { + expiration = parseExpiration(value) + } + } + + if (isParameterizedReplaceableKind(event.kind)) { + tags.push({ name: 'd', value: Buffer.alloc(0) }) + } + + if (isEphemeralKind(event.kind)) { + expiration = EPHEMERAL_EXPIRATION + } + + return { id, pubkey, createdAt: event.created_at, kind: event.kind, expiration, tags } +} + +export const serializePackedEvent = (fields: PackedEventFields): Buffer => { + if (fields.id.length !== 32 || fields.pubkey.length !== 32) { + throw new Error('Event id and pubkey must be 32 bytes') + } + + const header = Buffer.alloc(PACKED_EVENT_HEADER_SIZE) + fields.id.copy(header, 0) + fields.pubkey.copy(header, 32) + header.writeBigUInt64LE(BigInt(fields.createdAt), 64) + header.writeBigUInt64LE(BigInt(fields.kind), 72) + header.writeBigUInt64LE(fields.expiration, 80) + + const tags = fields.tags.map(({ name, value }) => { + if (Buffer.byteLength(name) !== 1 || value.length > MAX_PACKED_TAG_VALUE_SIZE) { + throw new Error(`Tag ${name} cannot be packed`) + } + + return Buffer.concat([Buffer.from([name.charCodeAt(0), value.length]), value]) + }) + + return Buffer.concat([header, ...tags]) +} diff --git a/test/unit/data/strfry-fried.jsonl b/test/unit/data/strfry-fried.jsonl new file mode 100644 index 000000000..80e9a36c2 --- /dev/null +++ b/test/unit/data/strfry-fried.jsonl @@ -0,0 +1,25 @@ +{"content":"old event","created_at":1600000000,"id":"721b61ef1825b5d0f82cf59c1c132df71b36a47b9d4eb07e51b7b908b46c0be2","kind":1,"pubkey":"acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe","sig":"97e7f27b427a74d2efb675312d30a55f88e3c22514663e6035f244e5cd09220660cf6f1c9b44e4485a6fda06c588524c6903e0dbd8beabfdbf50182fd4250301","tags":[],"fried":"721b61ef1825b5d0f82cf59c1c132df71b36a47b9d4eb07e51b7b908b46c0be2acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe00105e5f0000000001000000000000000000000000000000"} +{"content":"hello world","created_at":1700000000,"id":"f11fc1353e376d748fba625e6fd94caa7516d0c8c2257971105fd0cef33f936d","kind":1,"pubkey":"5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","sig":"e87cae150069ce503947ee198d10ef4d4795f15eb586a7e46bb19e1673fd8ba4db94f1023a57b1a1867a2c4fc0633e637b6ae7bae1a607de46faa724293ff01e","tags":[["e","f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0","wss://relay.example.com","reply"],["p","acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe"],["t","nostr"],["subject","hi"],["client","nostream"]],"fried":"f11fc1353e376d748fba625e6fd94caa7516d0c8c2257971105fd0cef33f936d5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc00f1536500000000010000000000000000000000000000006520f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f07020acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe74056e6f737472"} +{"content":"long values","created_at":1700000001,"id":"0390fd2fed7ad960a450fa26799edf80868a309c16f3c4ca7734428552cadfa4","kind":1,"pubkey":"5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","sig":"aeea965ec68f8da6bcdaea6bbeee0d41caeaf83d2cf497284b85459d4feb0765ab9d611ad07d1fb883ff7919d44a016eb930fd1546cedcc2fc973d38b6caf337","tags":[["t","aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"],["r","bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"],["x","cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"]],"fried":"0390fd2fed7ad960a450fa26799edf80868a309c16f3c4ca7734428552cadfa45cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc01f15365000000000100000000000000000000000000000074ff616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161"} +{"content":"unicode \"quotes\" \\ backslash\nnewline 🚀","created_at":1700000002,"id":"03e3e5be514bc015618c56a195963db3e15aea1c198a7067a855643a3c10330b","kind":1,"pubkey":"acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe","sig":"f50b8d22b43a7330bccdeaf39b3d35e6121e0f3aef616ad25bab539bb25e2d8d4fdb51e6e36695fe6236471781d736855accb10d5ee2c36924a4887a2dfb916b","tags":[["t","ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé"],["t","éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé"],["t","nøstr✓"],["t",""]],"fried":"03e3e5be514bc015618c56a195963db3e15aea1c198a7067a855643a3c10330bacd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe02f15365000000000100000000000000000000000000000074fec3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a974096ec3b8737472e29c937400"} +{"content":"name-only and protected tags","created_at":1700000003,"id":"461113999479a11268f64c64e78b8800b390316af738dcfd4af23343005181ae","kind":1,"pubkey":"acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe","sig":"2dbbf9397144f34ea447e2377c529e936c8ebb78be7b935e0740bf84397fb8f619cfc9bfb77f8ab0e0302d371cd80e8c807facdba7ebaf567bd58dd0abdbc319","tags":[["t"],["-"]],"fried":"461113999479a11268f64c64e78b8800b390316af738dcfd4af23343005181aeacd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe03f15365000000000100000000000000000000000000000074002d00"} +{"content":"non-ascii and two-letter names","created_at":1700000004,"id":"fdeaaa91148916ce587746cf83f5b8743a3eb4d97f0bc2556d1b07ae12778504","kind":1,"pubkey":"5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","sig":"302617f0249f275a4c8a34918dd410af1e9706c885422d4d527e1684efb7c09578cf677049156a27a5b9ecdd7d035b4f8e66d58b9c31629fb050b00b692b6200","tags":[["é","x"],["ab","y"]],"fried":"fdeaaa91148916ce587746cf83f5b8743a3eb4d97f0bc2556d1b07ae127785045cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc04f153650000000001000000000000000000000000000000"} +{"content":"two expiration tags","created_at":1700000005,"id":"46ec1c085443adf449786e5c08b79266295ddf9d99ba75d5e36b8617c47fd547","kind":1,"pubkey":"5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","sig":"ef6306f67c434256d2671f84d574691579bec44ff2a2de66a0fd02295d60378989cdf5b3cbbb6e71884c33b71541bfcdd54fb2687b5f819e51a6e706b5c9b3df","tags":[["expiration","4102444800"],["expiration","later"]],"fried":"46ec1c085443adf449786e5c08b79266295ddf9d99ba75d5e36b8617c47fd5475cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc05f15365000000000100000000000000005786f400000000"} +{"content":"{\"name\":\"alice\"}","created_at":1700000006,"id":"ece2c6e4adf866e4b2b135adf9b33047191fd775b561184c1e189b5d54c2ebef","kind":0,"pubkey":"5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","sig":"8fced3599b8c915b54efb034c448bae60a78cfdac5969a4c8aaaa4b037c4e8dd25061afca4bfd16e6c157df9320774f3330be97fb32ef160cba19ee4b3e1c424","tags":[],"fried":"ece2c6e4adf866e4b2b135adf9b33047191fd775b561184c1e189b5d54c2ebef5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc06f1536500000000000000000000000000000000000000006400"} +{"content":"","created_at":1700000007,"id":"e4d54d24324242b05c02bf21b153d558bd28c978eca14b2c2961bce11093d94d","kind":3,"pubkey":"acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe","sig":"8495058f41cd74a70bd582b1a095842e2853b012d4820fd0d098e62fef04731171122fa5749e7717975cf4388677327a693010805994fa41e132793740a656b0","tags":[["p","acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe","","pet0"],["p","5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","","pet1"],["p","acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe","","pet2"],["p","5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","","pet3"],["p","acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe","","pet4"],["p","5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","","pet5"],["p","acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe","","pet6"],["p","5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","","pet7"],["p","acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe","","pet8"],["p","5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","","pet9"],["p","acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe","","pet10"],["p","5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","","pet11"],["p","acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe","","pet12"],["p","5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","","pet13"],["p","acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe","","pet14"],["p","5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","","pet15"],["p","acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe","","pet16"],["p","5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","","pet17"],["p","acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe","","pet18"],["p","5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","","pet19"],["p","acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe","","pet20"],["p","5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","","pet21"],["p","acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe","","pet22"],["p","5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","","pet23"],["p","acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe","","pet24"],["p","5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","","pet25"],["p","acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe","","pet26"],["p","5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","","pet27"],["p","acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe","","pet28"],["p","5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","","pet29"],["p","acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe","","pet30"],["p","5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","","pet31"],["p","acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe","","pet32"],["p","5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","","pet33"],["p","acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe","","pet34"],["p","5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","","pet35"],["p","acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe","","pet36"],["p","5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","","pet37"],["p","acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe","","pet38"],["p","5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","","pet39"],["p","acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe","","pet40"],["p","5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","","pet41"],["p","acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe","","pet42"],["p","5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","","pet43"],["p","acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe","","pet44"],["p","5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","","pet45"],["p","acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe","","pet46"],["p","5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","","pet47"],["p","acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe","","pet48"],["p","5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","","pet49"],["p","acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe","","pet50"],["p","5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","","pet51"],["p","acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe","","pet52"],["p","5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","","pet53"],["p","acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe","","pet54"],["p","5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","","pet55"],["p","acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe","","pet56"],["p","5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","","pet57"],["p","acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe","","pet58"],["p","5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","","pet59"],["p","acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe","","pet60"],["p","5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","","pet61"],["p","acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe","","pet62"],["p","5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","","pet63"],["p","acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe","","pet64"],["p","5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","","pet65"],["p","acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe","","pet66"],["p","5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","","pet67"],["p","acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe","","pet68"],["p","5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","","pet69"],["p","acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe","","pet70"],["p","5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","","pet71"],["p","acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe","","pet72"],["p","5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","","pet73"],["p","acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe","","pet74"],["p","5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","","pet75"],["p","acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe","","pet76"],["p","5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","","pet77"],["p","acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe","","pet78"],["p","5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","","pet79"],["p","acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe","","pet80"],["p","5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","","pet81"],["p","acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe","","pet82"],["p","5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","","pet83"],["p","acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe","","pet84"],["p","5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","","pet85"],["p","acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe","","pet86"],["p","5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","","pet87"],["p","acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe","","pet88"],["p","5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","","pet89"],["p","acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe","","pet90"],["p","5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","","pet91"],["p","acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe","","pet92"],["p","5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","","pet93"],["p","acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe","","pet94"],["p","5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","","pet95"],["p","acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe","","pet96"],["p","5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","","pet97"],["p","acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe","","pet98"],["p","5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","","pet99"]],"fried":"e4d54d24324242b05c02bf21b153d558bd28c978eca14b2c2961bce11093d94dacd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe07f15365000000000300000000000000000000000000000064007020acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe70205cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc7020acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe70205cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc7020acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe70205cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc7020acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe70205cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc7020acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe70205cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc7020acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe70205cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc7020acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe70205cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc7020acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe70205cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc7020acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe70205cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc7020acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe70205cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc7020acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe70205cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc7020acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe70205cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc7020acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe70205cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc7020acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe70205cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc7020acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe70205cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc7020acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe70205cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc7020acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe70205cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc7020acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe70205cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc7020acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe70205cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc7020acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe70205cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc7020acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe70205cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc7020acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe70205cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc7020acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe70205cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc7020acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe70205cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc7020acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe70205cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc7020acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe70205cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc7020acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe70205cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc7020acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe70205cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc7020acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe70205cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc7020acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe70205cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc7020acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe70205cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc7020acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe70205cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc7020acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe70205cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc7020acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe70205cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc7020acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe70205cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc7020acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe70205cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc7020acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe70205cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc7020acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe70205cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc7020acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe70205cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc7020acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe70205cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc7020acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe70205cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc7020acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe70205cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc7020acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe70205cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc7020acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe70205cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc7020acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe70205cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc7020acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe70205cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc7020acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe70205cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc7020acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe70205cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc7020acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe70205cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc7020acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe70205cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc"} +{"content":"{\"name\":\"channel\"}","created_at":1700000008,"id":"95bdf4480eadf40c709e4b56d92b9fab4ed921e15cb04fae211145d00cd2580c","kind":41,"pubkey":"5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","sig":"2d56644dc77d139350b22578697ee60e8950b45e40f59a8dd468c78f3449f4a542db901d9382fd3ca60e766af13a8bdc1552a9071b6c807884b2ad386a559e57","tags":[["e","f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0"]],"fried":"95bdf4480eadf40c709e4b56d92b9fab4ed921e15cb04fae211145d00cd2580c5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc08f15365000000002900000000000000000000000000000064006520f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0"} +{"content":"","created_at":1700000009,"id":"b4cd91d917f4dfc006742f9a70e0f08d6bae1b04f1db534f8bf49f5452b261aa","kind":10002,"pubkey":"acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe","sig":"12352ad64d725dd95413318d1a0ee7b61f5cba909db0476169aacf036db2bef5ed3980f0ceea1e8f0b3084f8a4080c4e99517cb579940ab1c0837211c2b3896a","tags":[["r","wss://relay.example.com","read"],["r","wss://other.example.com"]],"fried":"b4cd91d917f4dfc006742f9a70e0f08d6bae1b04f1db534f8bf49f5452b261aaacd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe09f153650000000012270000000000000000000000000000640072177773733a2f2f72656c61792e6578616d706c652e636f6d72177773733a2f2f6f746865722e6578616d706c652e636f6d"} +{"content":"last replaceable kind","created_at":1700000010,"id":"1c49bd5dc8af68cba491f823ef789c5ebd0a8bb61f0f75e33e0951f1d141c09b","kind":19999,"pubkey":"5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","sig":"6021931b220c0b6cc0c15f7fcf0126a05206205faa13b8ee1d5a6e808c87c07cb1878d2fd631a4d7a76228b5741e22500e10809231e02c078aa698639c485184","tags":[["t","edge"]],"fried":"1c49bd5dc8af68cba491f823ef789c5ebd0a8bb61f0f75e33e0951f1d141c09b5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc0af15365000000001f4e00000000000000000000000000006400740465646765"} +{"content":"# article","created_at":1700000011,"id":"cd6af056bce756a7986025a7635530feaa3a287b29bd2341a53726a9113c7b86","kind":30023,"pubkey":"5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","sig":"aaf3a919b34b6a8192595eac6374132557f0d53bcdffa71cc09c726635ed456d3127723267e8ee0d160c33bfa9d151205b2631115f6b02e7c139cc2f4466de4f","tags":[["d","my-article"],["t","longform"],["published_at","1700000000"]],"fried":"cd6af056bce756a7986025a7635530feaa3a287b29bd2341a53726a9113c7b865cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc0bf153650000000047750000000000000000000000000000640a6d792d61727469636c6574086c6f6e67666f726d6400"} +{"content":"addressable without a d tag","created_at":1700000012,"id":"7a3975f6343637cf7bf0c9b1d763ffdb1b73cf342e5f32ac1c2a5721499d9abd","kind":30000,"pubkey":"acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe","sig":"e4497f5f6d653a953c4190058d8543da7b9b377a1a6ad4e71d839733b2eab375810c29b7daf0358a7233750d05940bcb2451ef5f923da6696c6d60ccfc9213de","tags":[["p","5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc"]],"fried":"7a3975f6343637cf7bf0c9b1d763ffdb1b73cf342e5f32ac1c2a5721499d9abdacd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe0cf15365000000003075000000000000000000000000000070205cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc6400"} +{"content":"last addressable kind","created_at":1700000013,"id":"7de7905952ac662496b9436d85ed3727e8858569e5430b9ef8e8aae5f415d3c4","kind":39999,"pubkey":"acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe","sig":"042925db0221865de633bbc08847875b5d8b36880a4e746202d1f8d5377a077d29a04a28d3942dfc5ff95534db1e9d1dcff890799869b77c325e84331cc89860","tags":[["d","edge"]],"fried":"7de7905952ac662496b9436d85ed3727e8858569e5430b9ef8e8aae5f415d3c4acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe0df15365000000003f9c00000000000000000000000000006404656467656400"} +{"content":"kind 40000","created_at":1700000014,"id":"4d7584cf4067743ca4fc9fcfae88ab6d09ff9c6bbcb2e343a935921b092ab24d","kind":40000,"pubkey":"5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","sig":"64053ca9533677a83a5a06b6fd812fc39299bbc6282f7e1f5a7fce1ac7215a30bc9a5121e9da22cd749b6db7640706cc58a61dd22f624fd58e790e1b6d1d807c","tags":[["d","not-addressable"]],"fried":"4d7584cf4067743ca4fc9fcfae88ab6d09ff9c6bbcb2e343a935921b092ab24d5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc0ef1536500000000409c0000000000000000000000000000640f6e6f742d6164647265737361626c65"} +{"content":"ephemeral","created_at":1700000015,"id":"b85ef1b9d8c7d9ca5a760fc76522bfb39f5d555ec5fdff01fc06ffe713fee59d","kind":20001,"pubkey":"5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","sig":"9102f8b113dc6d3e3b8efa959dd37880c766859b87e31151c60949c7a0153d62576959226796b55fa10944c10fa7eeec7c9812b79b56d17f68b0bc53c05bfa80","tags":[["expiration","4102444800"]],"fried":"b85ef1b9d8c7d9ca5a760fc76522bfb39f5d555ec5fdff01fc06ffe713fee59d5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc0ff1536500000000214e0000000000000100000000000000"} +{"content":"last ephemeral kind","created_at":1700000016,"id":"16e50d54701992bb085a2f37738603c3e4cd3e4182bba706b9edda51c3c06f4a","kind":29999,"pubkey":"acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe","sig":"24a3f47e94f0ce38d5476a5faaf406d20dfc56603e34c1fad7c1258a871b0519ce9ad7fbe1d1cc8313d3981ac2e90d72e318a0126fa75b4b1633cd1a9abb8205","tags":[],"fried":"16e50d54701992bb085a2f37738603c3e4cd3e4182bba706b9edda51c3c06f4aacd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe10f15365000000002f750000000000000100000000000000"} +{"content":"deleting nothing","created_at":1700000017,"id":"465f7b82a0e48b0148e0c5f623ddcf74b8d5ce43d8d0cdbf2d481ad2027a9fff","kind":5,"pubkey":"5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","sig":"aa6af809d3db2d8a1076885544515bae595b9f1ea74ea532a8624b562ca401f292c49ab73c8df965376c95a2c4002d042bab47e349d6380b30b1e34c61a3bc29","tags":[["e","e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1"],["a","30023:5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc:nonexistent"]],"fried":"465f7b82a0e48b0148e0c5f623ddcf74b8d5ce43d8d0cdbf2d481ad2027a9fff5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc11f1536500000000050000000000000000000000000000006520e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1615233303032333a356362646630363436653564623465616133393866333635663265613761306533643431396237653033333065333963653932626464656463616334663962633a6e6f6e6578697374656e74"} +{"content":"uppercase hex p tag","created_at":1700000018,"id":"2554ea301bcf4938582c89e6f6d78a593b5444a2bbcf6fd98d319fc6ebb2f888","kind":1,"pubkey":"acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe","sig":"62ea24e9b0e44e822d280db0ac1476e9d5738ba06cb064011ea32bc29869343b6b9bbd167d29f401ebea19f9c5713559addae6391d51f302e3dc27caa6d4e6af","tags":[["p","5CBDF0646E5DB4EAA398F365F2EA7A0E3D419B7E0330E39CE92BDDEDCAC4F9BC"]],"fried":"2554ea301bcf4938582c89e6f6d78a593b5444a2bbcf6fd98d319fc6ebb2f888acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe12f15365000000000100000000000000000000000000000070205cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc"} +{"content":"prefixed hex e tag","created_at":1700000019,"id":"c1942a618b1c197268c58f8c2e2f552d1701973705a071ecaabf1045441d8967","kind":1,"pubkey":"acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe","sig":"74bfa80d45876a577dbbca4e442a3077c41ce16e0f33c569ac6da1d7fc98285eeae28f998eef2fb36ccda4451b69aa7414a3a5b906475af33d42eaa1b1600c12","tags":[["e","0xababababababababababababababababababababababababababababababab"]],"fried":"c1942a618b1c197268c58f8c2e2f552d1701973705a071ecaabf1045441d8967acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe13f153650000000001000000000000000000000000000000651fababababababababababababababababababababababababababababababab"} +{"content":"geohash and address tags","created_at":1700000020,"id":"4aa779e939f00ef6de2047dc90323abc6ed6fb674c61b93a06a9aadaa40dbfb0","kind":1,"pubkey":"5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","sig":"0d13aeea059716cd418787ed4ffb23ff5cdf472be70053510fc30228575a1f6475b1c1c1d55bff1e5c9c056a035d837a080f4c08d5a5fb3031a2f6d3302da6d2","tags":[["g","u4pruydqqvj"],["a","30023:acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe:x"],["k","1"]],"fried":"4aa779e939f00ef6de2047dc90323abc6ed6fb674c61b93a06a9aadaa40dbfb05cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc14f153650000000001000000000000000000000000000000670b753470727579647171766a614833303032333a616364343834653266306337663635333039616431373861396635353961626465303937393639373463353765373134633335663131306466633237636362653a786b0131"} +{"content":"gift wrap","created_at":1700000021,"id":"dd8d1ad667680e739d8dea832eed5a72cdef7d312db1cec4e28058d3afbfd20d","kind":1059,"pubkey":"acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe","sig":"a7026e92df529d30e68bda1fd83f9038bb918be1bab7add21323e08f6be239fb42893430ba415e34f745a009e968f9b1f41e88f380bce6eeb554c08e4999b658","tags":[["p","5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc"]],"fried":"dd8d1ad667680e739d8dea832eed5a72cdef7d312db1cec4e28058d3afbfd20dacd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe15f15365000000002304000000000000000000000000000070205cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc"} +{"content":"+","created_at":1700000022,"id":"bfc1a4c74045280f7e4576befa215cd253d71919a38807ad011e071c48e34e23","kind":7,"pubkey":"5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","sig":"d67ab56523825734b8a106c4cbbf593b6d70708f009dc2609d03294f732c3f22cae36966a97421f850f3029a4e2306e31de5b0b80777231e85f2a24063e02917","tags":[["e","f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0"],["p","acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe"],["k","1"]],"fried":"bfc1a4c74045280f7e4576befa215cd253d71919a38807ad011e071c48e34e235cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc16f1536500000000070000000000000000000000000000006520f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f07020acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe6b0131"} +{"content":"far future event","created_at":4102444800,"id":"8cf2acbd5edfab85bc6a9e4ac3a8e5ce74148b2717a90c6b89a9e125727db408","kind":1,"pubkey":"5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc","sig":"5eab47dc0897e66058b87b6bbefe8ae1ad5c6f8c5c4711bcd57b346cdd3f408e3ed99f88c0ff663bb42aca8e146ef89b55177f41c6d0a4ca4e4e4064e6d34734","tags":[],"fried":"8cf2acbd5edfab85bc6a9e4ac3a8e5ce74148b2717a90c6b89a9e125727db4085cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc005786f40000000001000000000000000000000000000000"} diff --git a/test/unit/scripts/verify-strfry-export.spec.ts b/test/unit/scripts/verify-strfry-export.spec.ts new file mode 100644 index 000000000..49890ab3e --- /dev/null +++ b/test/unit/scripts/verify-strfry-export.spec.ts @@ -0,0 +1,245 @@ +import { expect } from 'chai' +import fs from 'fs' +import os from 'os' +import path from 'path' +import Sinon from 'sinon' +import { Readable } from 'stream' + +import { getPublicKey, identifyEvent, signEvent } from '../../../src/utils/event' +import { + parseCliArgs, + runVerifyStrfryExport, + verifyFriedExport, + verifyFriedLine, +} from '../../../src/scripts/verify-strfry-export' +import { PackedEventView, serializePackedEvent, toPackedEventFields } from '../../../src/utils/strfry' +import { Event } from '../../../src/@types/event' + +const PRIVKEY = '0000000000000000000000000000000000000000000000000000000000000003' + +const createEvent = async (overrides: Partial> = {}): Promise => { + const pubkey = getPublicKey(PRIVKEY) + const unsigned = await identifyEvent({ + pubkey, + created_at: 1700000000, + kind: 1, + tags: [ + ['t', 'nostr'], + ['p', pubkey], + ], + content: 'hello', + ...overrides, + } as any) + + return signEvent(PRIVKEY)(unsigned as any) +} + +// Same format as a strfry export --fried line. +const toFriedLine = (event: Event, fried: Buffer = serializePackedEvent(toPackedEventFields(event))): string => { + const { content, created_at, id, kind, pubkey, sig, tags } = event + const json = JSON.stringify({ content, created_at, id, kind, pubkey, sig, tags }) + + return `${json.slice(0, -1)},"fried":"${fried.toString('hex')}"}` +} + +describe('verify-strfry-export', () => { + let event: Event + + before(async () => { + event = await createEvent() + }) + + describe('parseCliArgs', () => { + it('reads a file path and checks signatures by default', () => { + expect(parseCliArgs(['export.jsonl'])).to.deep.equal({ + filePath: 'export.jsonl', + checkSignatures: true, + showHelp: false, + }) + }) + + it('accepts - for stdin and --skip-signatures', () => { + expect(parseCliArgs(['--skip-signatures', '-'])).to.deep.equal({ + filePath: '-', + checkSignatures: false, + showHelp: false, + }) + }) + + it('returns help mode for --help', () => { + expect(parseCliArgs(['--help']).showHelp).to.equal(true) + }) + + it('rejects bad arguments', () => { + expect(() => parseCliArgs([])).to.throw('Missing path to a strfry export') + expect(() => parseCliArgs(['--fried', 'a.jsonl'])).to.throw('Unknown option: --fried') + expect(() => parseCliArgs(['a.jsonl', 'b.jsonl'])).to.throw('Unexpected extra argument: b.jsonl') + }) + }) + + describe('verifyFriedLine', () => { + afterEach(() => { + Sinon.restore() + }) + + it('accepts a line whose packed record matches the event', async () => { + expect(await verifyFriedLine(toFriedLine(event), true)).to.equal(undefined) + }) + + it('accepts replaceable, parameterized and ephemeral events', async () => { + for (const kind of [0, 30023, 20001]) { + const other = await createEvent({ kind, tags: [['d', 'slug']] }) + + expect(await verifyFriedLine(toFriedLine(other), true), String(kind)).to.equal(undefined) + } + }) + + it('reports lines that are not a fried export', async () => { + expect(await verifyFriedLine('{', true)).to.equal('Not valid JSON') + for (const line of ['null', '42', '"text"', '[]']) { + expect(await verifyFriedLine(line, true), line).to.equal('Not a JSON object') + } + expect(await verifyFriedLine(JSON.stringify(event), true)).to.equal( + 'No "fried" field, export with strfry export --fried', + ) + expect(await verifyFriedLine(toFriedLine(event).replace(/"fried":"[0-9a-f]/, '"fried":"Z'), true)).to.equal( + 'The "fried" field is not lowercase hex', + ) + }) + + it('reports the first byte where the packed record differs', async () => { + const fried = serializePackedEvent(toPackedEventFields(event)) + fried[72] ^= 0xff + + expect(await verifyFriedLine(toFriedLine(event, fried), true)).to.equal( + "Packed record differs from strfry's at byte 72", + ) + }) + + it('reports a packed record that is missing a tag', async () => { + const fields = toPackedEventFields(event) + const fried = serializePackedEvent({ ...fields, tags: fields.tags.slice(1) }) + + expect(await verifyFriedLine(toFriedLine(event, fried), true)).to.equal( + "Packed record differs from strfry's at byte 88", + ) + }) + + it('accepts an expiration above 2^53', async () => { + const expiring = await createEvent({ tags: [['expiration', '18446744073709551615']] }) + + expect(await verifyFriedLine(toFriedLine(expiring), true)).to.equal(undefined) + }) + + it('catches a decoded expiration that is off by one above 2^53', async () => { + const expiring = await createEvent({ tags: [['expiration', '18446744073709551615']] }) + Sinon.stub(PackedEventView.prototype, 'expiration').get(() => 18446744073709551614n) + + expect(await verifyFriedLine(toFriedLine(expiring), true)).to.equal('Decoded expiration differs') + }) + + it('reports a decoder that disagrees with the record', async () => { + const kind = Sinon.stub(PackedEventView.prototype, 'kind').get(() => 7) + expect(await verifyFriedLine(toFriedLine(event), true)).to.equal('Decoded kind differs') + kind.restore() + + Sinon.stub(PackedEventView.prototype, 'tags').returns([]) + expect(await verifyFriedLine(toFriedLine(event), true)).to.equal('Decoded 0 tags, expected 2') + }) + + it('reports events nostream cannot pack', async () => { + const broken = { ...event, tags: [['e', 'not-hex']] } as Event + + expect(await verifyFriedLine(toFriedLine(broken, Buffer.alloc(88)), true)).to.equal( + 'Tag e must be 64 hex characters', + ) + }) + + it('reports an id that does not match the event', async () => { + const edited = { ...event, content: 'edited' } + + expect(await verifyFriedLine(toFriedLine(edited), true)).to.equal('Id does not match the event hash') + }) + + it('checks signatures unless told to skip them', async () => { + const forged = { ...event, sig: '00'.repeat(64) } + + expect(await verifyFriedLine(toFriedLine(forged), true)).to.equal('Invalid signature') + expect(await verifyFriedLine(toFriedLine(forged), false)).to.equal(undefined) + }) + }) + + describe('verifyFriedExport', () => { + it('checks every line, skips blank ones and reports failures by line number', async () => { + const input = Readable.from([`${toFriedLine(event)}\n\n`, '{\n', `${toFriedLine(event)}\n`]) + const failures: [number, string][] = [] + + const stats = await verifyFriedExport(input, true, (lineNumber, reason) => failures.push([lineNumber, reason])) + + expect(stats).to.deep.equal({ checked: 3, failed: 1 }) + expect(failures).to.deep.equal([[3, 'Not valid JSON']]) + }) + }) + + describe('a real strfry export', () => { + // Exported from strfry 1.1.2-148-g4cd3cf6 (strfry import, then strfry export --fried). + it('matches every packed record strfry wrote', async () => { + const input = fs.createReadStream(path.join(__dirname, '../data/strfry-fried.jsonl')) + const failures: [number, string][] = [] + + const stats = await verifyFriedExport(input, true, (lineNumber, reason) => failures.push([lineNumber, reason])) + + expect(failures).to.deep.equal([]) + expect(stats).to.deep.equal({ checked: 25, failed: 0 }) + }) + }) + + describe('runVerifyStrfryExport', () => { + let directory: string + + beforeEach(() => { + directory = fs.mkdtempSync(path.join(os.tmpdir(), 'nostream-strfry-verify-')) + Sinon.stub(console, 'log') + Sinon.stub(console, 'error') + }) + + afterEach(() => { + Sinon.restore() + fs.rmSync(directory, { recursive: true, force: true }) + }) + + it('exits with 0 when every event checks out', async () => { + const filePath = path.join(directory, 'export.jsonl') + fs.writeFileSync(filePath, `${toFriedLine(event)}\n`) + + expect(await runVerifyStrfryExport([filePath])).to.equal(0) + }) + + it('exits with 1 when the export has no events', async () => { + const filePath = path.join(directory, 'empty.jsonl') + fs.writeFileSync(filePath, '\n') + + expect(await runVerifyStrfryExport([filePath])).to.equal(1) + expect((console.error as Sinon.SinonStub).calledWith('No events found in the export.')).to.equal(true) + }) + + it('says when it lists only the first failures', async () => { + const filePath = path.join(directory, 'export.jsonl') + fs.writeFileSync(filePath, '{\n'.repeat(21)) + + expect(await runVerifyStrfryExport([filePath])).to.equal(1) + expect((console.error as Sinon.SinonStub).callCount).to.equal(20) + expect( + (console.log as Sinon.SinonStub).calledWith('Checked 21 events, 21 failed (first 20 failures listed).'), + ).to.equal(true) + }) + + it('exits with 1 and names the failing line when one does not', async () => { + const filePath = path.join(directory, 'export.jsonl') + fs.writeFileSync(filePath, `${toFriedLine(event)}\n${toFriedLine({ ...event, content: 'edited' })}\n`) + + expect(await runVerifyStrfryExport([filePath])).to.equal(1) + expect((console.error as Sinon.SinonStub).calledWith('line 2: Id does not match the event hash')).to.equal(true) + }) + }) +}) diff --git a/test/unit/utils/strfry.spec.ts b/test/unit/utils/strfry.spec.ts new file mode 100644 index 000000000..2b0c48b40 --- /dev/null +++ b/test/unit/utils/strfry.spec.ts @@ -0,0 +1,340 @@ +import { expect } from 'chai' + +import { + PACKED_EVENT_HEADER_SIZE, + PackedEventView, + serializePackedEvent, + toPackedEventFields, +} from '../../../src/utils/strfry' +import { Event } from '../../../src/@types/event' + +const ID = Buffer.alloc(32, 0x11) +const PUBKEY = Buffer.alloc(32, 0x22) +const E_TAG = 'ab'.repeat(32) +const P_TAG = 'cd'.repeat(32) + +const u64 = (value: bigint): Buffer => { + const buffer = Buffer.alloc(8) + buffer.writeBigUInt64LE(value) + return buffer +} + +const tag = (name: string, value: Buffer | string): Buffer => { + const bytes = Buffer.isBuffer(value) ? value : Buffer.from(value) + return Buffer.concat([Buffer.from([name.charCodeAt(0), bytes.length]), bytes]) +} + +const record = (createdAt: bigint, kind: bigint, expiration: bigint, ...tags: Buffer[]): Buffer => + Buffer.concat([ID, PUBKEY, u64(createdAt), u64(kind), u64(expiration), ...tags]) + +// Loose type so tests can use kinds and tags that Event doesn't allow. +type EventOverrides = Partial> + +const event = (overrides: EventOverrides = {}): Event => + ({ + id: ID.toString('hex'), + pubkey: PUBKEY.toString('hex'), + created_at: 1700000000, + kind: 1, + tags: [], + content: '', + sig: '00'.repeat(64), + ...overrides, + }) as Event + +const packedTags = (overrides: EventOverrides) => + toPackedEventFields(event(overrides)).tags.map(({ name, value }) => [name, value.toString()]) + +describe('strfry packed events', () => { + describe('PackedEventView', () => { + it('reads the header fields', () => { + const view = new PackedEventView(record(1700000000n, 30023n, 1800000000n)) + + expect(view.id.equals(ID)).to.equal(true) + expect(view.pubkey.equals(PUBKEY)).to.equal(true) + expect(view.createdAt).to.equal(1700000000) + expect(view.kind).to.equal(30023) + expect(view.expiration).to.equal(1800000000n) + }) + + it('reads values that do not fit in 32 bits', () => { + const view = new PackedEventView(record(0x1f_ffff_ffff_ffffn, 0x100_0000_0000n, 0x1_0000_0005n)) + + expect(view.createdAt).to.equal(Number.MAX_SAFE_INTEGER) + expect(view.kind).to.equal(0x100_0000_0000) + expect(view.expiration).to.equal(0x1_0000_0005n) + }) + + it('reads expiration exactly across the whole 64-bit range', () => { + for (const expiration of [0x20_0000_0000_0001n, 0xffff_ffff_ffff_ffffn]) { + expect(new PackedEventView(record(1n, 1n, expiration)).expiration).to.equal(expiration) + } + }) + + it('throws instead of rounding a created_at or kind above 2^53', () => { + expect(() => new PackedEventView(record(0x20_0000_0000_0000n, 1n, 0n)).createdAt).to.throw( + RangeError, + 'Packed event value at byte 64 is above 2^53', + ) + expect(() => new PackedEventView(record(1n, 0x20_0000_0000_0000n, 0n)).kind).to.throw( + RangeError, + 'Packed event value at byte 72 is above 2^53', + ) + }) + + it('reads tags in order, including empty and binary values', () => { + const binary = Buffer.from(E_TAG, 'hex') + const long = Buffer.alloc(255, 0x61) + const view = new PackedEventView( + record(1n, 1n, 0n, tag('e', binary), tag('t', 'nostr'), tag('d', ''), tag('r', long)), + ) + + expect(view.tags()).to.deep.equal([ + { name: 'e', value: binary }, + { name: 't', value: Buffer.from('nostr') }, + { name: 'd', value: Buffer.alloc(0) }, + { name: 'r', value: long }, + ]) + }) + + it('returns no tags for a record without any', () => { + expect(new PackedEventView(record(1n, 1n, 0n)).tags()).to.deep.equal([]) + }) + + it('rejects a record shorter than the header', () => { + expect(() => new PackedEventView(Buffer.alloc(PACKED_EVENT_HEADER_SIZE - 1))).to.throw( + 'Packed event is 87 bytes, expected at least 88', + ) + }) + + it('rejects a record that ends inside a tag header', () => { + const view = new PackedEventView(Buffer.concat([record(1n, 1n, 0n), Buffer.from([0x74])])) + + expect(() => view.tags()).to.throw('Packed event ends inside a tag header at byte 88') + }) + + it('rejects a tag value that runs past the end of the record', () => { + const view = new PackedEventView(Buffer.concat([record(1n, 1n, 0n), Buffer.from([0x74, 5, 0x61, 0x62])])) + + expect(() => view.tags()).to.throw('Packed event tag at byte 88 runs past the end of the record') + }) + }) + + describe('toPackedEventFields', () => { + it('keeps single-letter tags in order and drops longer names', () => { + expect( + packedTags({ + tags: [ + ['t', 'nostr'], + ['subject', 'hi'], + ['r', 'wss://relay.example.com'], + ['client', 'nostream'], + ], + }), + ).to.deep.equal([ + ['t', 'nostr'], + ['r', 'wss://relay.example.com'], + ]) + }) + + it('packs e and p values as 32 raw bytes', () => { + const { tags } = toPackedEventFields( + event({ + tags: [ + ['e', E_TAG], + ['p', P_TAG, 'wss://relay.example.com'], + ], + }), + ) + + expect(tags).to.deep.equal([ + { name: 'e', value: Buffer.from(E_TAG, 'hex') }, + { name: 'p', value: Buffer.from(P_TAG, 'hex') }, + ]) + }) + + it('packs a tag that has only a name as an empty value', () => { + expect(packedTags({ tags: [['t']] })).to.deep.equal([['t', '']]) + }) + + it('skips values longer than 255 bytes, counting UTF-8 bytes', () => { + expect( + packedTags({ + tags: [ + ['t', 'a'.repeat(255)], + ['t', 'a'.repeat(256)], + ['t', 'é'.repeat(127)], + ['t', 'é'.repeat(128)], + ], + }), + ).to.deep.equal([ + ['t', 'a'.repeat(255)], + ['t', 'é'.repeat(127)], + ]) + }) + + it('drops a one-character name that takes more than one byte', () => { + expect(packedTags({ tags: [['é', 'x']] })).to.deep.equal([]) + }) + + it('puts an empty d tag first for replaceable kinds', () => { + for (const kind of [0, 3, 41, 10000, 10002, 19999]) { + expect(packedTags({ kind, tags: [['t', 'x']] })).to.deep.equal([ + ['d', ''], + ['t', 'x'], + ]) + } + }) + + it('puts an empty d tag last for parameterized replaceable kinds', () => { + for (const kind of [30000, 30023, 39999]) { + expect(packedTags({ kind, tags: [['d', 'slug']] })).to.deep.equal([ + ['d', 'slug'], + ['d', ''], + ]) + } + }) + + it('adds no d tag for other kinds', () => { + for (const kind of [1, 40, 9999, 20000, 29999, 40000]) { + expect(packedTags({ kind })).to.deep.equal([]) + } + }) + + it('uses the first expiration tag and ignores later ones', () => { + const fields = toPackedEventFields( + event({ + tags: [ + ['expiration', '1800000000'], + ['expiration', 'later'], + ], + }), + ) + + expect(fields.expiration).to.equal(1800000000n) + expect(fields.tags).to.deep.equal([]) + }) + + it('marks ephemeral events with expiration 1', () => { + expect(toPackedEventFields(event({ kind: 20001, tags: [['expiration', '1800000000']] })).expiration).to.equal(1n) + expect(toPackedEventFields(event({ kind: 29999 })).expiration).to.equal(1n) + }) + + it('leaves expiration at 0 when there is no expiration tag', () => { + expect(toPackedEventFields(event()).expiration).to.equal(0n) + }) + + it('rejects expiration values strfry refuses', () => { + for (const value of ['99', '0', '', 'soon', '-5', '1.5', '18446744073709551616']) { + expect(() => toPackedEventFields(event({ tags: [['expiration', value]] })), value).to.throw( + 'Invalid expiration tag', + ) + } + }) + + it('accepts the largest 64-bit expiration', () => { + const fields = toPackedEventFields(event({ tags: [['expiration', '18446744073709551615']] })) + + expect(fields.expiration).to.equal(0xffff_ffff_ffff_ffffn) + }) + + it('decodes e and p values the way strfry does, prefix and upper case included', () => { + const { tags } = toPackedEventFields( + event({ + tags: [ + ['e', `0x${'ab'.repeat(31)}`], + ['p', P_TAG.toUpperCase()], + ], + }), + ) + + expect(tags[0].value).to.deep.equal(Buffer.from('ab'.repeat(31), 'hex')) + expect(tags[1].value).to.deep.equal(Buffer.from(P_TAG, 'hex')) + }) + + it('rejects events strfry could not have packed', () => { + const invalid: EventOverrides[] = [ + { id: 'zz'.repeat(32) }, + { pubkey: 'ab'.repeat(31) }, + { created_at: -1 }, + { created_at: 1.5 }, + { kind: Number.MAX_SAFE_INTEGER + 1 }, + { tags: 'none' }, + { tags: [[]] }, + { tags: [['t', 1]] }, + { tags: [['e', 'ab'.repeat(31)]] }, + { tags: [['p', `${'ab'.repeat(31)}zz`]] }, + ] + + for (const overrides of invalid) { + expect(() => toPackedEventFields(event(overrides)), JSON.stringify(overrides)).to.throw() + } + }) + }) + + describe('serializePackedEvent', () => { + it("lays the record out like strfry's PackedEventBuilder", () => { + const fields = toPackedEventFields( + event({ + kind: 30023, + tags: [ + ['d', 'slug'], + ['e', E_TAG], + ['expiration', '1800000000'], + ], + }), + ) + + expect(serializePackedEvent(fields)).to.deep.equal( + record(1700000000n, 30023n, 1800000000n, tag('d', 'slug'), tag('e', Buffer.from(E_TAG, 'hex')), tag('d', '')), + ) + }) + + it('reads back the same fields', () => { + const fields = toPackedEventFields( + event({ + kind: 10002, + tags: [ + ['r', 'wss://relay.example.com'], + ['p', P_TAG], + ], + }), + ) + const view = new PackedEventView(serializePackedEvent(fields)) + + expect(view.id).to.deep.equal(fields.id) + expect(view.pubkey).to.deep.equal(fields.pubkey) + expect(view.createdAt).to.equal(fields.createdAt) + expect(view.kind).to.equal(fields.kind) + expect(view.expiration).to.equal(fields.expiration) + expect(view.tags()).to.deep.equal(fields.tags) + }) + + it('round-trips an expiration above 2^53', () => { + for (const expiration of ['9007199254740993', '18446744073709551615']) { + const fields = toPackedEventFields(event({ tags: [['expiration', expiration]] })) + + expect(new PackedEventView(serializePackedEvent(fields)).expiration).to.equal(BigInt(expiration)) + } + }) + + it('rejects an id or pubkey that is not 32 bytes', () => { + const fields = toPackedEventFields(event()) + + expect(() => serializePackedEvent({ ...fields, id: Buffer.alloc(31) })).to.throw( + 'Event id and pubkey must be 32 bytes', + ) + }) + + it('rejects tags that do not fit the format', () => { + const fields = toPackedEventFields(event()) + + expect(() => serializePackedEvent({ ...fields, tags: [{ name: 't', value: Buffer.alloc(256) }] })).to.throw( + 'Tag t cannot be packed', + ) + expect(() => serializePackedEvent({ ...fields, tags: [{ name: 'tt', value: Buffer.alloc(1) }] })).to.throw( + 'Tag tt cannot be packed', + ) + }) + }) +})