From 424449c773202e99eb652543b0baded2fb2ba8b9 Mon Sep 17 00:00:00 2001 From: futpib Date: Sun, 7 May 2023 14:12:09 +0400 Subject: [PATCH] Add NIP-18 utils --- event.ts | 1 + index.ts | 1 + nip18.test.js | 101 ++++++++++++++++++++++++++++++++++++++++++++++++++ nip18.ts | 97 ++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 200 insertions(+) create mode 100644 nip18.test.js create mode 100644 nip18.ts diff --git a/event.ts b/event.ts index 1402459..1092f12 100644 --- a/event.ts +++ b/event.ts @@ -12,6 +12,7 @@ export enum Kind { Contacts = 3, EncryptedDirectMessage = 4, EventDeletion = 5, + Repost = 6, Reaction = 7, BadgeAward = 8, ChannelCreation = 40, diff --git a/index.ts b/index.ts index 2ca54ae..d2ad5a9 100644 --- a/index.ts +++ b/index.ts @@ -10,6 +10,7 @@ export * as nip05 from './nip05' export * as nip06 from './nip06' export * as nip10 from './nip10' export * as nip13 from './nip13' +export * as nip18 from './nip18' export * as nip19 from './nip19' export * as nip21 from './nip21' export * as nip25 from './nip25' diff --git a/nip18.test.js b/nip18.test.js new file mode 100644 index 0000000..c8b938f --- /dev/null +++ b/nip18.test.js @@ -0,0 +1,101 @@ +/* eslint-env jest */ + +const {nip18, finishEvent, getPublicKey, Kind} = require('./lib/nostr.cjs') + +describe('finishRepostEvent + getRepostedEventPointer + getRepostedEvent', () => { + const privateKey = + 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf' + const relayUrl = 'https://relay.example.com' + + const publicKey = getPublicKey(privateKey) + + const repostedEvent = finishEvent({ + kind: Kind.Text, + tags: [ + ['e', 'replied event id'], + ['p', 'replied event pubkey'], + ], + content: 'Replied to a post', + created_at: 1617932115 + }, privateKey) + + it('should create a signed event from a minimal template', () => { + const template = { + created_at: 1617932115 + } + + const event = nip18.finishRepostEvent(template, repostedEvent, relayUrl, privateKey) + + expect(event.kind).toEqual(Kind.Repost) + expect(event.tags).toEqual([ + [ + 'e', + repostedEvent.id, + relayUrl, + ], + [ + 'p', + repostedEvent.pubkey, + ], + ]) + expect(event.content).toEqual(JSON.stringify(repostedEvent)) + expect(event.created_at).toEqual(template.created_at) + expect(event.pubkey).toEqual(publicKey) + expect(typeof event.id).toEqual('string') + expect(typeof event.sig).toEqual('string') + + const repostedEventPointer = nip18.getRepostedEventPointer(event) + + expect(repostedEventPointer.id).toEqual(repostedEvent.id) + expect(repostedEventPointer.author).toEqual(repostedEvent.pubkey) + expect(repostedEventPointer.relays).toEqual([ relayUrl ]) + + const repostedEventFromContent = nip18.getRepostedEvent(event) + + expect(repostedEventFromContent).toEqual(repostedEvent) + }) + + it('should create a signed event from a filled template', () => { + const template = { + tags: [ + ['nonstandard', 'tag'], + ], + content: '', + created_at: 1617932115 + } + + const event = nip18.finishRepostEvent(template, repostedEvent, relayUrl, privateKey) + + expect(event.kind).toEqual(Kind.Repost) + expect(event.tags).toEqual([ + [ + 'nonstandard', + 'tag', + ], + [ + 'e', + repostedEvent.id, + relayUrl, + ], + [ + 'p', + repostedEvent.pubkey, + ], + ]) + expect(event.content).toEqual('') + expect(event.created_at).toEqual(template.created_at) + expect(event.pubkey).toEqual(publicKey) + expect(typeof event.id).toEqual('string') + expect(typeof event.sig).toEqual('string') + + const repostedEventPointer = nip18.getRepostedEventPointer(event) + + expect(repostedEventPointer.id).toEqual(repostedEvent.id) + expect(repostedEventPointer.author).toEqual(repostedEvent.pubkey) + expect(repostedEventPointer.relays).toEqual([ relayUrl ]) + + const repostedEventFromContent = nip18.getRepostedEvent(event) + + expect(repostedEventFromContent).toEqual(undefined) + }) +}) diff --git a/nip18.ts b/nip18.ts new file mode 100644 index 0000000..77fce26 --- /dev/null +++ b/nip18.ts @@ -0,0 +1,97 @@ +import { Event, finishEvent, Kind, verifySignature } from './event' +import { EventPointer } from './nip19' + +export type RepostEventTemplate = { + /** + * Pass only non-nip18 tags if you have to. + * Nip18 tags ('e' and 'p' tags pointing to the reposted event) will be added automatically. + */ + tags?: string[][] + + /** + * Pass an empty string to NOT include the stringified JSON of the reposted event. + * Any other content will be ignored and replaced with the stringified JSON of the reposted event. + * @default Stringified JSON of the reposted event + */ + content?: ''; + + created_at: number +} + +export function finishRepostEvent( + t: RepostEventTemplate, + reposted: Event, + relayUrl: string, + privateKey: string, +): Event { + return finishEvent({ + kind: Kind.Repost, + tags: [ + ...(t.tags ?? []), + [ 'e', reposted.id, relayUrl ], + [ 'p', reposted.pubkey ], + ], + content: t.content === '' ? '' : JSON.stringify(reposted), + created_at: t.created_at, + }, privateKey) +} + +export function getRepostedEventPointer(event: Event): undefined | EventPointer { + if (event.kind !== Kind.Repost) { + return undefined + } + + let lastETag: undefined | string[] + let lastPTag: undefined | string[] + + for (let i = event.tags.length - 1; i >= 0 && (lastETag === undefined || lastPTag === undefined); i--) { + const tag = event.tags[i] + if (tag.length >= 2) { + if (tag[0] === 'e' && lastETag === undefined) { + lastETag = tag + } else if (tag[0] === 'p' && lastPTag === undefined) { + lastPTag = tag + } + } + } + + if (lastETag === undefined || lastPTag === undefined) { + return undefined + } + + return { + id: lastETag[1], + relays: [ lastETag[2], lastPTag[2] ].filter((x) => x !== undefined), + author: lastPTag[1], + } +} + +export type GetRepostedEventOptions = { + skipVerification?: boolean, +}; + +export function getRepostedEvent(event: Event, { skipVerification }: GetRepostedEventOptions = {}): undefined | Event { + const pointer = getRepostedEventPointer(event) + + if (pointer === undefined || event.content === '') { + return undefined + } + + let repostedEvent: undefined | Event + + try { + repostedEvent = JSON.parse(event.content) as Event + } catch (error) { + return undefined + } + + if (repostedEvent.id !== pointer.id) { + return undefined + } + + if (!skipVerification && !verifySignature(repostedEvent)) { + return undefined + } + + return repostedEvent +}