diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index 39a46d71..62312c24 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -32,7 +32,7 @@ function injectResolvers (resolvers) { return await upsertWallet({ wallet: { field: w.walletField, type: w.walletType }, - testConnectServer: (data) => w.testConnectServer(data, { me, models }) + testCreateInvoice: (data) => w.testCreateInvoice(data, { me, models }) }, { settings, data }, { me, models }) } } @@ -571,15 +571,15 @@ export const addWalletLog = async ({ wallet, level, message }, { models }) => { } async function upsertWallet ( - { wallet, testConnectServer }, { settings, data }, { me, models }) { + { wallet, testCreateInvoice }, { settings, data }, { me, models }) { if (!me) { throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) } assertApiKeyNotPermitted({ me }) - if (testConnectServer) { + if (testCreateInvoice) { try { - await testConnectServer(data) + await testCreateInvoice(data) } catch (err) { console.error(err) const message = 'failed to create test invoice: ' + (err.message || err.toString?.()) diff --git a/fragments/wallet.js b/fragments/wallet.js index be2d8a05..6f1ffec7 100644 --- a/fragments/wallet.js +++ b/fragments/wallet.js @@ -134,6 +134,9 @@ export const WALLET = gql` url invoiceKey } + ... on WalletNwc { + nwcUrlRecv + } } } } @@ -167,6 +170,9 @@ export const WALLET_BY_TYPE = gql` url invoiceKey } + ... on WalletNwc { + nwcUrlRecv + } } } } diff --git a/lib/nostr.js b/lib/nostr.js index 35a207fe..fb70c96e 100644 --- a/lib/nostr.js +++ b/lib/nostr.js @@ -2,6 +2,7 @@ import { bech32 } from 'bech32' import { nip19 } from 'nostr-tools' import WebSocket from 'isomorphic-ws' import { callWithTimeout, withTimeout } from '@/lib/time' +import crypto from 'crypto' export const NOSTR_PUBKEY_HEX = /^[0-9a-fA-F]{64}$/ export const NOSTR_PUBKEY_BECH32 = /^npub1[02-9ac-hj-np-z]+$/ @@ -29,11 +30,13 @@ export class Relay { } ws.onerror = function (err) { - console.error('websocket error: ' + err) - this.error = 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 } = {}) { @@ -83,8 +86,6 @@ export class Relay { let listener const ackPromise = new Promise((resolve, reject) => { - ws.send(JSON.stringify(['EVENT', event])) - listener = function onmessage (msg) { const [type, eventId, accepted, reason] = JSON.parse(msg.data) @@ -98,6 +99,8 @@ export class Relay { } ws.addEventListener('message', listener) + + ws.send(JSON.stringify(['EVENT', event])) }) try { @@ -112,17 +115,15 @@ export class Relay { let listener const ackPromise = new Promise((resolve, reject) => { - const id = crypto.randomUUID() - - ws.send(JSON.stringify(['REQ', id, ...filter])) + const id = crypto.randomBytes(16).toString('hex') const events = [] let eose = false listener = function onmessage (msg) { - const [type, eventId, event] = JSON.parse(msg.data) + const [type, subId, event] = JSON.parse(msg.data) - if (eventId !== id) return + if (subId !== id) return if (type === 'EVENT') { events.push(event) @@ -150,6 +151,8 @@ export class Relay { } ws.addEventListener('message', listener) + + ws.send(JSON.stringify(['REQ', id, ...filter])) }) try { diff --git a/lib/validate.js b/lib/validate.js index 6860fbf4..42dba1af 100644 --- a/lib/validate.js +++ b/lib/validate.js @@ -159,6 +159,33 @@ addMethod(string, 'hex', function (msg) { }) }) +addMethod(string, 'nwcUrl', function () { + return this.test({ + test: async (nwcUrl, context) => { + if (!nwcUrl) return true + + // run validation in sequence to control order of errors + // inspired by https://github.com/jquense/yup/issues/851#issuecomment-1049705180 + try { + await string().matches(/^nostr\+?walletconnect:\/\//, { message: 'must start with nostr+walletconnect://' }).validate(nwcUrl) + let relayUrl, walletPubkey, secret + try { + ({ relayUrl, 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') + } + await string().required('pubkey required').trim().matches(NOSTR_PUBKEY_HEX, 'pubkey must be 64 hex chars').validate(walletPubkey) + await string().required('relay url required').trim().wss('relay must use wss://').validate(relayUrl) + await string().required('secret required').trim().matches(/^[0-9a-fA-F]{64}$/, 'secret must be 64 hex chars').validate(secret) + } catch (err) { + return context.createError({ message: err.message }) + } + return true + } + }) +}) + const titleValidator = string().required('required').trim().max( MAX_TITLE_LENGTH, ({ max, value }) => `-${Math.abs(max - value.length)} characters remaining` @@ -687,31 +714,22 @@ export const lnbitsSchema = object().shape({ // see https://github.com/jquense/yup/issues/176#issuecomment-367352042 }, ['adminKey', 'invoiceKey']) -export const nwcSchema = object({ - nwcUrl: string() - .required('required') - .test(async (nwcUrl, context) => { - // run validation in sequence to control order of errors - // inspired by https://github.com/jquense/yup/issues/851#issuecomment-1049705180 - try { - await string().required('required').validate(nwcUrl) - await string().matches(/^nostr\+?walletconnect:\/\//, { message: 'must start with nostr+walletconnect://' }).validate(nwcUrl) - let relayUrl, walletPubkey, secret - try { - ({ relayUrl, 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') - } - await string().required('pubkey required').trim().matches(NOSTR_PUBKEY_HEX, 'pubkey must be 64 hex chars').validate(walletPubkey) - await string().required('relay url required').trim().wss('relay must use wss://').validate(relayUrl) - await string().required('secret required').trim().matches(/^[0-9a-fA-F]{64}$/, 'secret must be 64 hex chars').validate(secret) - } catch (err) { - return context.createError({ message: err.message }) - } - return true +export const nwcSchema = object().shape({ + nwcUrl: string().nwcUrl().when(['nwcUrlRecv'], ([nwcUrlRecv], schema) => { + if (!nwcUrlRecv) return schema.required('required if connection for receiving not set') + return schema.test({ + test: nwcUrl => nwcUrl !== nwcUrlRecv, + message: 'connection for sending cannot be the same as for receiving' }) -}) + }), + nwcUrlRecv: string().nwcUrl().when(['nwcUrl'], ([nwcUrl], schema) => { + if (!nwcUrl) return schema.required('required if connection for sending not set') + return schema.test({ + test: nwcUrlRecv => nwcUrlRecv !== nwcUrl, + message: 'connection for receiving cannot be the same as for sending' + }) + }) +}, ['nwcUrl', 'nwcUrlRecv']) export const blinkSchema = object({ apiKey: string() diff --git a/package-lock.json b/package-lock.json index 0a6358e9..e49e310b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -57,7 +57,7 @@ "node-s3-url-encode": "^0.0.4", "nodemailer": "^6.9.6", "nostr": "^0.2.8", - "nostr-tools": "^2.1.5", + "nostr-tools": "^2.7.2", "nprogress": "^0.2.0", "opentimestamps": "^0.4.9", "page-metadata-parser": "^1.1.4", @@ -3990,9 +3990,9 @@ } }, "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==", + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.5.3.tgz", + "integrity": "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==", "funding": { "url": "https://paulmillr.com/funding/" } @@ -14852,11 +14852,11 @@ } }, "node_modules/nostr-tools": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.1.5.tgz", - "integrity": "sha512-Gug/j54YGQ0ewB09dZW3mS9qfXWFlcOQMlyb1MmqQsuNO/95mfNOQSBi+jZ61O++Y+jG99SzAUPFLopUsKf0MA==", + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.7.2.tgz", + "integrity": "sha512-Bq3Ug0SZFtgtL1+0wCnAe8AJtI7yx/00/a2nUug9SkhfOwlKS92Tef12iCK9FdwXw+oFZWMtRnSwcLayQso+xA==", "dependencies": { - "@noble/ciphers": "0.2.0", + "@noble/ciphers": "^0.5.1", "@noble/curves": "1.2.0", "@noble/hashes": "1.3.1", "@scure/base": "1.1.1", diff --git a/package.json b/package.json index 282b0f00..e44ae38f 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,7 @@ "node-s3-url-encode": "^0.0.4", "nodemailer": "^6.9.6", "nostr": "^0.2.8", - "nostr-tools": "^2.1.5", + "nostr-tools": "^2.7.2", "nprogress": "^0.2.0", "opentimestamps": "^0.4.9", "page-metadata-parser": "^1.1.4", diff --git a/prisma/migrations/20240817072013_nwc_recv/migration.sql b/prisma/migrations/20240817072013_nwc_recv/migration.sql new file mode 100644 index 00000000..42076c2d --- /dev/null +++ b/prisma/migrations/20240817072013_nwc_recv/migration.sql @@ -0,0 +1,23 @@ +-- AlterEnum +ALTER TYPE "WalletType" ADD VALUE 'NWC'; + +-- CreateTable +CREATE TABLE "WalletNWC" ( + "int" SERIAL NOT NULL, + "walletId" INTEGER NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "nwcUrlRecv" TEXT NOT NULL, + + CONSTRAINT "WalletNWC_pkey" PRIMARY KEY ("int") +); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletNWC_walletId_key" ON "WalletNWC"("walletId"); + +-- AddForeignKey +ALTER TABLE "WalletNWC" ADD CONSTRAINT "WalletNWC_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "Wallet"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +CREATE TRIGGER wallet_nwc_as_jsonb +AFTER INSERT OR UPDATE ON "WalletNWC" +FOR EACH ROW EXECUTE PROCEDURE wallet_wallet_type_as_jsonb(); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 85356e22..47c2eb90 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -170,6 +170,7 @@ enum WalletType { LND CLN LNBITS + NWC } model Wallet { @@ -194,6 +195,7 @@ model Wallet { walletLND WalletLND? walletCLN WalletCLN? walletLNbits WalletLNbits? + walletNWC WalletNWC? withdrawals Withdrawl[] InvoiceForward InvoiceForward[] @@ -253,6 +255,15 @@ model WalletLNbits { invoiceKey String } +model WalletNWC { + int Int @id @default(autoincrement()) + walletId Int @unique + wallet Wallet @relation(fields: [walletId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + nwcUrlRecv String +} + model Mute { muterId Int mutedId Int diff --git a/wallets/README.md b/wallets/README.md index be6bc94c..185017f2 100644 --- a/wallets/README.md +++ b/wallets/README.md @@ -153,9 +153,9 @@ The badges that are shown inside the card. A wallet that supports paying invoices must export the following properties in client.js which are only available if this wallet is imported on the client: -- `testConnectClient: async (config, context) => Promise` +- `testSendPayment: async (config, context) => Promise` -`testConnectClient` will be called during submit on the client to validate the configuration (that is passed as the first argument) more thoroughly than the initial validation by `fieldValidation`. It contains validation code that should only be called during submits instead of possibly on every change like `fieldValidation`. +`testSendPayment` will be called during submit on the client to validate the configuration (that is passed as the first argument) more thoroughly than the initial validation by `fieldValidation`. It contains validation code that should only be called during submits instead of possibly on every change like `fieldValidation`. How this validation is implemented depends heavily on the wallet. For example, for NWC, this function attempts to fetch the info event from the relay specified in the connection string whereas for LNbits, it makes an HTTP request to /api/v1/wallet using the given URL and API key. @@ -167,7 +167,7 @@ The `context` argument is an object. It makes the wallet logger for this wallet `sendPayment` will be called if a payment is required. Therefore, this function should implement the code to pay invoices from this wallet. -The first argument is the [BOLT11 payment request](https://github.com/lightning/bolts/blob/master/11-payment-encoding.md). The `config` argument is the current configuration of this wallet (that was validated before). The `context` argument is the same as for `testConnectClient`. +The first argument is the [BOLT11 payment request](https://github.com/lightning/bolts/blob/master/11-payment-encoding.md). The `config` argument is the current configuration of this wallet (that was validated before). The `context` argument is the same as for `testSendPayment`. > [!IMPORTANT] > As mentioned above, this file must exist for every wallet and at least reexport everything in index.js so make sure that the following line is included: @@ -199,17 +199,17 @@ The first argument is the [BOLT11 payment request](https://github.com/lightning/ A wallet that supports receiving must export the following properties in server.js which are only available if this wallet is imported on the server: -- `testConnectServer: async (config, context) => Promise` +- `testCreateInvoice: async (config, context) => Promise` -`testConnectServer` is called on the server during submit and can thus use server dependencies like [`ln-service`](https://github.com/alexbosworth/ln-service). +`testCreateInvoice` is called on the server during submit and can thus use server dependencies like [`ln-service`](https://github.com/alexbosworth/ln-service). It should attempt to create a test invoice to make sure that this wallet can later create invoices for receiving. -Again, like `testConnectClient`, the first argument is the wallet configuration that we should validate and this should thrown an error if validation fails. However, unlike `testConnectClient`, the `context` argument here contains `me` (the user object) and `models` (the Prisma client). +Again, like `testSendPayment`, the first argument is the wallet configuration that we should validate and this should thrown an error if validation fails. However, unlike `testSendPayment`, the `context` argument here contains `me` (the user object) and `models` (the Prisma client). - `createInvoice: async (amount: int, config, context) => Promise` -`createInvoice` will be called whenever this wallet should receive a payment. It should return a BOLT11 payment request. The first argument `amount` specifies the amount in satoshis. The second argument `config` is the current configuration of this wallet. The third argument `context` is the same as in `testConnectServer` except it also includes `lnd` which is the return value of [`authenticatedLndGrpc`](https://github.com/alexbosworth/ln-service?tab=readme-ov-file#authenticatedlndgrpc) using the SN node credentials. +`createInvoice` will be called whenever this wallet should receive a payment. It should return a BOLT11 payment request. The first argument `amount` specifies the amount in satoshis. The second argument `config` is the current configuration of this wallet. The third argument `context` is the same as in `testCreateInvoice` except it also includes `lnd` which is the return value of [`authenticatedLndGrpc`](https://github.com/alexbosworth/ln-service?tab=readme-ov-file#authenticatedlndgrpc) using the SN node credentials. > [!IMPORTANT] diff --git a/wallets/blink/client.js b/wallets/blink/client.js index f46c1d42..360a824b 100644 --- a/wallets/blink/client.js +++ b/wallets/blink/client.js @@ -1,7 +1,7 @@ import { galoyBlinkUrl } from 'wallets/blink' export * from 'wallets/blink' -export async function testConnectClient ({ apiKey, currency }, { logger }) { +export async function testSendPayment ({ apiKey, currency }, { logger }) { currency = currency ? currency.toUpperCase() : 'BTC' logger.info('trying to fetch ' + currency + ' wallet') await getWallet(apiKey, currency) diff --git a/wallets/cln/server.js b/wallets/cln/server.js index e8779ef9..9371e5cf 100644 --- a/wallets/cln/server.js +++ b/wallets/cln/server.js @@ -2,7 +2,7 @@ import { createInvoice as clnCreateInvoice } from '@/lib/cln' export * from 'wallets/cln' -export const testConnectServer = async ({ socket, rune, cert }) => { +export const testCreateInvoice = async ({ socket, rune, cert }) => { return await createInvoice({ msats: 1000, expiry: 1, description: '' }, { socket, rune, cert }) } diff --git a/wallets/index.js b/wallets/index.js index 95bfc9c1..436fa490 100644 --- a/wallets/index.js +++ b/wallets/index.js @@ -79,17 +79,7 @@ export function useWallet (name) { }, [wallet, config, toaster]) const save = useCallback(async (newConfig) => { - // testConnectClient should log custom INFO and OK message - // testConnectClient is optional since validation might happen during save on server - // TODO: add timeout - let validConfig - try { - validConfig = await wallet.testConnectClient?.(newConfig, { me, logger }) - } catch (err) { - logger.error(err.message) - throw err - } - await saveConfig(validConfig ?? newConfig, { logger }) + await saveConfig(newConfig, { logger }) }, [saveConfig, me, logger]) // delete is a reserved keyword @@ -199,6 +189,17 @@ function useConfig (wallet) { } if (valid) { + try { + // XXX: testSendPayment can return a new config (e.g. lnc) + const newerConfig = await wallet.testSendPayment?.(newConfig, { me, logger }) + if (newerConfig) { + newClientConfig = newerConfig + } + } catch (err) { + logger.error(err.message) + throw err + } + setClientConfig(newClientConfig) logger.ok(wallet.isConfigured ? 'payment details updated' : 'wallet attached for payments') if (newConfig.enabled) wallet.enablePayments() @@ -235,10 +236,16 @@ function isConfigured ({ fields, config }) { if (!config || !fields) return false // a wallet is configured if all of its required fields are set - const val = fields.every(field => { - return field.optional ? true : !!config?.[field.name] + let val = fields.every(f => { + return f.optional ? true : !!config?.[f.name] }) + // however, a wallet is not configured if all fields are optional and none are set + // since that usually means that one of them is required + if (val && fields.length > 0) { + val = !(fields.every(f => f.optional) && fields.every(f => !config?.[f.name])) + } + return val } diff --git a/wallets/lightning-address/server.js b/wallets/lightning-address/server.js index cc5ccb37..3dd93121 100644 --- a/wallets/lightning-address/server.js +++ b/wallets/lightning-address/server.js @@ -3,7 +3,7 @@ import { lnAddrOptions } from '@/lib/lnurl' export * from 'wallets/lightning-address' -export const testConnectServer = async ({ address }) => { +export const testCreateInvoice = async ({ address }) => { return await createInvoice({ msats: 1000 }, { address }) } diff --git a/wallets/lnbits/client.js b/wallets/lnbits/client.js index 8b88f79a..507bb8ae 100644 --- a/wallets/lnbits/client.js +++ b/wallets/lnbits/client.js @@ -1,6 +1,6 @@ export * from 'wallets/lnbits' -export async function testConnectClient ({ url, adminKey, invoiceKey }, { logger }) { +export async function testSendPayment ({ url, adminKey, invoiceKey }, { logger }) { logger.info('trying to fetch wallet') url = url.replace(/\/+$/, '') diff --git a/wallets/lnbits/server.js b/wallets/lnbits/server.js index 37fc1806..97e376cc 100644 --- a/wallets/lnbits/server.js +++ b/wallets/lnbits/server.js @@ -2,7 +2,7 @@ import { msatsToSats } from '@/lib/format' export * from 'wallets/lnbits' -export async function testConnectServer ({ url, invoiceKey }) { +export async function testCreateInvoice ({ url, invoiceKey }) { return await createInvoice({ msats: 1000, expiry: 1 }, { url, invoiceKey }) } diff --git a/wallets/lnc/client.js b/wallets/lnc/client.js index 36dea17a..5e748325 100644 --- a/wallets/lnc/client.js +++ b/wallets/lnc/client.js @@ -30,7 +30,7 @@ async function disconnect (lnc, logger) { } } -export async function testConnectClient (credentials, { logger }) { +export async function testSendPayment (credentials, { logger }) { let lnc try { lnc = await getLNC(credentials) diff --git a/wallets/lnd/server.js b/wallets/lnd/server.js index 7f830029..5cdffb88 100644 --- a/wallets/lnd/server.js +++ b/wallets/lnd/server.js @@ -3,7 +3,7 @@ import { authenticatedLndGrpc, createInvoice as lndCreateInvoice } from 'ln-serv export * from 'wallets/lnd' -export const testConnectServer = async ({ cert, macaroon, socket }) => { +export const testCreateInvoice = async ({ cert, macaroon, socket }) => { return await createInvoice({ msats: 1000, expiry: 1 }, { cert, macaroon, socket }) } diff --git a/wallets/nwc/client.js b/wallets/nwc/client.js index b06021d2..19d8d996 100644 --- a/wallets/nwc/client.js +++ b/wallets/nwc/client.js @@ -1,74 +1,21 @@ -import { parseNwcUrl } from '@/lib/url' -import { finalizeEvent, nip04 } from 'nostr-tools' -import { Relay } from '@/lib/nostr' - +import { nwcCall, supportedMethods } from 'wallets/nwc' export * from 'wallets/nwc' -export async function testConnectClient ({ nwcUrl }, { logger }) { - const { relayUrl, walletPubkey } = parseNwcUrl(nwcUrl) +export async function testSendPayment ({ nwcUrl }, { logger }) { + const timeout = 15_000 - logger.info(`requesting info event from ${relayUrl}`) - - const relay = await Relay.connect(relayUrl) - logger.ok(`connected to ${relayUrl}`) - - try { - const [info] = await relay.fetch([{ - kinds: [13194], - authors: [walletPubkey] - }]) - - if (info) { - logger.ok(`received info event from ${relayUrl}`) - } else { - throw new Error('info event not found') - } - } finally { - relay?.close() - logger.info(`closed connection to ${relayUrl}`) + const supported = await supportedMethods(nwcUrl, { logger, timeout }) + if (!supported.includes('pay_invoice')) { + throw new Error('pay_invoice not supported') } } export async function sendPayment (bolt11, { nwcUrl }, { logger }) { - const { relayUrl, walletPubkey, secret } = parseNwcUrl(nwcUrl) - - const relay = await Relay.connect(relayUrl) - logger.ok(`connected to ${relayUrl}`) - - try { - const payload = { - method: 'pay_invoice', - params: { invoice: bolt11 } - } - 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) - await relay.publish(request) - - const [response] = await relay.fetch([{ - kinds: [23195], - authors: [walletPubkey], - '#e': [request.id] - }]) - - if (!response) { - throw new Error('no response') - } - - 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 { preimage: content.result.preimage } - - throw new Error('invalid response') - } finally { - relay?.close() - logger.info(`closed connection to ${relayUrl}`) - } + const result = await nwcCall({ + nwcUrl, + method: 'pay_invoice', + params: { invoice: bolt11 } + }, + { logger }) + return result.preimage } diff --git a/wallets/nwc/index.js b/wallets/nwc/index.js index c0bd8da0..254ac428 100644 --- a/wallets/nwc/index.js +++ b/wallets/nwc/index.js @@ -1,4 +1,7 @@ +import { Relay } from '@/lib/nostr' +import { parseNwcUrl } from '@/lib/url' import { nwcSchema } from '@/lib/validate' +import { finalizeEvent, nip04 } from 'nostr-tools' export const name = 'nwc' @@ -6,14 +9,80 @@ export const fields = [ { name: 'nwcUrl', label: 'connection', - type: 'password' + type: 'password', + optional: 'for sending', + clientOnly: true, + editable: false + }, + { + name: 'nwcUrlRecv', + label: 'connection', + type: 'password', + optional: 'for receiving', + serverOnly: true, + editable: false } ] export const card = { title: 'NWC', subtitle: 'use Nostr Wallet Connect for payments', - badges: ['send only', 'budgetable'] + badges: ['send & receive', 'budgetable'] } export const fieldValidation = nwcSchema + +export const walletType = 'NWC' + +export const walletField = 'walletNWC' + +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) + await relay.publish(request, { timeout }) + + logger?.info(`published ${method} request`) + + logger?.info('waiting for response ...') + const [response] = await relay.fetch([{ + kinds: [23195], + authors: [walletPubkey], + '#e': [request.id] + }], { timeout }) + + if (!response) { + throw new Error('no response') + } + + logger?.ok('response received') + + 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 response') + } 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 }) + return result.methods +} diff --git a/wallets/nwc/server.js b/wallets/nwc/server.js new file mode 100644 index 00000000..d20b01ff --- /dev/null +++ b/wallets/nwc/server.js @@ -0,0 +1,39 @@ +import { withTimeout } from '@/lib/time' +import { nwcCall, supportedMethods } from 'wallets/nwc' +export * from 'wallets/nwc' + +export async function testCreateInvoice ({ nwcUrlRecv }) { + const timeout = 15_000 + + const supported = await supportedMethods(nwcUrlRecv, { timeout }) + + const supports = (method) => supported.includes(method) + + if (!supports('make_invoice')) { + throw new Error('make_invoice not supported') + } + + const mustNotSupport = ['pay_invoice', 'multi_pay_invoice', 'pay_keysend', 'multi_pay_keysend'] + for (const method of mustNotSupport) { + if (supports(method)) { + throw new Error(`${method} must not be supported`) + } + } + + return await withTimeout(createInvoice({ msats: 1000, expiry: 1 }, { nwcUrlRecv }), timeout) +} + +export async function createInvoice ( + { msats, description, expiry }, + { nwcUrlRecv }) { + const result = await nwcCall({ + nwcUrl: nwcUrlRecv, + method: 'make_invoice', + params: { + amount: msats, + description, + expiry + } + }) + return result.invoice +} diff --git a/wallets/server.js b/wallets/server.js index 946631d3..d26e54d4 100644 --- a/wallets/server.js +++ b/wallets/server.js @@ -2,12 +2,13 @@ import * as lnd from 'wallets/lnd/server' import * as cln from 'wallets/cln/server' import * as lnAddr from 'wallets/lightning-address/server' import * as lnbits from 'wallets/lnbits/server' +import * as nwc from 'wallets/nwc/server' import { addWalletLog } from '@/api/resolvers/wallet' import walletDefs from 'wallets/server' import { parsePaymentRequest } from 'ln-service' import { toPositiveNumber } from '@/lib/validate' -export default [lnd, cln, lnAddr, lnbits] +export default [lnd, cln, lnAddr, lnbits, nwc] export async function createInvoice (userId, { msats, description, descriptionHash, expiry = 360 }, { models }) { // get the wallets in order of priority