Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/nip-65-relay-list-metadata.md
Original file line number Diff line number Diff line change
@@ -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).
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
33,
40,
44,
45
45,
65
],
"supportedNipExtensions": [],
"main": "src/index.ts",
Expand Down
5 changes: 5 additions & 0 deletions src/@types/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion src/cli/commands/info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,12 @@ const getEventCount = async (): Promise<number | null> => {
}

const getRelayUptimeSeconds = async (): Promise<number | null> => {
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
}
Expand Down
2 changes: 2 additions & 0 deletions src/constants/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ export enum EventKinds {
ZAP_RECEIPT = 9735,
// Replaceable events
REPLACEABLE_FIRST = 10000,
// NIP-65: Relay List Metadata
RELAY_LIST = 10002,
REPLACEABLE_LAST = 19999,
// Ephemeral events
EPHEMERAL_FIRST = 20000,
Expand Down
3 changes: 2 additions & 1 deletion src/factories/event-strategy-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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)
Expand Down
14 changes: 14 additions & 0 deletions src/schemas/event-schema.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { z } from 'zod'

import { EventKinds, EventTags } from '../constants/base'
import { createdAtSchema, idSchema, kindSchema, pubkeySchema, signatureSchema, tagSchema } from './base-schema'

/**
Expand Down Expand Up @@ -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],
})
}
})
}
})
12 changes: 12 additions & 0 deletions src/utils/nip65.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Event, RelayListEntry } from '../@types/event'
import { EventKinds, EventTags } from '../constants/base'

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],
Comment thread
CKodidela marked this conversation as resolved.
marker: tag[2] === 'read' || tag[2] === 'write' ? tag[2] : undefined,
}))
27 changes: 27 additions & 0 deletions test/integration/features/nip-65/nip-65.feature
Original file line number Diff line number Diff line change
@@ -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"
104 changes: 104 additions & 0 deletions test/integration/features/nip-65/nip-65.feature.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, any>>, 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<Record<string, any>>, relayUrl: string) {
const events: Event[] = this.parameters.lastRelayListEvents
expect(events[0].tags).to.deep.include(['r', relayUrl])
},
)
5 changes: 5 additions & 0 deletions test/unit/factories/event-strategy-factory.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
56 changes: 55 additions & 1 deletion test/unit/schemas/event-schema.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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 = {
Expand Down
Loading
Loading