import { bech32 } from 'bech32' import { nip19 } from 'nostr-tools' import NDK, { NDKEvent, NDKNip46Signer, NDKRelaySet, NDKPrivateKeySigner, NDKNip07Signer } from '@nostr-dev-kit/ndk' export const NOSTR_PUBKEY_HEX = /^[0-9a-fA-F]{64}$/ export const NOSTR_PUBKEY_BECH32 = /^npub1[02-9ac-hj-np-z]+$/ export const NOSTR_MAX_RELAY_NUM = 20 export const NOSTR_ZAPPLE_PAY_NPUB = 'npub1wxl6njlcgygduct7jkgzrvyvd9fylj4pqvll6p32h59wyetm5fxqjchcan' export const DEFAULT_CROSSPOSTING_RELAYS = [ 'wss://nostrue.com/', 'wss://relay.damus.io/', 'wss://relay.nostr.band/', 'wss://relay.snort.social/', 'wss://nostr21.com/', 'wss://nostr.mutinywallet.com/', 'wss://relay.mutinywallet.com/' ] export const RELAYS_BLACKLIST = [] /* eslint-disable camelcase */ /** * @import {NDKSigner} from '@nostr-dev-kit/ndk' * @import { NDK } from '@nostr-dev-kit/ndk' * @import {NDKNwc} from '@nostr-dev-kit/ndk' * @typedef {Object} Nostr * @property {NDK} ndk * @property {function(string, {logger: Object}): Promise} nwc * @property {function(Object, {privKey: string, signer: NDKSigner}): Promise} sign * @property {function(Object, {relays: Array, privKey: string, signer: NDKSigner}): Promise} publish */ export default class Nostr { /** * @type {NDK} */ _ndk = null static globalInstance = null constructor ({ privKey, defaultSigner, relays, nip46token, supportNip07 = false, ...ndkOptions } = {}) { this._ndk = new NDK({ explicitRelayUrls: relays, blacklistRelayUrls: RELAYS_BLACKLIST, autoConnectUserRelays: false, autoFetchUserMutelist: false, clientName: 'stacker.news', signer: defaultSigner ?? this.getSigner({ privKey, supportNip07, nip46token }), ...ndkOptions }) } /** * @type {NDK} */ static get () { if (!Nostr.globalInstance) { Nostr.globalInstance = new Nostr() } return Nostr.globalInstance } /** * @type {NDK} */ get ndk () { return this._ndk } /** * * @param {Object} param0 * @param {string} [args.privKey] - private key to use for signing * @param {string} [args.nip46token] - NIP-46 token to use for signing * @param {boolean} [args.supportNip07] - whether to use NIP-07 signer if available * @returns {NDKPrivateKeySigner | NDKNip46Signer | NDKNip07Signer | null} - a signer instance */ getSigner ({ privKey, nip46token, supportNip07 = true } = {}) { if (privKey) return new NDKPrivateKeySigner(privKey) if (nip46token) return new NDKNip46SignerURLPatch(this.ndk, nip46token) if (supportNip07 && typeof window !== 'undefined' && window?.nostr) return new NDKNip07Signer() return null } /** * @param {Object} rawEvent * @param {number} rawEvent.kind * @param {number} rawEvent.created_at * @param {string} rawEvent.content * @param {Array>} rawEvent.tags * @param {Object} context * @param {string} context.privKey * @param {NDKSigner} context.signer * @returns {Promise} */ async sign ({ kind, created_at, content, tags }, { signer } = {}) { const event = new NDKEvent(this.ndk, { kind, created_at, content, tags }) signer ??= this.ndk.signer if (!signer) throw new Error('no way to sign this event, please provide a signer or private key') await event.sign(signer) return event } /** * @param {Object} rawEvent * @param {number} rawEvent.kind * @param {number} rawEvent.created_at * @param {string} rawEvent.content * @param {Array>} rawEvent.tags * @param {Object} context * @param {Array} context.relays * @param {string} context.privKey * @param {NDKSigner} context.signer * @param {number} context.timeout * @returns {Promise} */ async publish ({ created_at, content, tags = [], kind }, { relays, signer, timeout } = {}) { const event = await this.sign({ kind, created_at, content, tags }, { signer }) const successfulRelays = [] const failedRelays = [] const relaySet = NDKRelaySet.fromRelayUrls(relays, this.ndk, true) event.on('relay:publish:failed', (relay, error) => { failedRelays.push({ relay: relay.url, error }) }) for (const relay of (await relaySet.publish(event, timeout))) { successfulRelays.push(relay.url) } return { event, successfulRelays, failedRelays } } async crosspost ({ created_at, content, tags = [], kind }, { relays = DEFAULT_CROSSPOSTING_RELAYS, signer, timeout } = {}) { try { signer ??= this.getSigner({ supportNip07: true }) const { event: signedEvent, successfulRelays, failedRelays } = await this.publish({ created_at, content, tags, kind }, { relays, signer, timeout }) let noteId = null if (signedEvent.kind !== 1) { noteId = await nip19.naddrEncode({ kind: signedEvent.kind, pubkey: signedEvent.pubkey, identifier: signedEvent.tags[0][1] }) } else { noteId = hexToBech32(signedEvent.id, 'note') } return { successfulRelays, failedRelays, noteId } } catch (error) { console.error('Crosspost error:', error) return { error } } } /** * Close all relay connections */ close () { const pool = this.ndk.pool for (const relay of pool.urls()) { pool.removeRelay(relay) } } } export function hexToBech32 (hex, prefix = 'npub') { return bech32.encode(prefix, bech32.toWords(Buffer.from(hex, 'hex'))) } export function nostrZapDetails (zap) { let { pubkey, content, tags } = zap let npub = hexToBech32(pubkey) if (npub === NOSTR_ZAPPLE_PAY_NPUB) { const znpub = content.match(/^From: nostr:(npub1[02-9ac-hj-np-z]+)$/)?.[1] if (znpub) { npub = znpub // zapple pay does not support user content content = null } } const event = tags.filter(t => t?.length >= 2 && t[0] === 'e')?.[0]?.[1] const note = event ? hexToBech32(event, 'note') : null return { npub, content, note } } // workaround NDK url parsing issue (see https://github.com/stackernews/stacker.news/pull/1636) class NDKNip46SignerURLPatch extends NDKNip46Signer { connectionTokenInit (connectionToken) { connectionToken = connectionToken.replace('bunker://', 'http://') return super.connectionTokenInit(connectionToken) } }