diff --git a/components/use-crossposter.js b/components/use-crossposter.js index 8798009b..e4ee529d 100644 --- a/components/use-crossposter.js +++ b/components/use-crossposter.js @@ -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' @@ -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 = [] @@ -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}`) - } - let noteId try { diff --git a/lib/nostr.js b/lib/nostr.js index 7a5e497a..f0c3e87a 100644 --- a/lib/nostr.js +++ b/lib/nostr.js @@ -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]+$/ @@ -17,155 +15,147 @@ export const DEFAULT_CROSSPOSTING_RELAYS = [ 'wss://nostr.mutinywallet.com/', 'wss://relay.mutinywallet.com/' ] +export const RELAYS_BLACKLIST = [] -export class Relay { - constructor (relayUrl) { - const ws = new WebSocket(relayUrl) +/* eslint-disable camelcase */ - ws.onmessage = (msg) => { - const [type, notice] = JSON.parse(msg.data) - if (type === 'NOTICE') { - console.log('relay notice:', notice) - } - } +/** + * @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 class Nostr { + /** + * @type {NDK} + */ + _ndk = null - ws.onerror = (err) => { - console.error('websocket error:', err.message) - this.error = err.message - } - - this.ws = ws - this.url = relayUrl - this.error = 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 + }) } - static async connect (url, { timeout } = {}) { - const relay = new Relay(url) - await relay.waitUntilConnected({ timeout }) - return relay + /** + * @type {NDK} + */ + get ndk () { + return this._ndk } - get connected () { - return this.ws.readyState === WebSocket.OPEN + /** + * + * @param {Object} args + * @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 } - get closed () { - return this.ws.readyState === WebSocket.CLOSING || this.ws.readyState === WebSocket.CLOSED + /** + * @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 } - async waitUntilConnected ({ timeout } = {}) { - let interval + /** + * @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 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) + 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 { - return await withTimeout(checkPromise, timeout) - } catch (err) { - this.close() - throw err - } finally { - clearInterval(interval) - } - } + signer ??= this.getSigner({ supportNip07: true }) + const { event: signedEvent, successfulRelays, failedRelays } = await this.publish({ created_at, content, tags, kind }, { relays, signer, timeout }) - close () { - const state = this.ws.readyState - if (state !== WebSocket.CLOSING && state !== WebSocket.CLOSED) { - this.ws.close() - } - } - - 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 - - if (accepted) { - resolve(eventId) - } else { - reject(new Error(reason || `event rejected: ${eventId}`)) - } + 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(['EVENT', event])) - }) - - try { - return await withTimeout(ackPromise, timeout) - } finally { - ws.removeEventListener('message', listener) - } - } - - 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) - } - } - } - - 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() + export function hexToBech32 (hex, prefix = 'npub') { return bech32.encode(prefix, bech32.toWords(Buffer.from(hex, 'hex'))) } @@ -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 } - } -} diff --git a/lib/url.js b/lib/url.js index d6eca903..3bb51e3f 100644 --- a/lib/url.js +++ b/lib/url.js @@ -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 } diff --git a/lib/yup.js b/lib/yup.js index fbbf0b12..745cb5df 100644 --- a/lib/yup.js +++ b/lib/yup.js @@ -147,15 +147,15 @@ addMethod(string, 'nwcUrl', function () { // inspired by https://github.com/jquense/yup/issues/851#issuecomment-1049705180 try { string().matches(/^nostr\+?walletconnect:\/\//, { message: 'must start with nostr+walletconnect://' }).validateSync(nwcUrl) - let relayUrl, walletPubkey, secret + let relayUrls, walletPubkey, secret try { - ({ relayUrl, walletPubkey, secret } = parseNwcUrl(nwcUrl)) + ({ relayUrls, walletPubkey, secret } = parseNwcUrl(nwcUrl)) } catch { // invalid URL error. handle as if pubkey validation failed to not confuse user. throw new Error('pubkey must be 64 hex chars') } string().required('pubkey required').trim().matches(NOSTR_PUBKEY_HEX, 'pubkey must be 64 hex chars').validateSync(walletPubkey) - string().required('relay url required').trim().wss('relay must use wss://').validateSync(relayUrl) + array().of(string().required('relay url required').trim().wss('relay must use wss://')).min(1, 'at least one relay required').validateSync(relayUrls) string().required('secret required').trim().matches(/^[0-9a-fA-F]{64}$/, 'secret must be 64 hex chars').validateSync(secret) } catch (err) { return context.createError({ message: err.message }) diff --git a/package-lock.json b/package-lock.json index 3326e9a1..d373b8eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@graphql-tools/schema": "^10.0.6", "@lightninglabs/lnc-web": "^0.3.2-alpha", "@noble/curves": "^1.6.0", + "@nostr-dev-kit/ndk": "^2.10.5", "@opensearch-project/opensearch": "^2.12.0", "@prisma/client": "^5.20.0", "@slack/web-api": "^7.6.0", @@ -4371,6 +4372,15 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@noble/secp256k1": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-2.1.0.tgz", + "integrity": "sha512-XLEQQNdablO0XZOIniFQimiXsZDNwaYgL96dZwC54Q30imSbAOFf3NKtepc+cXyuZf5Q1HCgbqgZ2UFFuHVcEw==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -4406,6 +4416,49 @@ "node": ">= 8" } }, + "node_modules/@nostr-dev-kit/ndk": { + "version": "2.10.5", + "resolved": "https://registry.npmjs.org/@nostr-dev-kit/ndk/-/ndk-2.10.5.tgz", + "integrity": "sha512-QEnarJL9BGCxeenSIE9jxNSDyYQYjzD30YL3sVJ9cNybNZX8tl/I1/vhEUeRRMBz/qjROLtt0M2RV68rZ205tg==", + "license": "MIT", + "dependencies": { + "@noble/curves": "^1.6.0", + "@noble/hashes": "^1.5.0", + "@noble/secp256k1": "^2.1.0", + "@scure/base": "^1.1.9", + "debug": "^4.3.6", + "light-bolt11-decoder": "^3.2.0", + "nostr-tools": "^2.7.1", + "tseep": "^1.2.2", + "typescript-lru-cache": "^2.0.0", + "utf8-buffer": "^1.0.0", + "websocket-polyfill": "^0.0.3" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@nostr-dev-kit/ndk/node_modules/@noble/hashes": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.5.0.tgz", + "integrity": "sha512-1j6kQFb7QRru7eKN3ZDvRcP13rugwdxZqCjbiAVZfIJwgj2A65UmT4TgARXGlXgnRkORLTDTrO19ZErt7+QXgA==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@nostr-dev-kit/ndk/node_modules/@scure/base": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.9.tgz", + "integrity": "sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@opensearch-project/opensearch": { "version": "2.12.0", "resolved": "https://registry.npmjs.org/@opensearch-project/opensearch/-/opensearch-2.12.0.tgz", @@ -7310,6 +7363,19 @@ "node": ">=4" } }, + "node_modules/bufferutil": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.8.tgz", + "integrity": "sha512-4T53u4PdgsXqKaIctwF8ifXlRTTmEPJ8iEPWFdGZvcf7sbwYo6FKFEX9eNNAnzFZ7EzJAQ3CJeOtCRA4rDp7Pw==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, "node_modules/builtins": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/builtins/-/builtins-5.0.1.tgz", @@ -8089,6 +8155,19 @@ "node": ">= 10" } }, + "node_modules/d": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz", + "integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==", + "license": "ISC", + "dependencies": { + "es5-ext": "^0.10.64", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.12" + } + }, "node_modules/d3-array": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", @@ -8968,6 +9047,46 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es5-ext": { + "version": "0.10.64", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", + "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==", + "hasInstallScript": true, + "license": "ISC", + "dependencies": { + "es6-iterator": "^2.0.3", + "es6-symbol": "^3.1.3", + "esniff": "^2.0.1", + "next-tick": "^1.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/es6-iterator": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", + "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", + "license": "MIT", + "dependencies": { + "d": "1", + "es5-ext": "^0.10.35", + "es6-symbol": "^3.1.1" + } + }, + "node_modules/es6-symbol": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz", + "integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==", + "license": "ISC", + "dependencies": { + "d": "^1.0.2", + "ext": "^1.7.0" + }, + "engines": { + "node": ">=0.12" + } + }, "node_modules/esbuild": { "version": "0.23.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.23.1.tgz", @@ -9581,6 +9700,21 @@ "node": ">=6" } }, + "node_modules/esniff": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", + "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==", + "license": "ISC", + "dependencies": { + "d": "^1.0.1", + "es5-ext": "^0.10.62", + "event-emitter": "^0.3.5", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/espree": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.2.0.tgz", @@ -9675,6 +9809,16 @@ "node": ">= 0.6" } }, + "node_modules/event-emitter": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", + "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", + "license": "MIT", + "dependencies": { + "d": "1", + "es5-ext": "~0.10.14" + } + }, "node_modules/eventemitter3": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", @@ -9829,6 +9973,15 @@ } ] }, + "node_modules/ext": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", + "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==", + "license": "ISC", + "dependencies": { + "type": "^2.7.2" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -14154,6 +14307,15 @@ "node": ">= 0.8.0" } }, + "node_modules/light-bolt11-decoder": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/light-bolt11-decoder/-/light-bolt11-decoder-3.2.0.tgz", + "integrity": "sha512-3QEofgiBOP4Ehs9BI+RkZdXZNtSys0nsJ6fyGeSiAGCBsMwHGUDS/JQlY/sTnWs91A2Nh0S9XXfA8Sy9g6QpuQ==", + "license": "MIT", + "dependencies": { + "@scure/base": "1.1.1" + } + }, "node_modules/lightning": { "version": "10.22.0", "resolved": "https://registry.npmjs.org/lightning/-/lightning-10.22.0.tgz", @@ -15606,6 +15768,12 @@ "react-dom": ">=16.0.0" } }, + "node_modules/next-tick": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", + "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==", + "license": "ISC" + }, "node_modules/no-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", @@ -19408,11 +19576,23 @@ "resolved": "https://registry.npmjs.org/tsdef/-/tsdef-0.0.14.tgz", "integrity": "sha512-UjMD4XKRWWFlFBfwKVQmGFT5YzW/ZaF8x6KpCDf92u9wgKeha/go3FU0e5WqDjXsCOdfiavCkfwfVHNDxRDGMA==" }, + "node_modules/tseep": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tseep/-/tseep-1.3.1.tgz", + "integrity": "sha512-ZPtfk1tQnZVyr7BPtbJ93qaAh2lZuIOpTMjhrYa4XctT8xe7t4SAW9LIxrySDuYMsfNNayE51E/WNGrNVgVicQ==", + "license": "MIT" + }, "node_modules/tslib": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.0.tgz", "integrity": "sha512-7At1WUettjcSRHXCyYtTselblcHl9PJFFVKiCAy/bY97+BPZXSQ2wbq0P9s8tK2G7dFQfNnlJnPAiArVBVBsfA==" }, + "node_modules/tstl": { + "version": "2.5.16", + "resolved": "https://registry.npmjs.org/tstl/-/tstl-2.5.16.tgz", + "integrity": "sha512-+O2ybLVLKcBwKm4HymCEwZIT0PpwS3gCYnxfSDEjJEKADvIFruaQjd3m7CAKNU1c7N3X3WjVz87re7TA2A5FUw==", + "license": "MIT" + }, "node_modules/tsx": { "version": "4.19.1", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.1.tgz", @@ -19452,6 +19632,12 @@ "resolved": "https://registry.npmjs.org/tweetnacl-util/-/tweetnacl-util-0.15.1.tgz", "integrity": "sha512-RKJBIj8lySrShN4w6i/BonWp2Z/uxwC3h4y7xsRrpP59ZboCd0GpEVsOnMDYLMmKBpYhb5TgHzZXy7wTfYFBRw==" }, + "node_modules/type": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz", + "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==", + "license": "ISC" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -19574,11 +19760,26 @@ "node": ">= 18" } }, + "node_modules/typedarray-to-buffer": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", + "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", + "license": "MIT", + "dependencies": { + "is-typedarray": "^1.0.0" + } + }, "node_modules/typeforce": { "version": "1.18.0", "resolved": "https://registry.npmjs.org/typeforce/-/typeforce-1.18.0.tgz", "integrity": "sha512-7uc1O8h1M1g0rArakJdf0uLRSSgFcYexrVoKo+bzJd32gd4gDy2L/Z+8/FjPnU9ydY3pEnVPtr9FyscYY60K1g==" }, + "node_modules/typescript-lru-cache": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/typescript-lru-cache/-/typescript-lru-cache-2.0.0.tgz", + "integrity": "sha512-Jp57Qyy8wXeMkdNuZiglE6v2Cypg13eDA1chHwDG6kq51X7gk4K7P7HaDdzZKCxkegXkVHNcPD0n5aW6OZH3aA==", + "license": "MIT" + }, "node_modules/uint8array-tools": { "version": "0.0.7", "resolved": "https://registry.npmjs.org/uint8array-tools/-/uint8array-tools-0.0.7.tgz", @@ -19997,6 +20198,28 @@ } } }, + "node_modules/utf-8-validate": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", + "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, + "node_modules/utf8-buffer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/utf8-buffer/-/utf8-buffer-1.0.0.tgz", + "integrity": "sha512-ueuhzvWnp5JU5CiGSY4WdKbiN/PO2AZ/lpeLiz2l38qwdLy/cW40XobgyuIWucNyum0B33bVB0owjFCeGBSLqg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/util": { "version": "0.12.4", "resolved": "https://registry.npmjs.org/util/-/util-0.12.4.tgz", @@ -20320,6 +20543,47 @@ "npm": ">=3.10.0" } }, + "node_modules/websocket": { + "version": "1.0.35", + "resolved": "https://registry.npmjs.org/websocket/-/websocket-1.0.35.tgz", + "integrity": "sha512-/REy6amwPZl44DDzvRCkaI1q1bIiQB0mEFQLUrhz3z2EK91cp3n72rAjUlrTP0zV22HJIUOVHQGPxhFRjxjt+Q==", + "license": "Apache-2.0", + "dependencies": { + "bufferutil": "^4.0.1", + "debug": "^2.2.0", + "es5-ext": "^0.10.63", + "typedarray-to-buffer": "^3.1.5", + "utf-8-validate": "^5.0.2", + "yaeti": "^0.0.6" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/websocket-polyfill": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/websocket-polyfill/-/websocket-polyfill-0.0.3.tgz", + "integrity": "sha512-pF3kR8Uaoau78MpUmFfzbIRxXj9PeQrCuPepGE6JIsfsJ/o/iXr07Q2iQNzKSSblQJ0FiGWlS64N4pVSm+O3Dg==", + "dependencies": { + "tstl": "^2.0.7", + "websocket": "^1.0.28" + } + }, + "node_modules/websocket/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/websocket/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, "node_modules/whatwg-encoding": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", @@ -20896,6 +21160,15 @@ "node": ">=10" } }, + "node_modules/yaeti": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/yaeti/-/yaeti-0.0.6.tgz", + "integrity": "sha512-MvQa//+KcZCUkBTIC9blM+CU9J2GzuTytsOUwf2lidtvkx/6gnEp1QvJv34t9vdjhFmha/mUiNDbN0D0mJWdug==", + "license": "MIT", + "engines": { + "node": ">=0.10.32" + } + }, "node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", diff --git a/package.json b/package.json index 4be50cc3..f4e7f2c5 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@graphql-tools/schema": "^10.0.6", "@lightninglabs/lnc-web": "^0.3.2-alpha", "@noble/curves": "^1.6.0", + "@nostr-dev-kit/ndk": "^2.10.5", "@opensearch-project/opensearch": "^2.12.0", "@prisma/client": "^5.20.0", "@slack/web-api": "^7.6.0", diff --git a/wallets/nwc/client.js b/wallets/nwc/client.js index b7dc13fa..0d3a9223 100644 --- a/wallets/nwc/client.js +++ b/wallets/nwc/client.js @@ -1,4 +1,4 @@ -import { nwcCall, supportedMethods } from '@/wallets/nwc' +import { getNwc, supportedMethods, nwcTryRun } from '@/wallets/nwc' export * from '@/wallets/nwc' export async function testSendPayment ({ nwcUrl }, { logger }) { @@ -11,11 +11,7 @@ export async function testSendPayment ({ nwcUrl }, { logger }) { } export async function sendPayment (bolt11, { nwcUrl }, { logger }) { - const result = await nwcCall({ - nwcUrl, - method: 'pay_invoice', - params: { invoice: bolt11 } - }, - { logger }) + const nwc = await getNwc(nwcUrl) + const result = await nwcTryRun(() => nwc.payInvoice(bolt11)) return result.preimage } diff --git a/wallets/nwc/index.js b/wallets/nwc/index.js index 5bab2300..6b9e40d0 100644 --- a/wallets/nwc/index.js +++ b/wallets/nwc/index.js @@ -1,7 +1,7 @@ -import { Relay } from '@/lib/nostr' -import { parseNwcUrl } from '@/lib/url' +import Nostr from '@/lib/nostr' import { string } from '@/lib/yup' -import { finalizeEvent, nip04, verifyEvent } from 'nostr-tools' +import { parseNwcUrl } from '@/lib/url' +import { NDKNwc } from '@nostr-dev-kit/ndk' export const name = 'nwc' export const walletType = 'NWC' @@ -33,61 +33,38 @@ export const card = { subtitle: 'use Nostr Wallet Connect for payments' } -export async function nwcCall ({ nwcUrl, method, params }, { logger, timeout } = {}) { - const { relayUrl, walletPubkey, secret } = parseNwcUrl(nwcUrl) - - const relay = await Relay.connect(relayUrl, { timeout }) - logger?.ok(`connected to ${relayUrl}`) +export async function getNwc (nwcUrl, { timeout = 5e4 } = {}) { + const ndk = Nostr.ndk + const { walletPubkey, secret, relayUrls } = parseNwcUrl(nwcUrl) + const nwc = new NDKNwc({ + ndk, + pubkey: walletPubkey, + relayUrls, + secret + }) + await nwc.blockUntilReady(timeout) + return nwc +} +/** + * Run a nwc function and throw if it errors + * (workaround to handle ambiguous NDK error handling) + * @param {function} fun - the nwc function to run + * @returns - the result of the nwc function + */ +export async function nwcTryRun (fun) { try { - const payload = { method, params } - const encrypted = await nip04.encrypt(secret, walletPubkey, JSON.stringify(payload)) - - const request = finalizeEvent({ - kind: 23194, - created_at: Math.floor(Date.now() / 1000), - tags: [['p', walletPubkey]], - content: encrypted - }, secret) - - // we need to subscribe to the response before publishing the request - // since NWC events are ephemeral (20000 <= kind < 30000) - const subscription = relay.fetch([{ - kinds: [23195], - authors: [walletPubkey], - '#e': [request.id] - }], { timeout }) - - await relay.publish(request, { timeout }) - - logger?.info(`published ${method} request`) - - logger?.info(`waiting for ${method} response ...`) - - const [response] = await subscription - - if (!response) { - throw new Error(`no ${method} response`) - } - - logger?.ok(`${method} response received`) - - if (!verifyEvent(response)) throw new Error(`invalid ${method} response: failed to verify`) - - const decrypted = await nip04.decrypt(secret, walletPubkey, response.content) - const content = JSON.parse(decrypted) - - if (content.error) throw new Error(content.error.message) - if (content.result) return content.result - - throw new Error(`invalid ${method} response: missing error or result`) - } finally { - relay?.close() - logger?.info(`closed connection to ${relayUrl}`) + const { error, result } = await fun() + if (error) throw new Error(error.message || error.code) + return result + } catch (e) { + if (e.error) throw new Error(e.error.message || e.error.code) + throw e } } export async function supportedMethods (nwcUrl, { logger, timeout } = {}) { - const result = await nwcCall({ nwcUrl, method: 'get_info' }, { logger, timeout }) + const nwc = await getNwc(nwcUrl, { timeout }) + const result = await nwcTryRun(() => nwc.getInfo()) return result.methods } diff --git a/wallets/nwc/server.js b/wallets/nwc/server.js index 9a8b06e1..e66bc1d3 100644 --- a/wallets/nwc/server.js +++ b/wallets/nwc/server.js @@ -1,5 +1,5 @@ import { withTimeout } from '@/lib/time' -import { nwcCall, supportedMethods } from '@/wallets/nwc' +import { getNwc, supportedMethods, nwcTryRun } from '@/wallets/nwc' export * from '@/wallets/nwc' export async function testCreateInvoice ({ nwcUrlRecv }, { logger }) { @@ -23,17 +23,8 @@ export async function testCreateInvoice ({ nwcUrlRecv }, { logger }) { return await withTimeout(createInvoice({ msats: 1000, expiry: 1 }, { nwcUrlRecv }, { logger }), timeout) } -export async function createInvoice ( - { msats, description, expiry }, - { nwcUrlRecv }, { logger }) { - const result = await nwcCall({ - nwcUrl: nwcUrlRecv, - method: 'make_invoice', - params: { - amount: msats, - description, - expiry - } - }, { logger }) +export async function createInvoice ({ msats, description, expiry }, { nwcUrlRecv }, { logger }) { + const nwc = await getNwc(nwcUrlRecv) + const result = await nwcTryRun(() => nwc.sendReq('make_invoice', { amount: msats, description, expiry })) return result.invoice } diff --git a/worker/index.js b/worker/index.js index 5543e289..16d48c59 100644 --- a/worker/index.js +++ b/worker/index.js @@ -38,6 +38,12 @@ import { expireBoost } from './expireBoost' import { payingActionConfirmed, payingActionFailed } from './payingAction' import { autoDropBolt11s } from './autoDropBolt11' +// WebSocket polyfill +import ws from 'isomorphic-ws' +if (typeof WebSocket === 'undefined') { + global.WebSocket = ws +} + async function work () { const boss = new PgBoss(process.env.DATABASE_URL) const models = createPrisma({ diff --git a/worker/nostr.js b/worker/nostr.js index 7dd932c9..fe220126 100644 --- a/worker/nostr.js +++ b/worker/nostr.js @@ -1,5 +1,4 @@ -import { signId, calculateId, getPublicKey } from 'nostr' -import { Relay } from '@/lib/nostr' +import Nostr from '@/lib/nostr' const nostrOptions = { startAfter: 5, retryLimit: 21, retryBackoff: true } @@ -40,26 +39,18 @@ export async function nip57 ({ data: { hash }, boss, lnd, models }) { const e = { kind: 9735, - pubkey: getPublicKey(process.env.NOSTR_PRIVATE_KEY), created_at: Math.floor(new Date(inv.confirmedAt).getTime() / 1000), content: '', tags } - e.id = await calculateId(e) - e.sig = await signId(process.env.NOSTR_PRIVATE_KEY, e.id) console.log('zap note', e, relays) - await Promise.allSettled( - relays.map(async r => { - const timeout = 1000 - const relay = await Relay.connect(r, { timeout }) - try { - await relay.publish(e, { timeout }) - } finally { - relay.close() - } - }) - ) + const signer = Nostr.getSigner({ privKey: process.env.NOSTR_PRIVATE_KEY }) + await Nostr.publish(e, { + relays, + signer, + timeout: 1000 + }) } catch (e) { console.log(e) }