From ec21446b9004837f82ac5a8e0fe90d81e6d7d39d Mon Sep 17 00:00:00 2001 From: BROCODES2024 Date: Mon, 27 Apr 2026 09:05:09 +0000 Subject: [PATCH 1/2] feat: add NIP-65 (Relay List Metadata) support --- .changeset/nip-65-relay-list-metadata.md | 5 + README.md | 1 + package.json | 3 +- src/constants/base.ts | 2 + src/utils/nip65.ts | 17 +++ .../features/nip-65/nip-65.feature | 27 +++++ .../features/nip-65/nip-65.feature.ts | 104 ++++++++++++++++++ .../factories/event-strategy-factory.spec.ts | 5 + test/unit/utils/nip65.spec.ts | 90 +++++++++++++++ 9 files changed, 253 insertions(+), 1 deletion(-) create mode 100644 .changeset/nip-65-relay-list-metadata.md create mode 100644 src/utils/nip65.ts create mode 100644 test/integration/features/nip-65/nip-65.feature create mode 100644 test/integration/features/nip-65/nip-65.feature.ts create mode 100644 test/unit/utils/nip65.spec.ts diff --git a/.changeset/nip-65-relay-list-metadata.md b/.changeset/nip-65-relay-list-metadata.md new file mode 100644 index 00000000..35cec677 --- /dev/null +++ b/.changeset/nip-65-relay-list-metadata.md @@ -0,0 +1,5 @@ +--- +"nostream": minor +--- + +Add NIP-65 Relay List Metadata support for kind 10002 events: relay list utility with `isRelayListEvent` and `parseRelayList` helpers, unit tests, and relay information document updated to advertise NIP-65 (#577). diff --git a/README.md b/README.md index 8ac5c31b..f3cb6694 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ NIPs with a relay-specific implementation are listed here. - [x] NIP-44: Encrypted Payloads (Versioned) - [x] NIP-45: Event Counts - [x] NIP-62: Request to Vanish +- [x] NIP-65: Relay List Metadata ## Requirements diff --git a/package.json b/package.json index 675394da..3ebcfbdc 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ 33, 40, 44, - 45 + 45, + 65 ], "supportedNipExtensions": [], "main": "src/index.ts", diff --git a/src/constants/base.ts b/src/constants/base.ts index f1daba11..52ca4114 100644 --- a/src/constants/base.ts +++ b/src/constants/base.ts @@ -30,6 +30,8 @@ export enum EventKinds { // Lightning zaps ZAP_REQUEST = 9734, ZAP_RECEIPT = 9735, + // NIP-65: Relay List Metadata + RELAY_LIST = 10002, // Replaceable events REPLACEABLE_FIRST = 10000, REPLACEABLE_LAST = 19999, diff --git a/src/utils/nip65.ts b/src/utils/nip65.ts new file mode 100644 index 00000000..a054d17c --- /dev/null +++ b/src/utils/nip65.ts @@ -0,0 +1,17 @@ +import { Event } from '../@types/event' +import { EventKinds, EventTags } from '../constants/base' + +export type RelayListEntry = { + url: string + marker?: 'read' | 'write' +} + +export const isRelayListEvent = (event: Event): boolean => event.kind === EventKinds.RELAY_LIST + +export const parseRelayList = (event: Event): RelayListEntry[] => + event.tags + .filter((tag) => tag[0] === EventTags.Relay && tag.length >= 2) + .map((tag) => ({ + url: tag[1], + marker: tag[2] === 'read' || tag[2] === 'write' ? tag[2] : undefined, + })) diff --git a/test/integration/features/nip-65/nip-65.feature b/test/integration/features/nip-65/nip-65.feature new file mode 100644 index 00000000..14b45615 --- /dev/null +++ b/test/integration/features/nip-65/nip-65.feature @@ -0,0 +1,27 @@ +Feature: NIP-65 Relay List Metadata + Scenario: Alice publishes a relay list and retrieves it + Given someone called Alice + When Alice sends a relay_list event with relays "wss://alice.relay.com" + And Alice subscribes to her relay_list events + Then Alice receives a relay_list event with relays "wss://alice.relay.com" + + Scenario: Alice updates her relay list and only the latest is kept + Given someone called Alice + When Alice sends a relay_list event with relays "wss://old.relay.com" + And Alice sends a relay_list event with relays "wss://new.relay.com" + And Alice subscribes to her relay_list events + Then Alice receives 1 relay_list event and EOSE + And the relay_list event has relays "wss://new.relay.com" + + Scenario: Bob can query Alice's relay list + Given someone called Alice + And someone called Bob + When Alice sends a relay_list event with relays "wss://alice.relay.com" + And Bob subscribes to author Alice + Then Bob receives a relay_list event with relays "wss://alice.relay.com" + + Scenario: Alice publishes a relay list with read and write markers + Given someone called Alice + When Alice sends a relay_list event with a read relay "wss://read.relay.com" and a write relay "wss://write.relay.com" + And Alice subscribes to her relay_list events + Then Alice receives a relay_list event with a read relay "wss://read.relay.com" and a write relay "wss://write.relay.com" diff --git a/test/integration/features/nip-65/nip-65.feature.ts b/test/integration/features/nip-65/nip-65.feature.ts new file mode 100644 index 00000000..7680cff6 --- /dev/null +++ b/test/integration/features/nip-65/nip-65.feature.ts @@ -0,0 +1,104 @@ +import { Then, When, World } from '@cucumber/cucumber' +import { expect } from 'chai' +import WebSocket from 'ws' +import { Event } from '../../../../src/@types/event' +import { EventKinds } from '../../../../src/constants/base' +import { createEvent, createSubscription, sendEvent, waitForEventCount, waitForNextEvent } from '../helpers' + +When(/^(\w+) sends a relay_list event with relays "([^"]+)"$/, async function (name: string, relayUrl: string) { + const ws = this.parameters.clients[name] as WebSocket + const { pubkey, privkey } = this.parameters.identities[name] + + const event: Event = await createEvent( + { + pubkey, + kind: EventKinds.RELAY_LIST, + content: '', + tags: [['r', relayUrl]], + }, + privkey, + ) + + await sendEvent(ws, event) + this.parameters.events[name].push(event) +}) + +When( + /^(\w+) sends a relay_list event with a read relay "([^"]+)" and a write relay "([^"]+)"$/, + async function (name: string, readRelay: string, writeRelay: string) { + const ws = this.parameters.clients[name] as WebSocket + const { pubkey, privkey } = this.parameters.identities[name] + + const event: Event = await createEvent( + { + pubkey, + kind: EventKinds.RELAY_LIST, + content: '', + tags: [ + ['r', readRelay, 'read'], + ['r', writeRelay, 'write'], + ], + }, + privkey, + ) + + await sendEvent(ws, event) + this.parameters.events[name].push(event) + }, +) + +When( + /^(\w+) subscribes to (?:her|his|their) relay_list events$/, + async function (this: World>, name: string) { + const ws = this.parameters.clients[name] as WebSocket + const { pubkey } = this.parameters.identities[name] + const subscription = { + name: `test-${Math.random()}`, + filters: [{ kinds: [EventKinds.RELAY_LIST], authors: [pubkey] }], + } + this.parameters.subscriptions[name].push(subscription) + + await createSubscription(ws, subscription.name, subscription.filters) + }, +) + +Then(/^(\w+) receives a relay_list event with relays "([^"]+)"$/, async function (name: string, relayUrl: string) { + const ws = this.parameters.clients[name] as WebSocket + const subscription = this.parameters.subscriptions[name][this.parameters.subscriptions[name].length - 1] + const receivedEvent = await waitForNextEvent(ws, subscription.name) + + expect(receivedEvent.kind).to.equal(EventKinds.RELAY_LIST) + expect(receivedEvent.tags).to.deep.include(['r', relayUrl]) +}) + +Then( + /^(\w+) receives a relay_list event with a read relay "([^"]+)" and a write relay "([^"]+)"$/, + async function (name: string, readRelay: string, writeRelay: string) { + const ws = this.parameters.clients[name] as WebSocket + const subscription = this.parameters.subscriptions[name][this.parameters.subscriptions[name].length - 1] + const receivedEvent = await waitForNextEvent(ws, subscription.name) + + expect(receivedEvent.kind).to.equal(EventKinds.RELAY_LIST) + expect(receivedEvent.tags).to.deep.include(['r', readRelay, 'read']) + expect(receivedEvent.tags).to.deep.include(['r', writeRelay, 'write']) + }, +) + +Then(/^(\w+) receives (\d+) relay_list event(?:s)? and EOSE$/, async function (name: string, count: string) { + const ws = this.parameters.clients[name] as WebSocket + const subscription = this.parameters.subscriptions[name][this.parameters.subscriptions[name].length - 1] + const events = await waitForEventCount(ws, subscription.name, Number(count), true) + + expect(events.length).to.equal(Number(count)) + expect(events[0].kind).to.equal(EventKinds.RELAY_LIST) + + this.parameters.lastRelayListEvents = events +}) + +Then( + /^the relay_list event has relays "([^"]+)"$/, + async function (this: World>, relayUrl: string) { + const events: Event[] = this.parameters.lastRelayListEvents + expect(events[0].tags).to.deep.include(['r', relayUrl]) + }, +) diff --git a/test/unit/factories/event-strategy-factory.spec.ts b/test/unit/factories/event-strategy-factory.spec.ts index 444c5e7b..c051ca7d 100644 --- a/test/unit/factories/event-strategy-factory.spec.ts +++ b/test/unit/factories/event-strategy-factory.spec.ts @@ -47,6 +47,11 @@ describe('eventStrategyFactory', () => { expect(factory([event, adapter])).to.be.an.instanceOf(ReplaceableEventStrategy) }) + it('returns ReplaceableEventStrategy given a relay_list event (NIP-65)', () => { + event.kind = EventKinds.RELAY_LIST + expect(factory([event, adapter])).to.be.an.instanceOf(ReplaceableEventStrategy) + }) + it('returns EphemeralEventStrategy given an ephemeral event', () => { event.kind = EventKinds.EPHEMERAL_FIRST expect(factory([event, adapter])).to.be.an.instanceOf(EphemeralEventStrategy) diff --git a/test/unit/utils/nip65.spec.ts b/test/unit/utils/nip65.spec.ts new file mode 100644 index 00000000..939ecd15 --- /dev/null +++ b/test/unit/utils/nip65.spec.ts @@ -0,0 +1,90 @@ +import { expect } from 'chai' +import { Event } from '../../../src/@types/event' +import { isRelayListEvent, parseRelayList } from '../../../src/utils/nip65' + +const baseEvent = (): Partial => ({ + kind: 10002, + tags: [], + content: '', +}) + +describe('NIP-65', () => { + describe('isRelayListEvent', () => { + it('returns true for kind 10002', () => { + expect(isRelayListEvent({ ...baseEvent(), kind: 10002 } as Event)).to.equal(true) + }) + + it('returns false for kind 0 (set_metadata)', () => { + expect(isRelayListEvent({ ...baseEvent(), kind: 0 } as Event)).to.equal(false) + }) + + it('returns false for kind 3 (contact_list)', () => { + expect(isRelayListEvent({ ...baseEvent(), kind: 3 } as Event)).to.equal(false) + }) + + it('returns false for kind 1 (text_note)', () => { + expect(isRelayListEvent({ ...baseEvent(), kind: 1 } as Event)).to.equal(false) + }) + }) + + describe('parseRelayList', () => { + it('returns empty array when tags is empty', () => { + const event = { ...baseEvent(), tags: [] } as unknown as Event + expect(parseRelayList(event)).to.deep.equal([]) + }) + + it('parses a relay tag with no marker as read+write', () => { + const event = { ...baseEvent(), tags: [['r', 'wss://relay.example.com']] } as unknown as Event + expect(parseRelayList(event)).to.deep.equal([{ url: 'wss://relay.example.com', marker: undefined }]) + }) + + it('parses a relay tag with read marker', () => { + const event = { ...baseEvent(), tags: [['r', 'wss://relay.example.com', 'read']] } as unknown as Event + expect(parseRelayList(event)).to.deep.equal([{ url: 'wss://relay.example.com', marker: 'read' }]) + }) + + it('parses a relay tag with write marker', () => { + const event = { ...baseEvent(), tags: [['r', 'wss://relay.example.com', 'write']] } as unknown as Event + expect(parseRelayList(event)).to.deep.equal([{ url: 'wss://relay.example.com', marker: 'write' }]) + }) + + it('sets marker to undefined when tag[2] is an unrecognized string', () => { + const event = { ...baseEvent(), tags: [['r', 'wss://relay.example.com', 'both']] } as unknown as Event + expect(parseRelayList(event)).to.deep.equal([{ url: 'wss://relay.example.com', marker: undefined }]) + }) + + it('ignores tags where tag[0] is not "r"', () => { + const event = { + ...baseEvent(), + tags: [ + ['p', 'somepubkey'], + ['e', 'someeventid'], + ], + } as unknown as Event + expect(parseRelayList(event)).to.deep.equal([]) + }) + + it('ignores tags shorter than 2 elements', () => { + const event = { ...baseEvent(), tags: [['r']] } as unknown as Event + expect(parseRelayList(event)).to.deep.equal([]) + }) + + it('parses a mixed list correctly', () => { + const event = { + ...baseEvent(), + tags: [ + ['r', 'wss://alice.relay.com'], + ['r', 'wss://bob.relay.com', 'write'], + ['r', 'wss://carol.relay.com', 'read'], + ['p', 'somepubkey'], + ], + } as unknown as Event + + expect(parseRelayList(event)).to.deep.equal([ + { url: 'wss://alice.relay.com', marker: undefined }, + { url: 'wss://bob.relay.com', marker: 'write' }, + { url: 'wss://carol.relay.com', marker: 'read' }, + ]) + }) + }) +}) From 48676cb1ba778a8db3571fe252140e0194b3ed47 Mon Sep 17 00:00:00 2001 From: CKodidela Date: Mon, 27 Apr 2026 16:45:11 +0000 Subject: [PATCH 2/2] fix: address NIP-65 PR review comments --- src/@types/event.ts | 5 +++ src/cli/commands/info.ts | 7 +++- src/constants/base.ts | 4 +- src/factories/event-strategy-factory.ts | 3 +- src/schemas/event-schema.ts | 14 +++++++ src/utils/nip65.ts | 7 +--- test/unit/schemas/event-schema.spec.ts | 56 ++++++++++++++++++++++++- 7 files changed, 85 insertions(+), 11 deletions(-) diff --git a/src/@types/event.ts b/src/@types/event.ts index dee845c7..66e8940f 100644 --- a/src/@types/event.ts +++ b/src/@types/event.ts @@ -49,6 +49,11 @@ export interface DBEvent { expires_at?: number } +export type RelayListEntry = { + url: string + marker?: 'read' | 'write' +} + export interface CanonicalEvent { 0: 0 1: string diff --git a/src/cli/commands/info.ts b/src/cli/commands/info.ts index 513e3e4d..3c9450c2 100644 --- a/src/cli/commands/info.ts +++ b/src/cli/commands/info.ts @@ -56,7 +56,12 @@ const getEventCount = async (): Promise => { } const getRelayUptimeSeconds = async (): Promise => { - const idResult = await runCommandWithOutput('docker', ['compose', 'ps', '-q', 'nostream'], { timeoutMs: 1000 }) + let idResult: { code: number; stdout: string; stderr: string } + try { + idResult = await runCommandWithOutput('docker', ['compose', 'ps', '-q', 'nostream'], { timeoutMs: 1000 }) + } catch { + return null + } if (idResult.code !== 0) { return null } diff --git a/src/constants/base.ts b/src/constants/base.ts index 52ca4114..3212efa2 100644 --- a/src/constants/base.ts +++ b/src/constants/base.ts @@ -30,10 +30,10 @@ export enum EventKinds { // Lightning zaps ZAP_REQUEST = 9734, ZAP_RECEIPT = 9735, - // NIP-65: Relay List Metadata - RELAY_LIST = 10002, // Replaceable events REPLACEABLE_FIRST = 10000, + // NIP-65: Relay List Metadata + RELAY_LIST = 10002, REPLACEABLE_LAST = 19999, // Ephemeral events EPHEMERAL_FIRST = 20000, diff --git a/src/factories/event-strategy-factory.ts b/src/factories/event-strategy-factory.ts index 289804f7..41d5a95e 100644 --- a/src/factories/event-strategy-factory.ts +++ b/src/factories/event-strategy-factory.ts @@ -8,6 +8,7 @@ import { isReplaceableEvent, isRequestToVanishEvent, } from '../utils/event' +import { isRelayListEvent } from '../utils/nip65' import { DefaultEventStrategy } from '../handlers/event-strategies/default-event-strategy' import { DeleteEventStrategy } from '../handlers/event-strategies/delete-event-strategy' import { EphemeralEventStrategy } from '../handlers/event-strategies/ephemeral-event-strategy' @@ -33,7 +34,7 @@ export const eventStrategyFactory = return new GiftWrapEventStrategy(adapter, eventRepository) } else if (isOpenTimestampsEvent(event)) { return new TimestampEventStrategy(adapter, eventRepository) - } else if (isReplaceableEvent(event)) { + } else if (isRelayListEvent(event) || isReplaceableEvent(event)) { return new ReplaceableEventStrategy(adapter, eventRepository) } else if (isEphemeralEvent(event)) { return new EphemeralEventStrategy(adapter) diff --git a/src/schemas/event-schema.ts b/src/schemas/event-schema.ts index 6aa2cf22..83be81ab 100644 --- a/src/schemas/event-schema.ts +++ b/src/schemas/event-schema.ts @@ -1,5 +1,6 @@ import { z } from 'zod' +import { EventKinds, EventTags } from '../constants/base' import { createdAtSchema, idSchema, kindSchema, pubkeySchema, signatureSchema, tagSchema } from './base-schema' /** @@ -29,3 +30,16 @@ export const eventSchema = z sig: signatureSchema, }) .strict() + .superRefine((event, ctx) => { + if (event.kind === EventKinds.RELAY_LIST) { + event.tags.forEach((tag, index) => { + if (tag[0] === EventTags.Relay && !z.string().url().safeParse(tag[1]).success) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Invalid relay URL`, + path: ['tags', index, 1], + }) + } + }) + } + }) diff --git a/src/utils/nip65.ts b/src/utils/nip65.ts index a054d17c..11eb553c 100644 --- a/src/utils/nip65.ts +++ b/src/utils/nip65.ts @@ -1,11 +1,6 @@ -import { Event } from '../@types/event' +import { Event, RelayListEntry } from '../@types/event' import { EventKinds, EventTags } from '../constants/base' -export type RelayListEntry = { - url: string - marker?: 'read' | 'write' -} - export const isRelayListEvent = (event: Event): boolean => event.kind === EventKinds.RELAY_LIST export const parseRelayList = (event: Event): RelayListEntry[] => diff --git a/test/unit/schemas/event-schema.spec.ts b/test/unit/schemas/event-schema.spec.ts index 1587b154..dbdbfe50 100644 --- a/test/unit/schemas/event-schema.spec.ts +++ b/test/unit/schemas/event-schema.spec.ts @@ -3,7 +3,7 @@ import { expect } from 'chai' import { Event } from '../../../src/@types/event' import { eventSchema } from '../../../src/schemas/event-schema' -import { EventTags } from '../../../src/constants/base' +import { EventKinds, EventTags } from '../../../src/constants/base' import { validateSchema } from '../../../src/utils/validation' describe('NIP-01', () => { @@ -109,6 +109,60 @@ describe('NIP-01', () => { }) }) +describe('NIP-65', () => { + const relayListBase: Event = { + id: 'fa4dd948576fe182f5d0e3120b9df42c83dffa1c884754d5e4d3b0a2f98a01c5', + pubkey: 'edfa27d49d2af37ee331e1225bb6ed1912c6d999281b36d8018ad99bc3573c29', + created_at: 1660306803, + kind: EventKinds.RELAY_LIST, + tags: [], + content: '', + sig: '313a9b8cd68267a51da84e292c0937d1f3686c6757c4584f50fcedad2b13fad755e6226924f79880fb5aa9de95c04231a4823981513ac9e7092bad7488282a96', + } + + it('accepts relay_list event with valid wss relay URL', () => { + const event = { ...relayListBase, tags: [[EventTags.Relay, 'wss://relay.example.com']] } + const result = validateSchema(eventSchema)(event) + expect(result.error).to.be.undefined + }) + + it('accepts relay_list event with valid wss relay URL and read marker', () => { + const event = { ...relayListBase, tags: [[EventTags.Relay, 'wss://relay.example.com', 'read']] } + const result = validateSchema(eventSchema)(event) + expect(result.error).to.be.undefined + }) + + it('accepts relay_list event with valid wss relay URL and write marker', () => { + const event = { ...relayListBase, tags: [[EventTags.Relay, 'wss://relay.example.com', 'write']] } + const result = validateSchema(eventSchema)(event) + expect(result.error).to.be.undefined + }) + + it('accepts relay_list event with no relay tags', () => { + const event = { ...relayListBase, tags: [] } + const result = validateSchema(eventSchema)(event) + expect(result.error).to.be.undefined + }) + + it('rejects relay_list event with invalid relay URL', () => { + const event = { ...relayListBase, tags: [[EventTags.Relay, 'not-a-url']] } + const result = validateSchema(eventSchema)(event) + expect(result.error).to.not.be.undefined + }) + + it('rejects relay_list event with empty relay URL', () => { + const event = { ...relayListBase, tags: [[EventTags.Relay, '']] } + const result = validateSchema(eventSchema)(event) + expect(result.error).to.not.be.undefined + }) + + it('does not validate relay URL on non-relay_list events with r tags', () => { + const event = { ...relayListBase, kind: EventKinds.TEXT_NOTE, tags: [[EventTags.Relay, 'not-a-url']] } + const result = validateSchema(eventSchema)(event) + expect(result.error).to.be.undefined + }) +}) + describe('NIP-14', () => { it('accepts subject tag on text note events', () => { const event: Event = {