-
Notifications
You must be signed in to change notification settings - Fork 14
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add support for NIP-65 Relay List Metadata
- Loading branch information
Showing
8 changed files
with
342 additions
and
5 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,154 @@ | ||
// | ||
// RelayListMetadataEvent.swift | ||
// | ||
// | ||
// Created by Terry Yiu on 7/13/24. | ||
// | ||
|
||
import Foundation | ||
import OrderedCollections | ||
|
||
/// Defines a replaceable event using kind 10002 to advertise preferred relays for discovering a user's content and receiving fresh content from others. | ||
/// This event doesn't fully replace relay lists that are designed to configure a client's usage of relays. | ||
/// Clients MAY use other relay lists in situations where ``RelayListMetadataEvent`` cannot be found. | ||
/// | ||
/// When seeking events from a user, clients SHOULD use the WRITE relays. | ||
/// When seeking events about a user, where the user was tagged, clients SHOULD use the READ relays. | ||
/// | ||
/// When broadcasting an event, clients SHOULD: | ||
/// - Broadcast the event to the WRITE relays of the author | ||
/// - Broadcast the event to all READ relays of each tagged user | ||
/// | ||
/// > Note: [NIP 65 - Relay List Metadata](https://github.com/nostr-protocol/nips/blob/master/65.md) | ||
public final class RelayListMetadataEvent: NostrEvent, NonParameterizedReplaceableEvent { | ||
|
||
public required init(from decoder: Decoder) throws { | ||
try super.init(from: decoder) | ||
} | ||
|
||
@available(*, unavailable, message: "This initializer is unavailable for this class.") | ||
override init(kind: EventKind, content: String, tags: [Tag] = [], createdAt: Int64 = Int64(Date.now.timeIntervalSince1970), signedBy keypair: Keypair) throws { | ||
try super.init(kind: kind, content: content, tags: tags, createdAt: createdAt, signedBy: keypair) | ||
} | ||
|
||
init(tags: [Tag] = [], createdAt: Int64 = Int64(Date.now.timeIntervalSince1970), signedBy keypair: Keypair) throws { | ||
try super.init(kind: .relayListMetadata, content: "", tags: tags, createdAt: createdAt, signedBy: keypair) | ||
} | ||
|
||
/// The list of ``UserRelayMetadata`` that describes preferred relays for discovering the user's content and receiving fresh content from others | ||
public var relayMetadataList: [UserRelayMetadata] { | ||
tags.compactMap { UserRelayMetadata(tag: $0) } | ||
} | ||
} | ||
|
||
/// Describes a preferred relay for discovering a user's content and receiving fresh content from others. | ||
public struct UserRelayMetadata: Equatable { | ||
/// The URL of the preferred relay. | ||
public let relayURL: URL | ||
|
||
/// The relay marker describing what type of events might be found from the preferred relay. | ||
public let marker: Marker | ||
|
||
public enum Marker { | ||
/// When seeking events about the user who authored the ``RelayListMetadataEvent``, where the user was tagged, | ||
/// clients SHOULD use this relay as a read relay. | ||
case read | ||
|
||
/// When seeking events from the user who authored the ``RelayListMetadataEvent``, | ||
/// clients SHOULD use this relay as a write relay. | ||
case write | ||
|
||
/// When seeking events about the user who authored the ``RelayListMetadataEvent``, where the user was tagged, | ||
/// or when seeking events from the user who authored the ``RelayListMetadataEvent``, | ||
/// clients SHOULD use this relay as a read and write relay. | ||
case readAndWrite | ||
} | ||
|
||
/// Creates a ``UserRelayMetadata`` from a ``Tag``. | ||
/// The tag must have a tag name of `r`, value of a valid relay URL string, and, optionally, a marker of `read` or `write`. | ||
/// If the marker is omitted, the relay is used for both read and write. | ||
/// | ||
/// A `nil` value is returned if the relay URL string is invalid or the marker is invalid. | ||
/// | ||
/// > Note: [NIP 65 - Relay List Metadata](https://github.com/nostr-protocol/nips/blob/master/65.md) | ||
public init?(tag: Tag) { | ||
guard tag.name == "r", let relayURL = try? RelayURLValidator.shared.validateRelayURLString(tag.value) else { | ||
return nil | ||
} | ||
|
||
switch tag.otherParameters.first { | ||
case "read": | ||
marker = .read | ||
case "write": | ||
marker = .write | ||
case .none: | ||
marker = .readAndWrite | ||
case .some: | ||
return nil | ||
} | ||
|
||
self.relayURL = relayURL | ||
} | ||
|
||
/// Creates a ``UserRelayMetadata`` from a relay ``URL`` and ``Marker``. | ||
/// | ||
/// A `nil` value is returned if the relay URL string is invalid. | ||
/// | ||
/// > Note: [NIP 65 - Relay List Metadata](https://github.com/nostr-protocol/nips/blob/master/65.md) | ||
public init?(relayURL: URL, marker: Marker) { | ||
guard let validatedRelayURL = try? RelayURLValidator.shared.validateRelayURL(relayURL) else { | ||
return nil | ||
} | ||
self.relayURL = validatedRelayURL | ||
self.marker = marker | ||
} | ||
|
||
/// The ``Tag`` that represents the user relay metadata that can be used in a ``RelayListMetadataEvent``. | ||
public var tag: Tag { | ||
let otherParameters: [String] | ||
switch marker { | ||
case .read: | ||
otherParameters = ["read"] | ||
case .write: | ||
otherParameters = ["write"] | ||
case .readAndWrite: | ||
otherParameters = [] | ||
} | ||
return Tag(name: "r", value: relayURL.absoluteString, otherParameters: otherParameters) | ||
} | ||
} | ||
|
||
public extension EventCreating { | ||
/// Creates a ``RelayListMetadataEvent`` (kind 10002). | ||
/// - Parameters: | ||
/// - relayMetadataList: The list of ``UserRelayMetadata``. | ||
/// - keypair: The ``Keypair`` to sign the event with. | ||
func relayListMetadataEvent(withRelayMetadataList relayMetadataList: [UserRelayMetadata], signedBy keypair: Keypair) throws -> RelayListMetadataEvent { | ||
// Using an ordered dictionary to retain the order of the list while de-duplicating the data. | ||
var deduplicatedMetadata = OrderedDictionary<URL, UserRelayMetadata>() | ||
|
||
for metadata in relayMetadataList { | ||
if let existingMetadata = deduplicatedMetadata[metadata.relayURL] { | ||
// If the user relay metadata is identical between the duplicates, | ||
// or if the existing one already has a read and write marker, skip it. | ||
guard existingMetadata.marker != metadata.marker && existingMetadata.marker != .readAndWrite else { | ||
continue | ||
} | ||
|
||
// Any other permutation of markers will result in a combined marker of read and write. | ||
switch metadata.marker { | ||
case .readAndWrite: | ||
// If the marker on `metadata` is set to read and write, just use that as the value | ||
// instead of creating a new object (as a micro-optimization). | ||
deduplicatedMetadata[metadata.relayURL] = metadata | ||
default: | ||
deduplicatedMetadata[metadata.relayURL] = UserRelayMetadata(relayURL: metadata.relayURL, marker: .readAndWrite) | ||
} | ||
} else { | ||
deduplicatedMetadata[metadata.relayURL] = metadata | ||
} | ||
} | ||
|
||
return try RelayListMetadataEvent(tags: deduplicatedMetadata.map { $0.value.tag }, signedBy: keypair) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
140 changes: 140 additions & 0 deletions
140
Tests/NostrSDKTests/Events/RelayListMetadataEventTests.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,140 @@ | ||
// | ||
// RelayListMetadataEventTests.swift | ||
// | ||
// | ||
// Created by Terry Yiu on 7/14/24. | ||
// | ||
|
||
@testable import NostrSDK | ||
import XCTest | ||
|
||
final class RelayListMetadataEventTests: XCTestCase, EventCreating, EventVerifying, FixtureLoading { | ||
|
||
func testCreateRelayListMetadata() throws { | ||
let relayURL1 = try XCTUnwrap(URL(string: "wss://relay.primal.net")) | ||
let relayURL2 = try XCTUnwrap(URL(string: "wss://relay.damus.io")) | ||
let relayURL3 = try XCTUnwrap(URL(string: "wss://relay.snort.social")) | ||
|
||
let expectedRelayMetadataList: [UserRelayMetadata] = [ | ||
try XCTUnwrap(UserRelayMetadata(relayURL: relayURL1, marker: .read)), | ||
try XCTUnwrap(UserRelayMetadata(relayURL: relayURL2, marker: .write)), | ||
try XCTUnwrap(UserRelayMetadata(relayURL: relayURL3, marker: .readAndWrite)) | ||
] | ||
let event = try XCTUnwrap(relayListMetadataEvent(withRelayMetadataList: expectedRelayMetadataList, signedBy: Keypair.test)) | ||
|
||
let expectedEventCoordinates = try XCTUnwrap(EventCoordinates(kind: .relayListMetadata, pubkey: Keypair.test.publicKey)) | ||
XCTAssertEqual(event.replaceableEventCoordinates(), expectedEventCoordinates) | ||
|
||
let tag1 = Tag(name: "r", value: relayURL1.absoluteString, otherParameters: ["read"]) | ||
let tag2 = Tag(name: "r", value: relayURL2.absoluteString, otherParameters: ["write"]) | ||
let tag3 = Tag(name: "r", value: relayURL3.absoluteString) | ||
XCTAssertEqual(event.tags, [tag1, tag2, tag3]) | ||
|
||
let relayMetadataList = event.relayMetadataList | ||
XCTAssertEqual(relayMetadataList.count, expectedRelayMetadataList.count) | ||
|
||
XCTAssertEqual(relayMetadataList[0].relayURL.absoluteString, relayURL1.absoluteString) | ||
XCTAssertEqual(relayMetadataList[0].marker, .read) | ||
XCTAssertEqual(relayMetadataList[0].tag, tag1) | ||
XCTAssertEqual(relayMetadataList[1].relayURL.absoluteString, relayURL2.absoluteString) | ||
XCTAssertEqual(relayMetadataList[1].marker, .write) | ||
XCTAssertEqual(relayMetadataList[1].tag, tag2) | ||
XCTAssertEqual(relayMetadataList[2].relayURL.absoluteString, relayURL3.absoluteString) | ||
XCTAssertEqual(relayMetadataList[2].marker, .readAndWrite) | ||
XCTAssertEqual(relayMetadataList[2].tag, tag3) | ||
|
||
try verifyEvent(event) | ||
} | ||
|
||
func testCreateRelayListMetadataWithDuplicates() throws { | ||
let relayURL1 = try XCTUnwrap(URL(string: "wss://relay.primal.net")) | ||
let relayURL2 = try XCTUnwrap(URL(string: "wss://relay.damus.io")) | ||
let relayURL3 = try XCTUnwrap(URL(string: "wss://relay.snort.social")) | ||
let relayURL4 = try XCTUnwrap(URL(string: "wss://relay.nostrsdk.com")) | ||
let relayURL5 = try XCTUnwrap(URL(string: "wss://relay.coracle.social")) | ||
let relayURL6 = try XCTUnwrap(URL(string: "wss://relay.nostr.band")) | ||
|
||
let relayMetadataListWithDuplicates: [UserRelayMetadata] = [ | ||
try XCTUnwrap(UserRelayMetadata(relayURL: relayURL1, marker: .read)), | ||
try XCTUnwrap(UserRelayMetadata(relayURL: relayURL2, marker: .write)), | ||
try XCTUnwrap(UserRelayMetadata(relayURL: relayURL3, marker: .read)), | ||
try XCTUnwrap(UserRelayMetadata(relayURL: relayURL4, marker: .readAndWrite)), | ||
try XCTUnwrap(UserRelayMetadata(relayURL: relayURL5, marker: .write)), | ||
try XCTUnwrap(UserRelayMetadata(relayURL: relayURL6, marker: .read)), | ||
try XCTUnwrap(UserRelayMetadata(relayURL: relayURL6, marker: .read)), | ||
try XCTUnwrap(UserRelayMetadata(relayURL: relayURL5, marker: .write)), | ||
try XCTUnwrap(UserRelayMetadata(relayURL: relayURL4, marker: .write)), | ||
try XCTUnwrap(UserRelayMetadata(relayURL: relayURL3, marker: .readAndWrite)), | ||
try XCTUnwrap(UserRelayMetadata(relayURL: relayURL2, marker: .read)), | ||
try XCTUnwrap(UserRelayMetadata(relayURL: relayURL1, marker: .write)) | ||
] | ||
let event = try XCTUnwrap(relayListMetadataEvent(withRelayMetadataList: relayMetadataListWithDuplicates, signedBy: Keypair.test)) | ||
|
||
let expectedEventCoordinates = try XCTUnwrap(EventCoordinates(kind: .relayListMetadata, pubkey: Keypair.test.publicKey)) | ||
XCTAssertEqual(event.replaceableEventCoordinates(), expectedEventCoordinates) | ||
|
||
let tag1 = Tag(name: "r", value: relayURL1.absoluteString) | ||
let tag2 = Tag(name: "r", value: relayURL2.absoluteString) | ||
let tag3 = Tag(name: "r", value: relayURL3.absoluteString) | ||
let tag4 = Tag(name: "r", value: relayURL4.absoluteString) | ||
let tag5 = Tag(name: "r", value: relayURL5.absoluteString, otherParameters: ["write"]) | ||
let tag6 = Tag(name: "r", value: relayURL6.absoluteString, otherParameters: ["read"]) | ||
XCTAssertEqual(event.tags, [tag1, tag2, tag3, tag4, tag5, tag6]) | ||
|
||
let relayMetadataList = event.relayMetadataList | ||
XCTAssertEqual(relayMetadataList.count, 6) | ||
|
||
XCTAssertEqual(relayMetadataList[0].relayURL.absoluteString, relayURL1.absoluteString) | ||
XCTAssertEqual(relayMetadataList[0].marker, .readAndWrite) | ||
XCTAssertEqual(relayMetadataList[0].tag, tag1) | ||
XCTAssertEqual(relayMetadataList[1].relayURL.absoluteString, relayURL2.absoluteString) | ||
XCTAssertEqual(relayMetadataList[1].marker, .readAndWrite) | ||
XCTAssertEqual(relayMetadataList[1].tag, tag2) | ||
XCTAssertEqual(relayMetadataList[2].relayURL.absoluteString, relayURL3.absoluteString) | ||
XCTAssertEqual(relayMetadataList[2].marker, .readAndWrite) | ||
XCTAssertEqual(relayMetadataList[2].tag, tag3) | ||
XCTAssertEqual(relayMetadataList[3].relayURL.absoluteString, relayURL4.absoluteString) | ||
XCTAssertEqual(relayMetadataList[3].marker, .readAndWrite) | ||
XCTAssertEqual(relayMetadataList[3].tag, tag4) | ||
XCTAssertEqual(relayMetadataList[4].relayURL.absoluteString, relayURL5.absoluteString) | ||
XCTAssertEqual(relayMetadataList[4].marker, .write) | ||
XCTAssertEqual(relayMetadataList[4].tag, tag5) | ||
XCTAssertEqual(relayMetadataList[5].relayURL.absoluteString, relayURL6.absoluteString) | ||
XCTAssertEqual(relayMetadataList[5].marker, .read) | ||
XCTAssertEqual(relayMetadataList[5].tag, tag6) | ||
|
||
try verifyEvent(event) | ||
} | ||
|
||
func testDecodeRelayListMetadata() throws { | ||
let event: RelayListMetadataEvent = try decodeFixture(filename: "relay_list_metadata") | ||
XCTAssertEqual(event.id, "68962e8499c2067306b2daaa8811b95f38d5e7b8954976d15a8419751a96757a") | ||
XCTAssertEqual(event.pubkey, "cb9f20cbd8616dcb79ce1dbdcec702b9b1549e678225ce035b31db4d820f4418") | ||
XCTAssertEqual(event.createdAt, 1720822990) | ||
XCTAssertEqual(event.kind, .relayListMetadata) | ||
XCTAssertEqual(event.content, "") | ||
XCTAssertEqual(event.signature, "0b40f4f5de3f6cb2223a7961d7b2a3c8f3b5944a275dc27ba3cea765f6be127fb311f10218e25001bee0e30ac6a2ed7dfa45d8c77e3973ce599eaa0bd1e2423b") | ||
|
||
let publicKey = try XCTUnwrap(PublicKey(hex: event.pubkey)) | ||
let expectedEventCoordinates = try XCTUnwrap(EventCoordinates(kind: .relayListMetadata, pubkey: publicKey)) | ||
XCTAssertEqual(event.replaceableEventCoordinates(), expectedEventCoordinates) | ||
|
||
let tag1 = Tag(name: "r", value: "wss://relay.momostr.pink/") | ||
let tag2 = Tag(name: "r", value: "wss://relay.primal.net/", otherParameters: ["read"]) | ||
let tag3 = Tag(name: "r", value: "wss://relay.nostr.band/", otherParameters: ["read"]) | ||
|
||
XCTAssertEqual(event.tags, [tag1, tag2, tag3]) | ||
|
||
XCTAssertEqual(event.relayMetadataList.count, 3) | ||
XCTAssertEqual(event.relayMetadataList[0].relayURL.absoluteString, "wss://relay.momostr.pink/") | ||
XCTAssertEqual(event.relayMetadataList[0].marker, .readAndWrite) | ||
XCTAssertEqual(event.relayMetadataList[0].tag, tag1) | ||
XCTAssertEqual(event.relayMetadataList[1].relayURL.absoluteString, "wss://relay.primal.net/") | ||
XCTAssertEqual(event.relayMetadataList[1].marker, .read) | ||
XCTAssertEqual(event.relayMetadataList[1].tag, tag2) | ||
XCTAssertEqual(event.relayMetadataList[2].relayURL.absoluteString, "wss://relay.nostr.band/") | ||
XCTAssertEqual(event.relayMetadataList[2].marker, .read) | ||
XCTAssertEqual(event.relayMetadataList[2].tag, tag3) | ||
} | ||
|
||
} |
Oops, something went wrong.