Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 2 additions & 0 deletions src/constants/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ export enum EventKinds {
// Lightning zaps
ZAP_REQUEST = 9734,
ZAP_RECEIPT = 9735,
// NIP-65: Relay List Metadata
RELAY_LIST = 10002,
Comment thread
CKodidela marked this conversation as resolved.
Outdated
// Replaceable events
REPLACEABLE_FIRST = 10000,
REPLACEABLE_LAST = 19999,
Expand Down
17 changes: 17 additions & 0 deletions src/utils/nip65.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Event } from '../@types/event'
import { EventKinds, EventTags } from '../constants/base'

export type RelayListEntry = {
Comment thread
CKodidela marked this conversation as resolved.
Outdated
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],
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
90 changes: 90 additions & 0 deletions test/unit/utils/nip65.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { expect } from 'chai'
import { Event } from '../../../src/@types/event'
import { isRelayListEvent, parseRelayList } from '../../../src/utils/nip65'

const baseEvent = (): Partial<Event> => ({
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' },
])
})
})
})
Loading