diff --git a/Sources/NostrSDK/EventCreating.swift b/Sources/NostrSDK/EventCreating.swift index 3b7653d..ba304f3 100644 --- a/Sources/NostrSDK/EventCreating.swift +++ b/Sources/NostrSDK/EventCreating.swift @@ -110,6 +110,26 @@ public extension EventCreating { return try DirectMessageEvent(content: encryptedMessage, tags: [recipientTag], signedBy: keypair) } + /// Creates a ``DeletionEvent`` (kind 5) and signs it with the provided ``Keypair``. + /// - Parameters: + /// - events: The events the signer would like to request deletion for. + /// - keypair: The Keypair to sign with. + /// - Returns: The signed ``DeletionEvent``. + /// + /// > Important: Events can only be deleted using the same keypair that was used to create them. + /// See [NIP-09 Specification](https://github.com/nostr-protocol/nips/blob/master/09.md) + func delete(events: [NostrEvent], reason: String? = nil, signedBy keypair: Keypair) throws -> DeletionEvent { + // Verify that the events being deleted were created with the same keypair. + let creatorValidatedEvents = events.filter { $0.pubkey == keypair.publicKey.hex } + + guard !creatorValidatedEvents.isEmpty else { + throw EventCreatingError.invalidInput + } + + let tags = creatorValidatedEvents.map { Tag(name: .event, value: $0.id) } + return try DeletionEvent(content: reason ?? "", tags: tags, signedBy: keypair) + } + /// Creates a ``TextNoteRepostEvent`` (kind 6) or ``GenericRepostEvent`` (kind 16) based on the kind of the event being reposted and signs it with the provided ``Keypair``. /// - Parameters: /// - event: The event to repost. diff --git a/Sources/NostrSDK/EventKind.swift b/Sources/NostrSDK/EventKind.swift index 291f41e..9e68cee 100644 --- a/Sources/NostrSDK/EventKind.swift +++ b/Sources/NostrSDK/EventKind.swift @@ -38,6 +38,14 @@ public enum EventKind: RawRepresentable, CaseIterable, Codable, Equatable { /// See [NIP-04 - Direct Messages](https://github.com/nostr-protocol/nips/blob/master/04.md) case directMessage + /// This kind of event indicates that the author requests that the events in the included + /// tags should be deleted. + /// > Note: This event can only *request* that the listed events be deleted. In reality, they + /// may not be deleted by all clients or relays. + /// + /// See [NIP-09 - Event Deletion](https://github.com/nostr-protocol/nips/blob/master/09.md) + case deletion + /// This kind of note is used to signal to followers that another event is worth reading. /// /// > Note: The reposted event must be a kind 1 text note. @@ -70,6 +78,7 @@ public enum EventKind: RawRepresentable, CaseIterable, Codable, Equatable { .recommendServer, .contactList, .directMessage, + .deletion, .repost, .reaction, .genericRepost, @@ -88,6 +97,7 @@ public enum EventKind: RawRepresentable, CaseIterable, Codable, Equatable { case .recommendServer: return 2 case .contactList: return 3 case .directMessage: return 4 + case .deletion: return 5 case .repost: return 6 case .reaction: return 7 case .genericRepost: return 16 diff --git a/Sources/NostrSDK/Events/DeletionEvent.swift b/Sources/NostrSDK/Events/DeletionEvent.swift new file mode 100644 index 0000000..40cd466 --- /dev/null +++ b/Sources/NostrSDK/Events/DeletionEvent.swift @@ -0,0 +1,38 @@ +// +// DeletionEvent.swift +// +// +// Created by Bryan Montz on 10/29/23. +// + +import Foundation + +/// An event that contains one or more references to other events that the +/// event creator would like to delete. +/// +/// > Note: [NIP-09 Specification](https://github.com/nostr-protocol/nips/blob/master/09.md) +public final class DeletionEvent: NostrEvent { + + 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(content: String, tags: [Tag] = [], createdAt: Int64 = Int64(Date.now.timeIntervalSince1970), signedBy keypair: Keypair) throws { + try super.init(kind: .deletion, content: content, tags: tags, createdAt: createdAt, signedBy: keypair) + } + + /// The reason the creator of the event gave for deleting the included events. + public var reason: String { + content + } + + /// The event ids that the creator requests deletion for. + public var deletedEventIds: [String] { + tags.filter { $0.name == .event }.map { $0.value } + } +} diff --git a/Tests/NostrSDKTests/EventCreatingTests.swift b/Tests/NostrSDKTests/EventCreatingTests.swift index 091bebf..a588268 100644 --- a/Tests/NostrSDKTests/EventCreatingTests.swift +++ b/Tests/NostrSDKTests/EventCreatingTests.swift @@ -121,7 +121,28 @@ final class EventCreatingTests: XCTestCase, EventCreating, EventVerifying, Fixtu try verifyEvent(event) } + + func testDeletionEvent() throws { + let noteToDelete: TextNoteEvent = try decodeFixture(filename: "text_note_deletable") + let reason = "Didn't mean to post" + + let event = try delete(events: [noteToDelete], reason: reason, signedBy: Keypair.test) + + XCTAssertEqual(event.kind, .deletion) + + XCTAssertEqual(event.reason, "Didn't mean to post") + XCTAssertEqual(event.deletedEventIds, ["fa5ed84fc8eeb959fd39ad8e48388cfc33075991ef8e50064cfcecfd918bb91b"]) + + try verifyEvent(event) + } + func testDeletionEventFailsWithMismatchedKey() throws { + let noteToDelete: TextNoteEvent = try decodeFixture(filename: "text_note") + let reason = "Didn't mean to post" + + XCTAssertThrowsError(try delete(events: [noteToDelete], reason: reason, signedBy: Keypair.test)) + } + func testRepostTextNoteEvent() throws { let noteToRepost: TextNoteEvent = try decodeFixture(filename: "text_note") diff --git a/Tests/NostrSDKTests/Fixtures/text_note_deletable.json b/Tests/NostrSDKTests/Fixtures/text_note_deletable.json new file mode 100644 index 0000000..b3f5a72 --- /dev/null +++ b/Tests/NostrSDKTests/Fixtures/text_note_deletable.json @@ -0,0 +1,18 @@ +{ + "id": "fa5ed84fc8eeb959fd39ad8e48388cfc33075991ef8e50064cfcecfd918bb91b", + "pubkey": "9947f9659dd80c3682402b612f5447e28249997fb3709500c32a585eb0977340", + "created_at": 1682080184, + "kind": 1, + "tags": [ + [ + "e", + "93930d65435d49db723499335473920795e7f13c45600dcfad922135cf44bd63" + ], + [ + "p", + "f8e6c64342f1e052480630e27e1016dce35fc3a614e60434fef4aa2503328ca9" + ] + ], + "content": "I think it stays persistent on your profile, but interface setting doesn’t persist. Bug. ", + "sig": "96e6667348b2b1fc5f6e73e68fb1605f571ad044077dda62a35c15eb8290f2c4559935db461f8466df3dcf39bc2e11984c5344f65aabee4520dd6653d74cdc09" +}