From 96f2c70dabd1fc0d3cc7271ca396c49e96638281 Mon Sep 17 00:00:00 2001 From: Terry Yiu <963907+tyiu@users.noreply.github.com> Date: Sun, 14 Jul 2024 11:15:46 -0400 Subject: [PATCH] Add support for NIP-65 Relay List Metadata --- Package.resolved | 9 + Package.swift | 6 +- README.md | 2 +- Sources/NostrSDK/EventKind.swift | 10 +- .../Events/RelayListMetadataEvent.swift | 154 ++++++++++++++++++ Sources/NostrSDK/Tag.swift | 2 +- .../Events/RelayListMetadataEventTests.swift | 140 ++++++++++++++++ .../Fixtures/relay_list_metadata.json | 24 +++ 8 files changed, 342 insertions(+), 5 deletions(-) create mode 100644 Sources/NostrSDK/Events/RelayListMetadataEvent.swift create mode 100644 Tests/NostrSDKTests/Events/RelayListMetadataEventTests.swift create mode 100644 Tests/NostrSDKTests/Fixtures/relay_list_metadata.json diff --git a/Package.resolved b/Package.resolved index b256477..a151fb5 100644 --- a/Package.resolved +++ b/Package.resolved @@ -18,6 +18,15 @@ "version" : "0.12.2" } }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "3d2dc41a01f9e49d84f0a3925fb858bed64f702d", + "version" : "1.1.2" + } + }, { "identity" : "swift-docc-plugin", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 26aa828..6629b7f 100644 --- a/Package.swift +++ b/Package.swift @@ -17,7 +17,8 @@ let package = Package( // .package(url: /* package url */, from: "1.0.0"), .package(url: "https://github.com/apple/swift-docc-plugin.git", from: "1.0.0"), .package(url: "https://github.com/GigaBitcoin/secp256k1.swift", from: "0.12.2"), - .package(url: "https://github.com/krzyzanowskim/CryptoSwift.git", .upToNextMajor(from: "1.8.1")) + .package(url: "https://github.com/krzyzanowskim/CryptoSwift.git", .upToNextMajor(from: "1.8.1")), + .package(url: "https://github.com/apple/swift-collections.git", .upToNextMajor(from: "1.1.2")) ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. @@ -26,7 +27,8 @@ let package = Package( name: "NostrSDK", dependencies: [ .product(name: "secp256k1", package: "secp256k1.swift"), - "CryptoSwift" + "CryptoSwift", + .product(name: "OrderedCollections", package: "swift-collections") ] ), .testTarget( diff --git a/README.md b/README.md index 3044df3..7ba66de 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ The following [NIPs](https://github.com/nostr-protocol/nips) are implemented: - [ ] [NIP-57: Lightning Zaps](https://github.com/nostr-protocol/nips/blob/master/57.md) - [ ] [NIP-58: Badges](https://github.com/nostr-protocol/nips/blob/master/58.md) - [x] [NIP-59: Gift Wrap](https://github.com/nostr-protocol/nips/blob/master/59.md) -- [ ] [NIP-65: Relay List Metadata](https://github.com/nostr-protocol/nips/blob/master/65.md) +- [x] [NIP-65: Relay List Metadata](https://github.com/nostr-protocol/nips/blob/master/65.md) - [ ] [NIP-72: Moderated Communities](https://github.com/nostr-protocol/nips/blob/master/72.md) - [ ] [NIP-75: Zap Goals](https://github.com/nostr-protocol/nips/blob/master/75.md) - [ ] [NIP-78: Application-specific data](https://github.com/nostr-protocol/nips/blob/master/78.md) diff --git a/Sources/NostrSDK/EventKind.swift b/Sources/NostrSDK/EventKind.swift index d6d4b4d..5175501 100644 --- a/Sources/NostrSDK/EventKind.swift +++ b/Sources/NostrSDK/EventKind.swift @@ -86,7 +86,12 @@ public enum EventKind: RawRepresentable, CaseIterable, Codable, Equatable, Hasha /// /// See [NIP-51](https://github.com/nostr-protocol/nips/blob/master/51.md#standard-lists) case muteList - + + /// This kind of replaceable event advertises preferred relays for discovering a user's content and receiving fresh content from others. + /// + /// See [NIP-65 - Relay List Metadata](https://github.com/nostr-protocol/nips/blob/master/65.md) + case relayListMetadata + /// This kind of event contains an uncategorized, "global" list of things a user wants to save. /// /// See [NIP-51](https://github.com/nostr-protocol/nips/blob/master/51.md#standard-lists) @@ -138,6 +143,7 @@ public enum EventKind: RawRepresentable, CaseIterable, Codable, Equatable, Hasha .giftWrap, .report, .muteList, + .relayListMetadata, .bookmarksList, .authentication, .longformContent, @@ -170,6 +176,7 @@ public enum EventKind: RawRepresentable, CaseIterable, Codable, Equatable, Hasha case .giftWrap: return 1059 case .report: return 1984 case .muteList: return 10000 + case .relayListMetadata: return 10002 case .bookmarksList: return 10003 case .authentication: return 22242 case .longformContent: return 30023 @@ -196,6 +203,7 @@ public enum EventKind: RawRepresentable, CaseIterable, Codable, Equatable, Hasha case .giftWrap: return GiftWrapEvent.self case .report: return ReportEvent.self case .muteList: return MuteListEvent.self + case .relayListMetadata: return RelayListMetadataEvent.self case .bookmarksList: return BookmarksListEvent.self case .authentication: return AuthenticationEvent.self case .longformContent: return LongformContentEvent.self diff --git a/Sources/NostrSDK/Events/RelayListMetadataEvent.swift b/Sources/NostrSDK/Events/RelayListMetadataEvent.swift new file mode 100644 index 0000000..fc0bb15 --- /dev/null +++ b/Sources/NostrSDK/Events/RelayListMetadataEvent.swift @@ -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() + + 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) + } +} diff --git a/Sources/NostrSDK/Tag.swift b/Sources/NostrSDK/Tag.swift index 7f7ff66..d717096 100644 --- a/Sources/NostrSDK/Tag.swift +++ b/Sources/NostrSDK/Tag.swift @@ -180,6 +180,6 @@ extension Tag { extension Tag: CustomDebugStringConvertible { public var debugDescription: String { - "Tag(name: \"\(name)\", value: \"\(value)\")" + "Tag(name: \"\(name)\", value: \"\(value)\", otherParameters: \(otherParameters)" } } diff --git a/Tests/NostrSDKTests/Events/RelayListMetadataEventTests.swift b/Tests/NostrSDKTests/Events/RelayListMetadataEventTests.swift new file mode 100644 index 0000000..cc0ae53 --- /dev/null +++ b/Tests/NostrSDKTests/Events/RelayListMetadataEventTests.swift @@ -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) + } + +} diff --git a/Tests/NostrSDKTests/Fixtures/relay_list_metadata.json b/Tests/NostrSDKTests/Fixtures/relay_list_metadata.json new file mode 100644 index 0000000..87bd14b --- /dev/null +++ b/Tests/NostrSDKTests/Fixtures/relay_list_metadata.json @@ -0,0 +1,24 @@ +{ + "id": "68962e8499c2067306b2daaa8811b95f38d5e7b8954976d15a8419751a96757a", + "pubkey": "cb9f20cbd8616dcb79ce1dbdcec702b9b1549e678225ce035b31db4d820f4418", + "created_at": 1720822990, + "kind": 10002, + "tags": [ + [ + "r", + "wss://relay.momostr.pink/" + ], + [ + "r", + "wss://relay.primal.net/", + "read" + ], + [ + "r", + "wss://relay.nostr.band/", + "read" + ] + ], + "content": "", + "sig": "0b40f4f5de3f6cb2223a7961d7b2a3c8f3b5944a275dc27ba3cea765f6be127fb311f10218e25001bee0e30ac6a2ed7dfa45d8c77e3973ce599eaa0bd1e2423b" +}