Repository: nbd-wtf/nostr-tools
Pull request #479: Generic repost event for addressable events
Author: login: antonioconselheiro
Created: 2025-03-09T13:20:10Z | Merged: 2025-03-10T04:58:00Z

This pull request include backwards compatible implementation of NIP18 update for generic repost support for addressable events.
Includes throwing exceptions for invalid events and validating these exceptions in unit tests.

Please, validate if this implementation satisfies the needs for https://github.com/nbd-wtf/nostr-tools/issues/476.


Diff:
diff --git a/nip18.test.ts b/nip18.test.ts
index 01442890..a2630fdb 100644
--- a/nip18.test.ts
+++ b/nip18.test.ts
@@ -1,7 +1,7 @@
 import { describe, test, expect } from 'bun:test'
 import { hexToBytes } from '@noble/hashes/utils'
-import { finalizeEvent, getPublicKey } from './pure.ts'
-import { Repost, ShortTextNote } from './kinds.ts'
+import { EventTemplate, finalizeEvent, getPublicKey } from './pure.ts'
+import { GenericRepost, Repost, ShortTextNote, BadgeDefinition as BadgeDefinitionKind } from './kinds.ts'
 import { finishRepostEvent, getRepostedEventPointer, getRepostedEvent } from './nip18.ts'
 import { buildEvent } from './test-helpers.ts'
 
@@ -86,6 +86,51 @@ describe('finishRepostEvent + getRepostedEventPointer + getRepostedEvent', () =>
   })
 })
 
+describe('GenericRepost', () => {
+  const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
+  const publicKey = getPublicKey(privateKey)
+
+  const eventTemplate: EventTemplate = {
+    content: '',
+    created_at: 1617932114,
+    kind: BadgeDefinitionKind,
+    tags: [
+      ['d', 'badge-id'],
+      ['name', 'Badge Name'],
+      ['description', 'Badge Description'],
+      ['image', 'https://example.com/badge.png', '1024x1024'],
+      ['thumb', 'https://example.com/thumb.png', '100x100'],
+      ['thumb', 'https://example.com/thumb2.png', '200x200'],
+    ],
+  }
+
+  const repostedEvent = finalizeEvent(eventTemplate, privateKey)
+  test('should create a generic reposted event', () => {
+    const template = { created_at: 1617932115 }
+    const event = finishRepostEvent(template, repostedEvent, relayUrl, privateKey)
+
+    expect(event.kind).toEqual(GenericRepost)
+    expect(event.tags).toEqual([
+      ['e', repostedEvent.id, relayUrl],
+      ['p', repostedEvent.pubkey],
+      ['k', '30009'],
+    ])
+    expect(event.content).toEqual(JSON.stringify(repostedEvent))
+    expect(event.created_at).toEqual(template.created_at)
+    expect(event.pubkey).toEqual(publicKey)
+
+    const repostedEventPointer = getRepostedEventPointer(event)
+
+    expect(repostedEventPointer!.id).toEqual(repostedEvent.id)
+    expect(repostedEventPointer!.author).toEqual(repostedEvent.pubkey)
+    expect(repostedEventPointer!.relays).toEqual([relayUrl])
+
+    const repostedEventFromContent = getRepostedEvent(event)
+
+    expect(repostedEventFromContent).toEqual(repostedEvent)
+  })
+})
+
 describe('getRepostedEventPointer', () => {
   test('should parse an event with only an `e` tag', () => {
     const event = buildEvent({
diff --git a/nip18.ts b/nip18.ts
index 9fd60654..12ffadba 100644
--- a/nip18.ts
+++ b/nip18.ts
@@ -1,6 +1,6 @@
-import { Event, finalizeEvent, verifyEvent } from './pure.ts'
-import { Repost } from './kinds.ts'
+import { GenericRepost, Repost, ShortTextNote } from './kinds.ts'
 import { EventPointer } from './nip19.ts'
+import { Event, finalizeEvent, verifyEvent } from './pure.ts'
 
 export type RepostEventTemplate = {
   /**
@@ -25,10 +25,19 @@ export function finishRepostEvent(
   relayUrl: string,
   privateKey: Uint8Array,
 ): Event {
+  let kind: Repost | GenericRepost
+  const tags = [...(t.tags ?? []), ['e', reposted.id, relayUrl], ['p', reposted.pubkey]]
+  if (reposted.kind === ShortTextNote) {
+    kind = Repost
+  } else {
+    kind = GenericRepost
+    tags.push(['k', String(reposted.kind)])
+  }
+
   return finalizeEvent(
     {
-      kind: Repost,
-      tags: [...(t.tags ?? []), ['e', reposted.id, relayUrl], ['p', reposted.pubkey]],
+      kind,
+      tags,
       content: t.content === '' || reposted.tags?.find(tag => tag[0] === '-') ? '' : JSON.stringify(reposted),
       created_at: t.created_at,
     },
@@ -37,7 +46,7 @@ export function finishRepostEvent(
 }
 
 export function getRepostedEventPointer(event: Event): undefined | EventPointer {
-  if (event.kind !== Repost) {
+  if (![Repost, GenericRepost].includes(event.kind)) {
     return undefined
   }
 


Reviews:
user: login: antonioconselheiro
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-09T13:22:01Z
user: login: AsaiToshiya
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-09T15:11:28Z
user: login: AsaiToshiya
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-09T15:14:06Z
user: login: AsaiToshiya
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-09T15:37:51Z
user: login: antonioconselheiro
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-09T15:47:55Z
user: login: antonioconselheiro
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-09T15:59:28Z

Review comments:
diff_hunk: @@ -25,10 +25,26 @@ export function finishRepostEvent(
   relayUrl: string,
   privateKey: Uint8Array,
 ): Event {
+  let kind: Repost | GenericRepost
+  const tags = [...(t.tags ?? []), ['e', reposted.id, relayUrl], ['p', reposted.pubkey]]
+  if (reposted.kind === ShortTextNote) {
+    kind = Repost
+  } else if (isAddressableKind(reposted.kind)) {
+    let d = reposted.tags.find(([t, v]) => t === 'd' && v)
+    if (!d) throw new Error('d tag not found or is empty')
+    const address = ['a', `${reposted.kind}:${reposted.pubkey}:${d[1]}`]
path: nip18.ts
user: login: antonioconselheiro
body: I just copied this from nip57.ts, perhaps the code should be centralized in a function somewhere, but I wouldn't know which file is most appropriate
created_at: 2025-03-09T13:22:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -25,10 +25,26 @@ export function finishRepostEvent(
   relayUrl: string,
   privateKey: Uint8Array,
 ): Event {
+  let kind: Repost | GenericRepost
+  const tags = [...(t.tags ?? []), ['e', reposted.id, relayUrl], ['p', reposted.pubkey]]
+  if (reposted.kind === ShortTextNote) {
+    kind = Repost
+  } else if (isAddressableKind(reposted.kind)) {
path: nip18.ts
user: login: AsaiToshiya
body: The generic repost should be available for all kinds expect kind 1.
created_at: 2025-03-09T15:11:28Z
author_association: COLLABORATOR
diff_hunk: @@ -25,10 +25,26 @@ export function finishRepostEvent(
   relayUrl: string,
   privateKey: Uint8Array,
 ): Event {
+  let kind: Repost | GenericRepost
+  const tags = [...(t.tags ?? []), ['e', reposted.id, relayUrl], ['p', reposted.pubkey]]
+  if (reposted.kind === ShortTextNote) {
+    kind = Repost
+  } else if (isAddressableKind(reposted.kind)) {
+    let d = reposted.tags.find(([t, v]) => t === 'd' && v)
+    if (!d) throw new Error('d tag not found or is empty')
+    const address = ['a', `${reposted.kind}:${reposted.pubkey}:${d[1]}`]
+
+    kind = GenericRepost
+    tags.push(address)
+    tags.push(['relay-url', relayUrl])
path: nip18.ts
user: login: AsaiToshiya
body: The generic repost should contain a `k` tag.
created_at: 2025-03-09T15:14:06Z
author_association: COLLABORATOR
diff_hunk: @@ -25,10 +25,25 @@ export function finishRepostEvent(
   relayUrl: string,
   privateKey: Uint8Array,
 ): Event {
+  let kind: Repost | GenericRepost
+  const tags = [...(t.tags ?? []), ['e', reposted.id, relayUrl], ['p', reposted.pubkey]]
+  if (reposted.kind === ShortTextNote) {
+    kind = Repost
+  } else {
+    let d = reposted.tags.find(([t, v]) => t === 'd' && v)
+    if (!d) throw new Error('d tag not found or is empty')
+    const address = ['a', `${reposted.kind}:${reposted.pubkey}:${d[1]}`]
path: nip18.ts
user: login: AsaiToshiya
body: There is no `d` tag for regular events etc.
created_at: 2025-03-09T15:37:51Z
author_association: COLLABORATOR
diff_hunk: @@ -25,10 +25,25 @@ export function finishRepostEvent(
   relayUrl: string,
   privateKey: Uint8Array,
 ): Event {
+  let kind: Repost | GenericRepost
+  const tags = [...(t.tags ?? []), ['e', reposted.id, relayUrl], ['p', reposted.pubkey]]
+  if (reposted.kind === ShortTextNote) {
+    kind = Repost
+  } else {
+    let d = reposted.tags.find(([t, v]) => t === 'd' && v)
+    if (!d) throw new Error('d tag not found or is empty')
+    const address = ['a', `${reposted.kind}:${reposted.pubkey}:${d[1]}`]
path: nip18.ts
user: login: antonioconselheiro
body: How should I change this? I must keep including 'd' if have and if don't have I keep just ${reposted.kind}:${reposted.pubkey}? Or should I remove this d identifier?
created_at: 2025-03-09T15:47:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -25,10 +25,25 @@ export function finishRepostEvent(
   relayUrl: string,
   privateKey: Uint8Array,
 ): Event {
+  let kind: Repost | GenericRepost
+  const tags = [...(t.tags ?? []), ['e', reposted.id, relayUrl], ['p', reposted.pubkey]]
+  if (reposted.kind === ShortTextNote) {
+    kind = Repost
+  } else {
+    let d = reposted.tags.find(([t, v]) => t === 'd' && v)
+    if (!d) throw new Error('d tag not found or is empty')
+    const address = ['a', `${reposted.kind}:${reposted.pubkey}:${d[1]}`]
path: nip18.ts
user: login: antonioconselheiro
body: I've remove 'd' as mandatory, see if this matches with 'a' tag implementation
created_at: 2025-03-09T15:59:28Z
author_association: CONTRIBUTOR

PR comments:
user: login: antonioconselheiro
created_at: 2025-03-09T15:25:11Z
body: Fixed.
author_association: CONTRIBUTOR
user: login: antonioconselheiro
created_at: 2025-03-09T16:00:26Z
body: This relay-url tag is right? I see now, the nip18 final docs there are no mention to 'a' tag or 'relay-url', should I remove that?
author_association: CONTRIBUTOR