Skip to content

Commit

Permalink
Add NIP-19 bech32 identifier generation for note, nevent, and naddr p…
Browse files Browse the repository at this point in the history
…refixes (nostr-sdk#166)

* Add NIP-19 bech32 identifier generation for note, nevent, and naddr prefixes

* Apply suggestions from code review

Co-authored-by: Bryan Montz <[email protected]>

* Fix incomplete sentence in the Bech32IdentifierType documentation

* Remove unused bech32NoteIdPrefix constant

* Inverse the excludeAuthor and excludeKind parameters in the shareableEventCoordinates function in the ReplaceableEvent protocol for easier readability

* Refactor duplicate code for the shareableEventCoordinates function for ReplaceableEvents

---------

Co-authored-by: Bryan Montz <[email protected]>
  • Loading branch information
2 people authored and RandyMcMillan committed Sep 1, 2024
1 parent 24a0f9b commit 74afc55
Show file tree
Hide file tree
Showing 14 changed files with 361 additions and 25 deletions.
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-15: Nostr Marketplace (for resilient marketplaces)](https://github.com/nostr-protocol/nips/blob/master/15.md)
- [ ] [NIP-17: Private Direct Messages](https://github.com/nostr-protocol/nips/blob/master/17.md)
- [x] [NIP-18: Reposts](https://github.com/nostr-protocol/nips/blob/master/18.md)
- [ ] [NIP-19: bech32-encoded entities](https://github.com/nostr-protocol/nips/blob/master/19.md)
- [x] [NIP-19: bech32-encoded entities](https://github.com/nostr-protocol/nips/blob/master/19.md)
- [ ] [NIP-21: `nostr:` URI scheme](https://github.com/nostr-protocol/nips/blob/master/21.md)
- [x] [NIP-23: Long-form Content](https://github.com/nostr-protocol/nips/blob/master/23.md)
- [x] [NIP-24: Extra metadata fields and tags](https://github.com/nostr-protocol/nips/blob/master/24.md)
Expand Down
20 changes: 20 additions & 0 deletions Sources/NostrSDK/Bech32IdentifierType.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
//
// Bech32IdentifierType.swift
//
//
// Created by Terry Yiu on 6/30/24.
//

/// The type of Bech32-encoded identifier.
/// These identifiers can be used to succinctly encapsulate metadata to aid in the discovery of events and users.
/// See [NIP-19](https://github.com/nostr-protocol/nips/blob/master/19.md) for information about how these
/// identifiers are encoded and used.
public enum Bech32IdentifierType: String {
case publicKey = "npub"
case privateKey = "nsec"
case note = "note"
case profile = "nprofile"
case event = "nevent"
case relay = "nrelay"
case address = "naddr"
}
2 changes: 1 addition & 1 deletion Sources/NostrSDK/Events/AuthenticationEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import Foundation
/// This kind is not meant to be published or queried.
///
/// See [NIP-42](https://github.com/nostr-protocol/nips/blob/master/42.md).
public final class AuthenticationEvent: NostrEvent, RelayProviding, RelayURLValidating {
public final class AuthenticationEvent: NostrEvent, RelayProviding {
public required init(from decoder: Decoder) throws {
try super.init(from: decoder)
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/NostrSDK/Events/GenericRepostEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import Foundation
/// A generic repost event (kind 16) can include any kind of event inside other than kind 1.
/// > Note: Generic reposts SHOULD contain a `k` tag with the stringified kind number of the reposted event as its value.
/// See [NIP-18](https://github.com/nostr-protocol/nips/blob/master/18.md#generic-reposts).
public class GenericRepostEvent: NostrEvent, RelayURLValidating {
public class GenericRepostEvent: NostrEvent {

public required init(from decoder: Decoder) throws {
try super.init(from: decoder)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,8 @@ public extension NonParameterizedReplaceableEvent {

return try? EventCoordinates(kind: kind, pubkey: publicKey, relayURL: relayURL)
}

func shareableEventCoordinates(relayURLStrings: [String]? = nil, includeAuthor: Bool = true, includeKind: Bool = true) throws -> String {
try shareableEventCoordinates(relayURLStrings: relayURLStrings, includeAuthor: includeAuthor, includeKind: includeKind, identifier: "")
}
}
43 changes: 43 additions & 0 deletions Sources/NostrSDK/Events/NostrEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -141,3 +141,46 @@ public class NostrEvent: Codable, Equatable, Hashable {
tags.filter { $0.name == tag.rawValue }.map { $0.value }
}
}

extension NostrEvent: MetadataCoding, RelayURLValidating {

/// Gets a bare `note`-prefixed bech32-formatted human-friendly id of this event, or `nil` if it could not be generated.
/// It is not meant to be used inside the standard NIP-01 event formats or inside the filters.
/// They are meant for human-friendlier display and input only.
/// Clients should still accept keys in both hex and npub format and convert internally.
///
/// > Note: [NIP-19 bech32-encoded entities](https://github.com/nostr-protocol/nips/blob/master/19.md)
public var bech32NoteId: String? {
guard let data = id.hexDecoded else {
return nil
}
return Bech32.encode(Bech32IdentifierType.note.rawValue, baseEightData: data)
}

/// Gets a shareable human-interactable event identifier for this event.
/// The identifier is bech32-formatted with a prefix of `nevent` using a binary-encoded list of TLV (type-length-value).
/// The identifier has all the information needed for the event to be found, which includes the
/// event id, optionally the relays, optionally the author's public key, and optionally the event kind number.
/// - Parameters:
/// - relayURLs: The String representations of relay URLs in which the event is more likely to be found, encoded as ASCII.
/// - excludeAuthor: Whether the author public key should be excluded from the identifier.
/// - excludeKind: Whether the event kind number should be excluded from the identifier.
/// - Throws: `URLError.Code.badURL`, `RelayURLError.invalidScheme`, `TLVCodingError.failedToEncode`
///
/// > Note: [NIP-19 bech32-encoded entities](https://github.com/nostr-protocol/nips/blob/master/19.md)
public func shareableEventIdentifier(relayURLStrings: [String]? = nil, excludeAuthor: Bool = false, excludeKind: Bool = false) throws -> String {
let validatedRelayURLStrings = try relayURLStrings?.map {
try validateRelayURLString($0)
}.map { $0.absoluteString }

var metadata = Metadata(relays: validatedRelayURLStrings, eventId: id)
if !excludeAuthor {
metadata.pubkey = pubkey
}
if !excludeKind {
metadata.kind = UInt32(kind.rawValue)
}

return try encodedIdentifier(with: metadata, identifierType: .event)
}
}
6 changes: 5 additions & 1 deletion Sources/NostrSDK/Events/ParameterizedReplaceableEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import Foundation

public protocol ParameterizedReplaceableEvent: ReplaceableEvent {}
public protocol ParameterizedReplaceableEvent: ReplaceableEvent, MetadataCoding {}
public extension ParameterizedReplaceableEvent {
/// The identifier of the event. For parameterized replaceable events, this identifier remains stable across replacements.
/// This identifier is represented by the "d" tag, which is distinctly different from the `id` field on ``NostrEvent``.
Expand All @@ -22,4 +22,8 @@ public extension ParameterizedReplaceableEvent {

return try? EventCoordinates(kind: kind, pubkey: publicKey, identifier: identifier, relayURL: relayURL)
}

func shareableEventCoordinates(relayURLStrings: [String]? = nil, includeAuthor: Bool = true, includeKind: Bool = true) throws -> String {
try shareableEventCoordinates(relayURLStrings: relayURLStrings, includeAuthor: includeAuthor, includeKind: includeKind, identifier: identifier ?? "")
}
}
31 changes: 31 additions & 0 deletions Sources/NostrSDK/Events/ReplaceableEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,35 @@ public protocol ReplaceableEvent: NostrEvent {
/// - Parameters:
/// - relayURL: A relay URL that this replaceable event could be found.
func replaceableEventCoordinates(relayURL: URL?) -> EventCoordinates?

/// Gets a shareable human-interactable event coordinates for this replaceable event.
/// The coordinates are bech32-formatted with a prefix of `nevent` using a binary-encoded list of TLV (type-length-value).
/// The coordinates have all the information needed for this replaceable event to be found, which includes the
/// identifier (if it is parameterized), optionally the relays, optionally the author's public key, and optionally the event kind number.
/// - Parameters:
/// - relayURLStrings: The String representations of relay URLs in which the event is more likely to be found, encoded as ASCII.
/// - includeAuthor: Whether the author public key should be included in the identifier.
/// - includeKind: Whether the event kind number should be included in the identifier.
/// - Throws: `URLError.Code.badURL`, `RelayURLError.invalidScheme`, `TLVCodingError.failedToEncode`
///
/// > Note: [NIP-19 bech32-encoded entities](https://github.com/nostr-protocol/nips/blob/master/19.md)
func shareableEventCoordinates(relayURLStrings: [String]?, includeAuthor: Bool, includeKind: Bool) throws -> String
}

extension ReplaceableEvent {
func shareableEventCoordinates(relayURLStrings: [String]?, includeAuthor: Bool, includeKind: Bool, identifier: String) throws -> String {
let validatedRelayURLStrings = try relayURLStrings?.map {
try validateRelayURLString($0)
}.map { $0.absoluteString }

var metadata = Metadata(relays: validatedRelayURLStrings, identifier: identifier)
if includeAuthor {
metadata.pubkey = pubkey
}
if includeKind {
metadata.kind = UInt32(kind.rawValue)
}

return try encodedIdentifier(with: metadata, identifierType: .address)
}
}
12 changes: 4 additions & 8 deletions Sources/NostrSDK/Keys.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,6 @@ public struct Keypair {
}

public struct PublicKey: Equatable {
static let humanReadablePrefix = "npub"

public static func == (lhs: Self, rhs: Self) -> Bool {
lhs.hex == rhs.hex
}
Expand All @@ -74,7 +72,7 @@ public struct PublicKey: Equatable {
public let dataRepresentation: Data

public init?(dataRepresentation: Data) {
self.init(npub: Bech32.encode(PublicKey.humanReadablePrefix, baseEightData: dataRepresentation))
self.init(npub: Bech32.encode(Bech32IdentifierType.publicKey.rawValue, baseEightData: dataRepresentation))
}

public init?(hex: String) {
Expand All @@ -97,7 +95,7 @@ public struct PublicKey: Equatable {
return nil
}

guard humanReadablePart == PublicKey.humanReadablePrefix else {
guard humanReadablePart == Bech32IdentifierType.publicKey.rawValue else {
Loggers.keypairs.error("Could not create public key because the human readable part, \(humanReadablePart), is not equal to npub.")
return nil
}
Expand All @@ -114,14 +112,12 @@ public struct PublicKey: Equatable {
}

public struct PrivateKey {
static let humanReadablePrefix = "nsec"

public let hex: String
public let nsec: String
public let dataRepresentation: Data

public init?(dataRepresentation: Data) {
self.init(nsec: Bech32.encode(PrivateKey.humanReadablePrefix, baseEightData: dataRepresentation))
self.init(nsec: Bech32.encode(Bech32IdentifierType.privateKey.rawValue, baseEightData: dataRepresentation))
}

public init?(hex: String) {
Expand All @@ -144,7 +140,7 @@ public struct PrivateKey {
return nil
}

guard humanReadablePart == PrivateKey.humanReadablePrefix else {
guard humanReadablePart == Bech32IdentifierType.privateKey.rawValue else {
Loggers.keypairs.error("Could not create private key because the human readable part, \(humanReadablePart), is not equal to nsec.")
return nil
}
Expand Down
23 changes: 12 additions & 11 deletions Sources/NostrSDK/MetadataCoding.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,6 @@

import Foundation

/// The type of Bech32-encoded identifier.
/// These identifiers can be used to succinctly encapsulate metadata to aid in the discovery of events and users.
/// See [NIP-19](https://github.com/nostr-protocol/nips/blob/master/19.md) for information about how these Bech32-encoded
public enum Bech32IdentifierType: String {
case profile = "nprofile"
case event = "nevent"
case relay = "nrelay"
case address = "naddr"
}

/// An error encountered while encoding or decoding TLV (Type-Length-Value) data.
public enum TLVCodingError: Error {
case unknownPrefix
Expand Down Expand Up @@ -70,7 +60,14 @@ public extension MetadataCoding {
guard let identifierType = Bech32IdentifierType(rawValue: hrp) else {
throw TLVCodingError.unknownPrefix
}


switch identifierType {
case .profile, .event, .relay, .address:
break
default:
throw TLVCodingError.unknownPrefix
}

// Given the example profile identifier, the `hrp` will be "nprofile", and we'll use the computed checksum to extract the raw TLV data:
guard let tlvString = checksum.base8FromBase5?.hexString else {
throw TLVCodingError.missingExpectedData
Expand Down Expand Up @@ -155,6 +152,8 @@ public extension MetadataCoding {
if let decoded = content.decoded() {
identifier = decoded
}
default:
throw TLVCodingError.unknownPrefix
}
case .relay:
if let decoded = content.decoded(using: .ascii) {
Expand Down Expand Up @@ -220,6 +219,8 @@ public extension MetadataCoding {
specialTypeValue = relay.data(using: .ascii)
case .address:
specialTypeValue = metadata.identifier?.data(using: .utf8)
default:
throw TLVCodingError.unknownPrefix
}

if let lengthByte = (specialTypeValue ?? Data()).byteLengthString {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
//
// NonParameterizedReplaceableEventTests.swift
//
//
// Created by Terry Yiu on 6/30/24.
//

@testable import NostrSDK
import XCTest

final class NonParameterizedReplaceableEventTests: XCTestCase, FixtureLoading, MetadataCoding {

func testReplaceableEventCoordinates() throws {
let event: MuteListEvent = try decodeFixture(filename: "mute_list")
let publicKey = try XCTUnwrap(PublicKey(hex: "9947f9659dd80c3682402b612f5447e28249997fb3709500c32a585eb0977340"))
let expectedReplaceableEventCoordinates = try XCTUnwrap(EventCoordinates(kind: .muteList, pubkey: publicKey))
XCTAssertEqual(event.replaceableEventCoordinates(relayURL: nil), expectedReplaceableEventCoordinates)
}

func testShareableEventCoordinates() throws {
let event: MuteListEvent = try decodeFixture(filename: "mute_list")
let shareableEventCoordinates = try XCTUnwrap(event.shareableEventCoordinates())
XCTAssertEqual(shareableEventCoordinates, "naddr1qqqqygyeglukt8wcpsmgysptvyh4g3lzsfyejlanwz2spse2tp0tp9mngqpsgqqqyugqp4hw4t")

let metadata = try XCTUnwrap(decodedMetadata(from: shareableEventCoordinates))
XCTAssertNil(metadata.eventId)
XCTAssertEqual(metadata.identifier, "")
XCTAssertEqual(metadata.pubkey, event.pubkey)
XCTAssertEqual(metadata.kind, UInt32(event.kind.rawValue))
XCTAssertEqual(metadata.relays, [])
}

func testShareableEventCoordinatesWithRelays() throws {
let relay1 = "wss://relay1.com"
let relay2 = "wss://relay2.com"

let event: MuteListEvent = try decodeFixture(filename: "mute_list")
let shareableEventCoordinates = try XCTUnwrap(event.shareableEventCoordinates(relayURLStrings: [relay1, relay2]))
XCTAssertEqual(shareableEventCoordinates, "naddr1qqqqzyrhwden5te0wfjkcctexyhxxmmdqyg8wumn8ghj7un9d3shjv3wvdhk6q3qn9rljevamqxrdqjq9dsj74z8u2pynxtlkdcf2qxr9fv9avyhwdqqxpqqqqn3qtqh7yd")

let metadata = try XCTUnwrap(decodedMetadata(from: shareableEventCoordinates))
XCTAssertNil(metadata.eventId)
XCTAssertEqual(metadata.identifier, "")
XCTAssertEqual(metadata.pubkey, event.pubkey)
XCTAssertEqual(metadata.kind, UInt32(event.kind.rawValue))
XCTAssertEqual(metadata.relays?.count, 2)
XCTAssertEqual(metadata.relays?[0], relay1)
XCTAssertEqual(metadata.relays?[1], relay2)
}

func testShareableEventCoordinatesExcludeAuthor() throws {
let event: MuteListEvent = try decodeFixture(filename: "mute_list")
let shareableEventCoordinates = try XCTUnwrap(event.shareableEventCoordinates(includeAuthor: false))
XCTAssertEqual(shareableEventCoordinates, "naddr1qqqqxpqqqqn3qat5qqg")

let metadata = try XCTUnwrap(decodedMetadata(from: shareableEventCoordinates))
XCTAssertNil(metadata.eventId)
XCTAssertEqual(metadata.identifier, "")
XCTAssertNil(metadata.pubkey)
XCTAssertEqual(metadata.kind, UInt32(event.kind.rawValue))
XCTAssertEqual(metadata.relays, [])
}

func testShareableEventCoordinatesExcludeKind() throws {
let event: MuteListEvent = try decodeFixture(filename: "mute_list")
let shareableEventCoordinates = try XCTUnwrap(event.shareableEventCoordinates(includeKind: false))
XCTAssertEqual(shareableEventCoordinates, "naddr1qqqqygyeglukt8wcpsmgysptvyh4g3lzsfyejlanwz2spse2tp0tp9mngq8y2x7g")

let metadata = try XCTUnwrap(decodedMetadata(from: shareableEventCoordinates))
XCTAssertNil(metadata.eventId)
XCTAssertEqual(metadata.identifier, "")
XCTAssertEqual(metadata.pubkey, event.pubkey)
XCTAssertNil(metadata.kind)
XCTAssertEqual(metadata.relays, [])
}

}
26 changes: 25 additions & 1 deletion Tests/NostrSDKTests/Events/NostrEventTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
@testable import NostrSDK
import XCTest

final class NostrEventTests: XCTestCase, FixtureLoading {
final class NostrEventTests: XCTestCase, FixtureLoading, MetadataCoding {

func testEquatable() throws {
let textNoteEvent: TextNoteEvent = try decodeFixture(filename: "text_note")
Expand Down Expand Up @@ -44,4 +44,28 @@ final class NostrEventTests: XCTestCase, FixtureLoading {
XCTAssertNotEqual(nostrEvent.hashValue, differentEvent.hashValue)
}

func testBech32NoteId() throws {
let textNoteEvent: TextNoteEvent = try decodeFixture(filename: "text_note")
let bech32NoteId = textNoteEvent.bech32NoteId
XCTAssertEqual(bech32NoteId, "note1lf0dsn7ga6u4nlfe4k8yswyvlseswkv3a789qpjvlnk0myvthydshz7qeg")
}

func testShareableEventIdentifier() throws {
let relay1 = "wss://relay1.com"
let relay2 = "wss://relay2.com"

let textNoteEvent: TextNoteEvent = try decodeFixture(filename: "text_note")
let shareableEventIdentifier = try XCTUnwrap(textNoteEvent.shareableEventIdentifier(relayURLStrings: [relay1, relay2]))
XCTAssertEqual(shareableEventIdentifier, "nevent1qqs05hkcflywaw2el5u6mrjg8zx0cvc8txg7lrjsqex0em8ajx9mjxcpzpmhxue69uhhyetvv9unztnrdakszyrhwden5te0wfjkcctexghxxmmdqgsgydql3q4ka27d9wnlrmus4tvkrnc8ftc4h8h5fgyln54gl0a7dgsrqsqqqqqplcac7m")

let metadata = try XCTUnwrap(decodedMetadata(from: shareableEventIdentifier))
XCTAssertEqual(metadata.eventId, textNoteEvent.id)
XCTAssertNil(metadata.identifier)
XCTAssertEqual(metadata.pubkey, textNoteEvent.pubkey)
XCTAssertEqual(metadata.kind, UInt32(textNoteEvent.kind.rawValue))
XCTAssertEqual(metadata.relays?.count, 2)
XCTAssertEqual(metadata.relays?[0], relay1)
XCTAssertEqual(metadata.relays?[1], relay2)
}

}
Loading

0 comments on commit 74afc55

Please sign in to comment.