Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

NDK #1590

Merged
merged 24 commits into from
Dec 13, 2024
Merged

NDK #1590

Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 2 additions & 10 deletions components/use-crossposter.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { useCallback } from 'react'
import { useToast } from './toast'
import { Button } from 'react-bootstrap'
import { DEFAULT_CROSSPOSTING_RELAYS, crosspost } from '@/lib/nostr'
import { callWithTimeout } from '@/lib/time'
import Nostr, { DEFAULT_CROSSPOSTING_RELAYS } from '@/lib/nostr'
import { gql, useMutation, useQuery, useLazyQuery } from '@apollo/client'
import { SETTINGS } from '@/fragments/users'
import { ITEM_FULL_FIELDS, POLL_FIELDS } from '@/fragments/items'
Expand Down Expand Up @@ -204,7 +203,7 @@ export default function useCrossposter () {

do {
try {
const result = await crosspost(event, failedRelays || relays)
const result = await Nostr.crosspost(event, { relays: failedRelays || relays })

if (result.error) {
failedRelays = []
Expand Down Expand Up @@ -239,13 +238,6 @@ export default function useCrossposter () {
}

const handleCrosspost = useCallback(async (itemId) => {
try {
const pubkey = await callWithTimeout(() => window.nostr.getPublicKey(), 10000)
if (!pubkey) throw new Error('failed to get pubkey')
} catch (e) {
throw new Error(`Nostr extension error: ${e.message}`)
}

ekzyis marked this conversation as resolved.
Show resolved Hide resolved
let noteId

try {
Expand Down
294 changes: 119 additions & 175 deletions lib/nostr.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { bech32 } from 'bech32'
import { nip19 } from 'nostr-tools'
import WebSocket from 'isomorphic-ws'
import { callWithTimeout, withTimeout } from '@/lib/time'
import crypto from 'crypto'
import NDK, { NDKEvent, 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]+$/
Expand All @@ -17,155 +15,147 @@ export const DEFAULT_CROSSPOSTING_RELAYS = [
'wss://nostr.mutinywallet.com/',
'wss://relay.mutinywallet.com/'
]

export class Relay {
constructor (relayUrl) {
const ws = new WebSocket(relayUrl)

ws.onmessage = (msg) => {
const [type, notice] = JSON.parse(msg.data)
if (type === 'NOTICE') {
console.log('relay notice:', notice)
}
}

ws.onerror = (err) => {
console.error('websocket error:', err.message)
this.error = err.message
}

this.ws = ws
this.url = relayUrl
this.error = null
}

static async connect (url, { timeout } = {}) {
const relay = new Relay(url)
await relay.waitUntilConnected({ timeout })
return relay
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<NDKNwc>} nwc
* @property {function(Object, {privKey: string, signer: NDKSigner}): Promise<NDKEvent>} sign
* @property {function(Object, {relays: Array<string>, privKey: string, signer: NDKSigner}): Promise<NDKEvent>} publish
*/
export class Nostr {
/**
* @type {NDK}
*/
_ndk = null

constructor ({ privKey, defaultSigner, relays, 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 }),
...ndkOptions
})
}

get connected () {
return this.ws.readyState === WebSocket.OPEN
/**
* @type {NDK}
*/
get ndk () {
return this._ndk
}

get closed () {
return this.ws.readyState === WebSocket.CLOSING || this.ws.readyState === WebSocket.CLOSED
/**
*
* @param {Object} param0
* @param {string} [args.privKey] - private key to use for signing
* @param {boolean} [args.supportNip07] - whether to use NIP-07 signer if available
* @returns {NDKPrivateKeySigner | NDKNip07Signer | null} - a signer instance
*/
getSigner ({ privKey, supportNip07 = true } = {}) {
if (privKey) return new NDKPrivateKeySigner(privKey)
if (supportNip07 && typeof window !== 'undefined' && window?.nostr) return new NDKNip07Signer()
return null
}

async waitUntilConnected ({ timeout } = {}) {
let interval

const checkPromise = new Promise((resolve, reject) => {
interval = setInterval(() => {
if (this.connected) {
resolve()
}
if (this.closed) {
reject(new Error(`failed to connect to ${this.url}: ` + this.error))
}
}, 100)
/**
* @param {Object} rawEvent
* @param {number} rawEvent.kind
* @param {number} rawEvent.created_at
* @param {string} rawEvent.content
* @param {Array<Array<string>>} rawEvent.tags
* @param {Object} context
* @param {string} context.privKey
* @param {NDKSigner} context.signer
* @returns {Promise<NDKEvent>}
*/
async sign ({ kind, created_at, content, tags }, { signer } = {}) {
const event = new NDKEvent(this.ndk, {
kind,
created_at,
content,
tags
})

try {
return await withTimeout(checkPromise, timeout)
} catch (err) {
this.close()
throw err
} finally {
clearInterval(interval)
}
}

close () {
const state = this.ws.readyState
if (state !== WebSocket.CLOSING && state !== WebSocket.CLOSED) {
this.ws.close()
}
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
}

async publish (event, { timeout } = {}) {
const ws = this.ws

let listener
const ackPromise = new Promise((resolve, reject) => {
listener = function onmessage (msg) {
const [type, eventId, accepted, reason] = JSON.parse(msg.data)

if (type !== 'OK' || eventId !== event.id) return
/**
* @param {Object} rawEvent
* @param {number} rawEvent.kind
* @param {number} rawEvent.created_at
* @param {string} rawEvent.content
* @param {Array<Array<string>>} rawEvent.tags
* @param {Object} context
* @param {Array<string>} context.relays
* @param {string} context.privKey
* @param {NDKSigner} context.signer
* @param {number} context.timeout
* @returns {Promise<NDKEvent>}
*/
async publish ({ created_at, content, tags = [], kind }, { relays, signer, timeout } = {}) {
const event = await this.sign({ kind, created_at, content, tags }, { signer })

if (accepted) {
resolve(eventId)
} else {
reject(new Error(reason || `event rejected: ${eventId}`))
}
}
const successfulRelays = []
const failedRelays = []

ws.addEventListener('message', listener)
const relaySet = NDKRelaySet.fromRelayUrls(relays, this.ndk, true)

ws.send(JSON.stringify(['EVENT', event]))
event.on('relay:publish:failed', (relay, error) => {
failedRelays.push({ relay: relay.url, error })
})

try {
return await withTimeout(ackPromise, timeout)
} finally {
ws.removeEventListener('message', listener)
for (const relay of (await relaySet.publish(event, timeout))) {
successfulRelays.push(relay.url)
}

return {
event,
successfulRelays,
failedRelays
}
}

async fetch (filter, { timeout } = {}) {
const ws = this.ws

let listener
const ackPromise = new Promise((resolve, reject) => {
const id = crypto.randomBytes(16).toString('hex')

const events = []
let eose = false

listener = function onmessage (msg) {
const [type, subId, event] = JSON.parse(msg.data)

if (subId !== id) return

if (type === 'EVENT') {
events.push(event)
if (eose) {
// EOSE was already received:
// return first event after EOSE
resolve(events)
}
return
}

if (type === 'CLOSED') {
return resolve(events)
}

if (type === 'EOSE') {
eose = true
if (events.length > 0) {
// we already received events before EOSE:
// return all events before EOSE
ws.send(JSON.stringify(['CLOSE', id]))
return resolve(events)
}
}
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')
}

ws.addEventListener('message', listener)

ws.send(JSON.stringify(['REQ', id, ...filter]))
})

try {
return await withTimeout(ackPromise, timeout)
} finally {
ws.removeEventListener('message', listener)
return { successfulRelays, failedRelays, noteId }
} catch (error) {
console.error('Crosspost error:', error)
return { error }
}
}
}

/**
* @type {Nostr}
*/
export default new Nostr()
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is the global singleton, connections to specific relays are handled with RelaySets and their life cycle is left to ndk to handle


export function hexToBech32 (hex, prefix = 'npub') {
return bech32.encode(prefix, bech32.toWords(Buffer.from(hex, 'hex')))
}
Expand All @@ -186,49 +176,3 @@ export function nostrZapDetails (zap) {

return { npub, content, note }
}

async function publishNostrEvent (signedEvent, relayUrl) {
const timeout = 3000
const relay = await Relay.connect(relayUrl, { timeout })
try {
await relay.publish(signedEvent, { timeout })
} finally {
relay.close()
}
}

export async function crosspost (event, relays = DEFAULT_CROSSPOSTING_RELAYS) {
try {
const signedEvent = await callWithTimeout(() => window.nostr.signEvent(event), 10000)
if (!signedEvent) throw new Error('failed to sign event')

const promises = relays.map(r => publishNostrEvent(signedEvent, r))
const results = await Promise.allSettled(promises)
const successfulRelays = []
const failedRelays = []

results.forEach((result, index) => {
if (result.status === 'fulfilled') {
successfulRelays.push(relays[index])
} else {
failedRelays.push({ relay: relays[index], error: result.reason })
}
})

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 }
}
}
6 changes: 3 additions & 3 deletions lib/url.js
Original file line number Diff line number Diff line change
Expand Up @@ -203,12 +203,12 @@ export function parseNwcUrl (walletConnectUrl) {
const params = {}
params.walletPubkey = url.host
const secret = url.searchParams.get('secret')
const relayUrl = url.searchParams.get('relay')
const relayUrls = url.searchParams.getAll('relay')
if (secret) {
params.secret = secret
}
if (relayUrl) {
params.relayUrl = relayUrl
if (relayUrls) {
params.relayUrls = relayUrls
}
return params
}
Expand Down
Loading
Loading