diff --git a/.env.development b/.env.development index a9242b5d5..54c9b101f 100644 --- a/.env.development +++ b/.env.development @@ -168,4 +168,8 @@ CPU_SHARES_IMPORTANT=1024 CPU_SHARES_MODERATE=512 CPU_SHARES_LOW=256 -NEXT_TELEMETRY_DISABLED=1 \ No newline at end of file +NEXT_TELEMETRY_DISABLED=1 + +CASHU_MINT_PORT=3338 + +NOSTR_PORT=7777 \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index 0dbddc5e0..5bb6ebbb8 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -11,5 +11,12 @@ "port": 9229, "outputCapture": "std" } - ] + ], + "typescript.preferences.importModuleSpecifier": "relative", + "typescript.tsserver.experimental.enableProjectDiagnostics": true, + "typescript.tsserver.log": "verbose", + "typescript.tsserver.trace": "messages", + "typescript.tsserver.useSeparateSyntaxServer": true, + "typescript.tsserver.useSyntaxServer": "always", + "typescript.tsserver.experimental.enableProjectDiagnostics": true } \ No newline at end of file diff --git a/components/use-crossposter.js b/components/use-crossposter.js index 8798009be..e4ee529d4 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/docker-compose.yml b/docker-compose.yml index 17ae74881..a21b864d2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -653,6 +653,30 @@ services: CONNECT: "localhost:${LNBITS_WEB_PORT}" TORDIR: "/app/.tor" cpu_shares: "${CPU_SHARES_LOW}" + cashu_mint: + image: cashubtc/nutshell:0.16.3 + container_name: cashu_mint + restart: unless-stopped + depends_on: + lnd: + condition: service_healthy + restart: true + ports: + - "${CASHU_MINT_PORT}:3338" + environment: + - MINT_BACKEND_BOLT11_SAT=LndRPCWallet + - MINT_LISTEN_HOST=0.0.0.0 + - MINT_LISTEN_PORT=3338 + - MINT_PRIVATE_KEY=ca47300443b0102324fbee24cd1d04914ed11a901ca7061755283e57d9fceae3 + - MINT_LND_RPC_ENDPOINT=lnd:10009 + - MINT_LND_RPC_CERT=/lnd/tls.cert + - MINT_LND_RPC_MACAROON=/lnd/data/chain/bitcoin/regtest/admin.macaroon + - DEBUG=true + - TOR=false + - NOSTR_RELAYS=["wss://relay.primal.net"] + volumes: + - lnd:/lnd + command: ["poetry", "run", "mint"] volumes: db: os: @@ -664,3 +688,4 @@ volumes: nwc_send: nwc_recv: tordata: + nostr: diff --git a/docker/nostr/strfry.conf b/docker/nostr/strfry.conf new file mode 100644 index 000000000..bd6233155 --- /dev/null +++ b/docker/nostr/strfry.conf @@ -0,0 +1,144 @@ +## +## Default strfry config +## + +# Directory that contains the strfry LMDB database (restart required) +db = "./strfry-db/" + +dbParams { + # Maximum number of threads/processes that can simultaneously have LMDB transactions open (restart required) + maxreaders = 256 + + # Size of mmap() to use when loading LMDB (default is 10TB, does *not* correspond to disk-space used) (restart required) + mapsize = 10995116277760 + + # Disables read-ahead when accessing the LMDB mapping. Reduces IO activity when DB size is larger than RAM. (restart required) + noReadAhead = false +} + +events { + # Maximum size of normalised JSON, in bytes + maxEventSize = 65536 + + # Events newer than this will be rejected + rejectEventsNewerThanSeconds = 900 + + # Events older than this will be rejected + rejectEventsOlderThanSeconds = 94608000 + + # Ephemeral events older than this will be rejected + rejectEphemeralEventsOlderThanSeconds = 60 + + # Ephemeral events will be deleted from the DB when older than this + ephemeralEventsLifetimeSeconds = 300 + + # Maximum number of tags allowed + maxNumTags = 2000 + + # Maximum size for tag values, in bytes + maxTagValSize = 1024 +} + +relay { + # Interface to listen on. Use 0.0.0.0 to listen on all interfaces (restart required) + bind = "0.0.0.0" + + # Port to open for the nostr websocket protocol (restart required) + port = 7777 + + # Set OS-limit on maximum number of open files/sockets (if 0, don't attempt to set) (restart required) + nofiles = 1000000 + + # HTTP header that contains the client's real IP, before reverse proxying (ie x-real-ip) (MUST be all lower-case) + realIpHeader = "" + + info { + # NIP-11: Name of this server. Short/descriptive (< 30 characters) + name = "strfry default" + + # NIP-11: Detailed information about relay, free-form + description = "This is a strfry instance." + + # NIP-11: Administrative nostr pubkey, for contact purposes + pubkey = "" + + # NIP-11: Alternative administrative contact (email, website, etc) + contact = "" + + # NIP-11: URL pointing to an image to be used as an icon for the relay + icon = "" + + # List of supported lists as JSON array, or empty string to use default. Example: "[1,2]" + nips = "" + } + + # Maximum accepted incoming websocket frame size (should be larger than max event) (restart required) + maxWebsocketPayloadSize = 131072 + + # Websocket-level PING message frequency (should be less than any reverse proxy idle timeouts) (restart required) + autoPingSeconds = 55 + + # If TCP keep-alive should be enabled (detect dropped connections to upstream reverse proxy) + enableTcpKeepalive = false + + # How much uninterrupted CPU time a REQ query should get during its DB scan + queryTimesliceBudgetMicroseconds = 10000 + + # Maximum records that can be returned per filter + maxFilterLimit = 500 + + # Maximum number of subscriptions (concurrent REQs) a connection can have open at any time + maxSubsPerConnection = 20 + + writePolicy { + # If non-empty, path to an executable script that implements the writePolicy plugin logic + plugin = "" + } + + compression { + # Use permessage-deflate compression if supported by client. Reduces bandwidth, but slight increase in CPU (restart required) + enabled = true + + # Maintain a sliding window buffer for each connection. Improves compression, but uses more memory (restart required) + slidingWindow = true + } + + logging { + # Dump all incoming messages + dumpInAll = false + + # Dump all incoming EVENT messages + dumpInEvents = false + + # Dump all incoming REQ/CLOSE messages + dumpInReqs = false + + # Log performance metrics for initial REQ database scans + dbScanPerf = false + + # Log reason for invalid event rejection? Can be disabled to silence excessive logging + invalidEvents = true + } + + numThreads { + # Ingester threads: route incoming requests, validate events/sigs (restart required) + ingester = 3 + + # reqWorker threads: Handle initial DB scan for events (restart required) + reqWorker = 3 + + # reqMonitor threads: Handle filtering of new events (restart required) + reqMonitor = 3 + + # negentropy threads: Handle negentropy protocol messages (restart required) + negentropy = 2 + } + + negentropy { + # Support negentropy protocol messages + enabled = true + + # Maximum records that sync will process before returning an error + maxSyncEvents = 1000000 + } +} \ No newline at end of file diff --git a/jsconfig.json b/jsconfig.json index 9b5c18ac9..2f296cfe3 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "sourceMap": true, "baseUrl": ".", "paths": { "@/api/*": [ diff --git a/lib/nostr.js b/lib/nostr.js index 7a5e497a0..c83682203 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]+$/ @@ -18,154 +16,244 @@ export const DEFAULT_CROSSPOSTING_RELAYS = [ '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) - } +export const RELAYS_BLACKLIST = [] + +/** + * @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 + _privKey = null + _relays = [] + constructor ({ privKey, defaultSigner, relays, supportNip07 = true, ...ndkOptions } = {}) { + if (typeof window !== 'undefined' && process.env.NODE_ENV === 'development') { + window.localStorage.debug = 'ndk:*,ndk-wallet:*' } - - ws.onerror = (err) => { - console.error('websocket error:', err.message) - this.error = err.message - } - - this.ws = ws - this.url = relayUrl - this.error = null + this._privKey = privKey + this._relays = relays?.sort() + this._ndk = new NDK({ + explicitRelayUrls: relays, + blacklistRelayUrls: RELAYS_BLACKLIST, + autoConnectUserRelays: false, + autoFetchUserMutelist: false, + clientName: 'stacker.news', + signer: defaultSigner ?? this.selectSigner({ privKey, supportNip07 }), + ...ndkOptions + }) } - static async connect (url, { timeout } = {}) { - const relay = new Relay(url) - await relay.waitUntilConnected({ timeout }) - return relay + close () { + // TODO: how? } - get connected () { - return this.ws.readyState === WebSocket.OPEN + checkConfig ({ privKey, relays }) { + relays = relays?.sort() + if (this._privKey !== privKey) return false + if (this._relays?.length !== relays?.length) return false + if (this._relays?.some((r, i) => r !== relays[i])) return false + return true } - get closed () { - return this.ws.readyState === WebSocket.CLOSING || this.ws.readyState === WebSocket.CLOSED + selectSigner ({ privKey, supportNip07 = true } = {}) { + return (privKey ? new NDKPrivateKeySigner(privKey) : null) ?? (supportNip07 && typeof window !== 'undefined' && window?.nostr ? new NDKNip07Signer() : null) } - async waitUntilConnected ({ timeout } = {}) { - let interval + /** + * @returns {Promise} + */ + get pubKey () { + return this._ndk.signer.user().then(u => u.pubkey) + } - 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) - }) + /** + * @returns {Promise>} + */ + get relays () { + return this._relays + } - try { - return await withTimeout(checkPromise, timeout) - } catch (err) { - this.close() - throw err - } finally { - clearInterval(interval) - } + /** + * @returns {string|undefined} + */ + get privKey () { + return this._privKey } - close () { - const state = this.ws.readyState - if (state !== WebSocket.CLOSING && state !== WebSocket.CLOSED) { - this.ws.close() - } + /** + * @returns {NDKSigner} + */ + get signer () { + return this._ndk.signer } - async publish (event, { timeout } = {}) { - const ws = this.ws + /** + * Get a nwc wallet + * @param {string} nwcUrl + * @returns {Promise} + */ + async nwc (nwcUrl) { + return await this._ndk.nwc(nwcUrl) + } - let listener - const ackPromise = new Promise((resolve, reject) => { - listener = function onmessage (msg) { - const [type, eventId, accepted, reason] = JSON.parse(msg.data) + get ndk () { + return this._ndk + } - if (type !== 'OK' || eventId !== event.id) return + /** + * Subscribe to events + * @import {NDKFilter} from '@nostr-dev-kit/ndk' + * @import {NDKEvent} from '@nostr-dev-kit/ndk' + * @param {NDKFilter[]|NDKFilter} filters + * @param {NDKEvent): void} onEvent + * @param {Object} options + * @param {Array} options.relays + * @param {boolean} [options.closeOnEose] + */ + subscribe (filters, { onEvent, onEose, relays, closeOnEose = false, waitClose = false } = {}) { + const ndk = this._ndk + const relaySet = NDKRelaySet.fromRelayUrls(relays, ndk, true) + + const sub = ndk.subscribe(filters, { + skipOptimisticPublishEvent: true + }, relaySet) + + if (onEvent) { + sub.on('event', (event) => { + const r = onEvent(sub, event) + if (r instanceof Promise) r.catch(console.error) + }) + } - if (accepted) { - resolve(eventId) + const closingPromise = new Promise((resolve, reject) => { + sub.on('close', () => { + resolve() + }) + }) + sub.wait = async () => closingPromise + + // code is a bit fonky, but it is to make sure + // sub is closed only after onEose is called + // and that if onEose is a promise, the exception is caught + if (onEose || closeOnEose) { + sub.on('eose', () => { + let r + if (onEose) { + r = onEose(sub) + } + if (r instanceof Promise) { + r.catch(console.error).finally(() => { + if (closeOnEose) sub.stop() + }) } else { - reject(new Error(reason || `event rejected: ${eventId}`)) + if (closeOnEose) sub.stop() } - } - - ws.addEventListener('message', listener) - - ws.send(JSON.stringify(['EVENT', event])) - }) - - try { - return await withTimeout(ackPromise, timeout) - } finally { - ws.removeEventListener('message', listener) + }) } + + return sub } - async fetch (filter, { timeout } = {}) { - const ws = this.ws + /** + * @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} + */ + /* eslint-disable camelcase */ + async sign ({ kind, created_at, content, tags }, { privKey, signer } = {}) { + const event = new NDKEvent(this._ndk) + event.kind = kind + event.created_at = created_at + event.content = content + event.tags = tags + + signer = signer ?? (privKey ? new NDKPrivateKeySigner(privKey) : 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 + } - let listener - const ackPromise = new Promise((resolve, reject) => { - const id = crypto.randomBytes(16).toString('hex') + /** + * @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} + */ + /* eslint-disable camelcase */ + async publish ({ created_at, content, tags = [], kind }, { relays, privKey, signer, timeout } = {}) { + const event = await this.sign({ kind, created_at, content, tags }, { privKey, signer }) - const events = [] - let eose = false + const successfulRelays = [] + const failedRelays = [] - listener = function onmessage (msg) { - const [type, subId, event] = JSON.parse(msg.data) + const relaySet = NDKRelaySet.fromRelayUrls(relays, this._ndk, true) - if (subId !== id) return + event.on('relay:publish:failed', (relay, error) => { + failedRelays.push({ relay: relay.url, error }) + }) - if (type === 'EVENT') { - events.push(event) - if (eose) { - // EOSE was already received: - // return first event after EOSE - resolve(events) - } - return - } + for (const relay of (await relaySet.publish(event, timeout))) { + successfulRelays.push(relay.url) + } - if (type === 'CLOSED') { - return resolve(events) - } + return { + event, + successfulRelays, + failedRelays + } + } - 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) - } - } + /* eslint-disable camelcase */ + async crosspost ({ created_at, content, tags = [], kind }, { relays = DEFAULT_CROSSPOSTING_RELAYS, privKey, signer, timeout } = {}) { + try { + const { event: signedEvent, successfulRelays, failedRelays } = await this.publish({ created_at, content, tags, kind }, { relays, privKey, 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() + export function hexToBech32 (hex, prefix = 'npub') { return bech32.encode(prefix, bech32.toWords(Buffer.from(hex, 'hex'))) } @@ -186,49 +274,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/package-lock.json b/package-lock.json index 3326e9a10..db835834b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,8 @@ "@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", + "@nostr-dev-kit/ndk-wallet": "^0.3.13", "@opensearch-project/opensearch": "^2.12.0", "@prisma/client": "^5.20.0", "@slack/web-api": "^7.6.0", @@ -2428,6 +2430,129 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, + "node_modules/@cashu/cashu-ts": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@cashu/cashu-ts/-/cashu-ts-1.2.1.tgz", + "integrity": "sha512-B0+e02S8DQA8KBt2FgKHgGYvtHQokXJ3sZcTyAdHqvb0T0jfo1zF7nHn19eU9iYcfk8VSWf5xNBTocpTfj1aNg==", + "license": "MIT", + "dependencies": { + "@cashu/crypto": "^0.2.7", + "@noble/curves": "^1.3.0", + "@noble/hashes": "^1.3.3", + "@scure/bip32": "^1.3.3", + "@scure/bip39": "^1.2.2", + "buffer": "^6.0.3" + } + }, + "node_modules/@cashu/cashu-ts/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/@cashu/cashu-ts/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/@cashu/cashu-ts/node_modules/@scure/bip32": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.5.0.tgz", + "integrity": "sha512-8EnFYkqEQdnkuGBVpCzKxyIwDCBLDVj3oiX0EKUFre/tOjL/Hqba1D6n/8RcmaQy4f95qQFrO2A8Sr6ybh4NRw==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.6.0", + "@noble/hashes": "~1.5.0", + "@scure/base": "~1.1.7" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@cashu/cashu-ts/node_modules/@scure/bip39": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.4.0.tgz", + "integrity": "sha512-BEEm6p8IueV/ZTfQLp/0vhw4NPnT9oWf5+28nvmeUICjP99f4vr2d+qc7AVGDDtwRep6ifR43Yed9ERVmiITzw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "~1.5.0", + "@scure/base": "~1.1.8" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@cashu/crypto": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/@cashu/crypto/-/crypto-0.2.7.tgz", + "integrity": "sha512-1aaDfUjiHNXoJqg8nW+341TLWV9W28DsVNXJUKcHL0yAmwLs5+56SSnb8LLDJzPamLVoYL0U0bda91klAzptig==", + "license": "MIT", + "dependencies": { + "@noble/curves": "^1.3.0", + "@noble/hashes": "^1.3.3", + "@scure/bip32": "^1.3.3", + "@scure/bip39": "^1.2.2", + "buffer": "^6.0.3" + } + }, + "node_modules/@cashu/crypto/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/@cashu/crypto/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/@cashu/crypto/node_modules/@scure/bip32": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.5.0.tgz", + "integrity": "sha512-8EnFYkqEQdnkuGBVpCzKxyIwDCBLDVj3oiX0EKUFre/tOjL/Hqba1D6n/8RcmaQy4f95qQFrO2A8Sr6ybh4NRw==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.6.0", + "@noble/hashes": "~1.5.0", + "@scure/base": "~1.1.7" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@cashu/crypto/node_modules/@scure/bip39": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.4.0.tgz", + "integrity": "sha512-BEEm6p8IueV/ZTfQLp/0vhw4NPnT9oWf5+28nvmeUICjP99f4vr2d+qc7AVGDDtwRep6ifR43Yed9ERVmiITzw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "~1.5.0", + "@scure/base": "~1.1.8" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.23.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.1.tgz", @@ -2982,6 +3107,72 @@ "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.8.tgz", "integrity": "sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==" }, + "node_modules/@getalby/sdk": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/@getalby/sdk/-/sdk-3.7.1.tgz", + "integrity": "sha512-fn/JrnH7NvD4Hu9REXQ8TLQVPN/BYnv0QcCO7L5M6gQg2Clndoj7JHn7CY/fX5BQq7d9jvfujeNrXgBJkEklnw==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.1", + "nostr-tools": "^1.17.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "lightning", + "url": "lightning:hello@getalby.com" + } + }, + "node_modules/@getalby/sdk/node_modules/@noble/ciphers": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.2.0.tgz", + "integrity": "sha512-6YBxJDAapHSdd3bLDv6x2wRPwq4QFMUaB3HvljNBUTThDd12eSm7/3F+2lnfzx2jvM+S6Nsy0jEt9QbPqSwqRw==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@getalby/sdk/node_modules/@noble/curves": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.1.0.tgz", + "integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.3.1" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@getalby/sdk/node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, + "node_modules/@getalby/sdk/node_modules/nostr-tools": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-1.17.0.tgz", + "integrity": "sha512-LZmR8GEWKZeElbFV5Xte75dOeE9EFUW/QLI1Ncn3JKn0kFddDKEfBbFN8Mu4TMs+L4HR/WTPha2l+PPuRnJcMw==", + "license": "Unlicense", + "dependencies": { + "@noble/ciphers": "0.2.0", + "@noble/curves": "1.1.0", + "@noble/hashes": "1.3.1", + "@scure/base": "1.1.1", + "@scure/bip32": "1.3.1", + "@scure/bip39": "1.2.1" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/@graphql-tools/merge": { "version": "9.0.7", "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-9.0.7.tgz", @@ -4371,6 +4562,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 +4606,65 @@ "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-wallet": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@nostr-dev-kit/ndk-wallet/-/ndk-wallet-0.3.13.tgz", + "integrity": "sha512-qaT2t9XktRNkGz7sXcJKKTxAP1M0FhzU5qkQ0JgRE2wCvfdMxV7/Al5yTlkC63TQtMOFKHlvZymzOiyRzgOdBw==", + "license": "MIT", + "dependencies": { + "@cashu/cashu-ts": "1.2.1", + "@getalby/sdk": "^3.6.1", + "@nostr-dev-kit/ndk": "2.10.5", + "debug": "^4.3.4", + "light-bolt11-decoder": "^3.0.0", + "tseep": "^1.1.1", + "typescript": "^5.4.4", + "webln": "^0.3.2" + } + }, + "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", @@ -7287,6 +7546,30 @@ "node-int64": "^0.4.0" } }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/buffer-compare": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/buffer-compare/-/buffer-compare-1.1.1.tgz", @@ -7310,6 +7593,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 +8385,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 +9277,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 +9930,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 +10039,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 +10203,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", @@ -11022,6 +11405,26 @@ "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==" }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.2.4", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz", @@ -14154,6 +14557,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 +16018,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 +19826,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 +19882,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 +20010,39 @@ "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": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "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 +20461,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 +20806,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 +21423,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 4be50cc37..4a01aec1e 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,8 @@ "@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", + "@nostr-dev-kit/ndk-wallet": "^0.3.13", "@opensearch-project/opensearch": "^2.12.0", "@prisma/client": "^5.20.0", "@slack/web-api": "^7.6.0", diff --git a/prisma/migrations/20241114195428_cashu_sender/migration.sql b/prisma/migrations/20241114195428_cashu_sender/migration.sql new file mode 100644 index 000000000..aba55886f --- /dev/null +++ b/prisma/migrations/20241114195428_cashu_sender/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "WalletType" ADD VALUE 'CASHU'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index da9d6e9da..022740439 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -185,6 +185,7 @@ enum WalletType { BLINK LNC WEBLN + CASHU } model Wallet { diff --git a/wallets/cashu/ATTACH.md b/wallets/cashu/ATTACH.md new file mode 100644 index 000000000..66139acd8 --- /dev/null +++ b/wallets/cashu/ATTACH.md @@ -0,0 +1,9 @@ + + + + + + +mint: http://127.0.0.1:3338 +relay: wss://relay.primal.net +nostr privkey: any private key, you can get one from https://nostrtool.com/ \ No newline at end of file diff --git a/wallets/cashu/client.js b/wallets/cashu/client.js new file mode 100644 index 000000000..c0dc1fabb --- /dev/null +++ b/wallets/cashu/client.js @@ -0,0 +1,24 @@ +import { getWallet, createWallet } from 'wallets/cashu/common' +export * from 'wallets/cashu' + +export async function testSendPayment ({ privKey, walletName, relays, mints }, { logger }) { + relays = relays.split(',') + mints = mints.split(',') + let wallet + try { + wallet = await getWallet({ privKey, relays, walletName, mints, logger }) + } catch (e) { + wallet = await createWallet({ privKey, relays, walletName, mints, logger }) + } + console.log('Wallet', wallet) +} + +export async function sendPayment (bolt11, { privKey, walletName, relays, mints }, { logger }) { + relays = relays.split(',') + mints = mints.split(',') + const wallet = await getWallet({ privKey, relays, walletName, mints, logger }) + if (!wallet) throw new Error('Wallet not found') + const res = await wallet.lnPay({ pr: bolt11 }) + if (!res) throw new Error('Payment failed') + return res.preimage +} diff --git a/wallets/cashu/common.js b/wallets/cashu/common.js new file mode 100644 index 000000000..11eeabb1b --- /dev/null +++ b/wallets/cashu/common.js @@ -0,0 +1,144 @@ +/** + * This is a custom implementation of NIP-60 cashu wallets + * It diverges from the NDK wallet service because its goals are: + * 1. control the flow so that things are available when we need them + * 2. reduce relay footprint so that we do not conflict with other apps sharing the same wallet + */ +import { Nostr } from '@/lib/nostr' +import { NDKCashuToken, NDKCashuWallet } from '@nostr-dev-kit/ndk-wallet' +import { NDKKind } from '@nostr-dev-kit/ndk' +let cashuNostrConnector = null + +/** + * + * In this implementation we assume the user client wants to run a single cashu attachment + * for a long time, so we keep it unless it changes. + * If we want to support multiple attachments of the same kind, + * his might need to be changed to an array instances that timeout after a while + * @param {*} param0 + * @returns + */ +async function getNostrConnector ({ privKey, relays }) { + console.log(relays) + if (!cashuNostrConnector || !cashuNostrConnector.checkConfig({ privKey, relays })) { + if (cashuNostrConnector) cashuNostrConnector.close() + cashuNostrConnector = new Nostr({ privKey, relays }) + } + return cashuNostrConnector +} + +// Get a cashu wallet from the relays +export async function getWallet ({ privKey, relays, walletId, walletName, mints }) { + const nostr = getNostrConnector({ privKey, relays }) + if (!walletName) throw new Error('walletName is required') + const pubKey = await nostr.pubKey + + const foundWallets = [] + await nostr.subscribe({ + kinds: [NDKKind.CashuWallet], + authors: [pubKey], + ...(walletId ? { tags: ['d', walletId] } : {}) + }, { + onEvent: async (sub, event) => { + const wallet = await NDKCashuWallet.from(event) + const name = wallet.name ?? 'unknown' + const mints = wallet.mints ?? [] + const relays = wallet.relays ?? [] + const unit = wallet.unit ?? 'unknown' + const walletId = wallet.walletId ?? 'unknown' + const status = wallet.status ?? 'unknown' + console.log('Found wallet:', { name, mints, relays, unit, walletId, status }) + foundWallets.push(wallet) + }, + relays, + closeOnEose: true + }).wait() + + if (foundWallets.length === 0) throw new Error('wallet not found') + const foundWallet = foundWallets.find(w => w.name === walletName || w.walletId === walletId) + console.log('Found wallets:', foundWallets, 'using', foundWallet) + + // merge more mints and relays to the wallet without changing the synched state + for (const relay of relays) { + if (!foundWallet.relays.includes(relay)) { + foundWallet.relays.push(relay) + } + } + for (const mint of mints) { + if (!foundWallet.mints.includes(mint)) { + foundWallet.mints.push(mint) + } + } + + await refreshBalance(nostr, foundWallet, { relays }) + + return foundWallet +} + +/** + * Refresh the balance of a cashu wallet by fetching all the tokens + * @param {NDKCashuWallet} wallet + * @param {*} pubKey + * @param {*} param2 + * @returns + */ +async function refreshBalance (nostr, wallet, { relays }) { + const pubKey = await nostr.pubKey + + /** @type {Promise[]} */ + const tokensPromises = [] + await nostr.subscribe({ + kinds: [NDKKind.CashuToken], + authors: [pubKey] + }, { + onEvent: (sub, event) => { + if (event.kind === NDKKind.CashuToken) { + tokensPromises.push(NDKCashuToken.from(event)) + } + }, + relays, + closeOnEose: true + }).wait() + + let balance = 0 + // add all the tokens and calculate the balance + for (const p of await Promise.allSettled(tokensPromises)) { + if (p.status === 'rejected') { + console.error('Error fetching token', p.reason) + continue + } + /** @type {NDKCashuToken[]} */ + const token = p.value + if (token.walletId !== wallet.walletId) { + console.warn('Token does not belong to the wallet', token.walletId, '!=', wallet.walletId) + continue + } + + // HOTFIX: NDKCashuToken is looking for this in the tags??? + // The NIP-60 spec says it should be in the content... + token.mint = token.mint ?? JSON.parse(token.content).mint + + console.log('Adding token', token, 'from mint', token.mint) + const sats = token.amount ?? 0 + balance += sats + wallet.addToken(token) + console.log('Wallet balance:', balance) + } + + const mintedBalances = wallet.mintBalances + console.log('Minted balances:', mintedBalances) + return balance +} + +export async function createWallet ({ privKey, relays, walletName, mints }) { + const nostr = getNostrConnector({ privKey, relays }) + + console.log('Creating wallet', walletName) + const wallet = new NDKCashuWallet(nostr.ndk) + wallet.name = walletName + wallet.mints = mints + wallet.relays = relays + wallet.unit = 'sats' + await wallet.publish() // publish the new wallet + return wallet +} diff --git a/wallets/cashu/index.js b/wallets/cashu/index.js new file mode 100644 index 000000000..68431e1a0 --- /dev/null +++ b/wallets/cashu/index.js @@ -0,0 +1,66 @@ +import { string } from '@/lib/yup' + +export const DEFAULT_CASHU_RELAYS = [ + 'wss://nostr.rblb.it:7777', + 'wss://relay.primal.net', + 'wss://relay.notoshi.win' +] + +export const DEFAULT_CASHU_MINTS = [ + 'https://mint.lnw.cash' +] + +export const name = 'cashu' +export const walletType = 'CASHU' +export const walletField = 'walletCASHU' + +export const fields = [ + { + name: 'walletName', + label: 'wallet name', + type: 'text', + placeholder: 'stacker.news-cashu', + clientOnly: true, + validate: string(), + help: 'The name of the cashu wallet to use.' + }, + { + name: 'relays', + label: 'relays', + type: 'text', + help: 'a comma-separated list of relays to use', + placeholder: 'wss://nostr.rblb.it:7777', + defaultValue: DEFAULT_CASHU_RELAYS.join(','), + clear: true, + clientOnly: true, + validate: string() + }, + { + name: 'mints', + label: 'mints', + type: 'text', + help: 'a comma-separated list of mints to use', + defaultValue: DEFAULT_CASHU_MINTS.join(','), + placeholder: 'blink_...', + clear: true, + clientOnly: true, + validate: string() + }, + { + name: 'privKey', + label: 'wallet nostr private key', + type: 'text', + help: 'the nostr private key to use for the wallet', + placeholder: 'nsec...', + clear: true, + clientOnly: true, + optional: 'if unset the browser extension will be used', + validate: string() + } +] + +export const card = { + title: 'Cashu', + subtitle: 'Cashu over nostr NIP-60', + badges: ['send'] +} diff --git a/wallets/cashu/server.js b/wallets/cashu/server.js new file mode 100644 index 000000000..5f4b1fd07 --- /dev/null +++ b/wallets/cashu/server.js @@ -0,0 +1,9 @@ +import { getWallet, createWallet } from 'wallets/cashu/common' + +export async function testCreateInvoice ({ nwcUrlRecv }, { logger }) { + +} + +export async function createInvoice ({ msats, description, expiry }, { privKey, walletName, relays, mints }, { logger }) { + +} diff --git a/wallets/client.js b/wallets/client.js index 96a68ca8f..3a780484b 100644 --- a/wallets/client.js +++ b/wallets/client.js @@ -7,5 +7,6 @@ import * as lnd from 'wallets/lnd/client' import * as webln from 'wallets/webln/client' import * as blink from 'wallets/blink/client' import * as phoenixd from 'wallets/phoenixd/client' +import * as cashu from 'wallets/cashu/client' -export default [nwc, lnbits, lnc, lnAddr, cln, lnd, webln, blink, phoenixd] +export default [nwc, lnbits, lnc, lnAddr, cln, lnd, webln, blink, phoenixd, cashu] diff --git a/wallets/nwc/client.js b/wallets/nwc/client.js index 19d8d9963..0794a7581 100644 --- a/wallets/nwc/client.js +++ b/wallets/nwc/client.js @@ -1,4 +1,5 @@ -import { nwcCall, supportedMethods } from 'wallets/nwc' +import { supportedMethods } from 'wallets/nwc' +import Nostr from '@/lib/nostr' export * from 'wallets/nwc' export async function testSendPayment ({ nwcUrl }, { logger }) { @@ -11,11 +12,8 @@ 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 Nostr.nwc(nwcUrl) + const { error, result } = await nwc.payInvoice(bolt11) + if (error) throw new Error(error.code + ' ' + error.message) return result.preimage } diff --git a/wallets/nwc/index.js b/wallets/nwc/index.js index 49794d926..3620571fb 100644 --- a/wallets/nwc/index.js +++ b/wallets/nwc/index.js @@ -1,7 +1,5 @@ -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' export const name = 'nwc' export const walletType = 'NWC' @@ -34,61 +32,9 @@ export const card = { badges: ['send', 'receive', 'budgetable'] } -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}`) - - 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}`) - } -} - export async function supportedMethods (nwcUrl, { logger, timeout } = {}) { - const result = await nwcCall({ nwcUrl, method: 'get_info' }, { logger, timeout }) + const nwc = await Nostr.nwc(nwcUrl) + const { error, result } = await nwc.getInfo() + if (error) throw new Error(error.code + ' ' + error.message) return result.methods } diff --git a/wallets/nwc/server.js b/wallets/nwc/server.js index ce2220872..a221e5e0a 100644 --- a/wallets/nwc/server.js +++ b/wallets/nwc/server.js @@ -1,5 +1,6 @@ import { withTimeout } from '@/lib/time' -import { nwcCall, supportedMethods } from 'wallets/nwc' +import { supportedMethods } from 'wallets/nwc' +import Nostr from '@/lib/nostr' export * from 'wallets/nwc' export async function testCreateInvoice ({ nwcUrlRecv }, { logger }) { @@ -26,14 +27,8 @@ export async function testCreateInvoice ({ nwcUrlRecv }, { logger }) { export async function createInvoice ( { msats, description, expiry }, { nwcUrlRecv }, { logger }) { - const result = await nwcCall({ - nwcUrl: nwcUrlRecv, - method: 'make_invoice', - params: { - amount: msats, - description, - expiry - } - }, { logger }) + const nwc = await Nostr.nwc(nwcUrlRecv) + const { error, result } = await nwc.sendReq('make_invoice', { amount: msats, description, expiry }) + if (error) throw new Error(error.code + ' ' + error.message) return result.invoice } diff --git a/worker/nostr.js b/worker/nostr.js index 7dd932c95..4b0621c25 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,22 @@ 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() - } - }) - ) + await Nostr.publish({ + kind: 9735, + created_at: Math.floor(new Date(inv.confirmedAt).getTime() / 1000), + content: '', + tags + }, { + relays, + privKey: process.env.NOSTR_PRIVATE_KEY, + timeout: 1000 + }) } catch (e) { console.log(e) }