Skip to content

Commit

Permalink
Add support for NIP-65 Relay List Metadata
Browse files Browse the repository at this point in the history
  • Loading branch information
tyiu committed Jul 14, 2024
1 parent 0275657 commit b08507a
Show file tree
Hide file tree
Showing 8 changed files with 341 additions and 5 deletions.
9 changes: 9 additions & 0 deletions Package.resolved
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 4 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 9 additions & 1 deletion Sources/NostrSDK/EventKind.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -138,6 +143,7 @@ public enum EventKind: RawRepresentable, CaseIterable, Codable, Equatable, Hasha
.giftWrap,
.report,
.muteList,
.relayListMetadata,
.bookmarksList,
.authentication,
.longformContent,
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
153 changes: 153 additions & 0 deletions Sources/NostrSDK/Events/RelayListMetadataEvent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
//
// 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:
["read"]
case .write:
["write"]
case .readAndWrite:
[]
}
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)
}
}
2 changes: 1 addition & 1 deletion Sources/NostrSDK/Tag.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)\""
}
}
140 changes: 140 additions & 0 deletions Tests/NostrSDKTests/Events/RelayListMetadataEventTests.swift
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)
}

}
Loading

0 comments on commit b08507a

Please sign in to comment.