From 034cb4e8b25aeaa5883b4843b2cfb4016c750ff7 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Thu, 20 Jun 2024 21:52:07 +0200 Subject: [PATCH] Add NWC wallet --- components/wallet/index.js | 6 +- components/wallet/lnbits.js | 1 + components/wallet/nwc.js | 161 +++++++++++++++++++++++++++++ lib/validate.js | 27 ----- pages/settings/wallets/[wallet].js | 2 +- 5 files changed, 167 insertions(+), 30 deletions(-) create mode 100644 components/wallet/nwc.js diff --git a/components/wallet/index.js b/components/wallet/index.js index ad748c59..bcdddb6c 100644 --- a/components/wallet/index.js +++ b/components/wallet/index.js @@ -7,7 +7,8 @@ import { bolt11Tags } from '@/lib/bolt11' // wallet definitions export const WALLET_DEFS = [ - await import('@/components/wallet/lnbits') + await import('@/components/wallet/lnbits'), + await import('@/components/wallet/nwc') ] export const Status = { @@ -29,7 +30,7 @@ export function useWallet (name) { const hash = bolt11Tags(bolt11).payment_hash logger.info('sending payment:', `payment_hash=${hash}`) try { - const { preimage } = await wallet.sendPayment({ bolt11, config }) + const { preimage } = await wallet.sendPayment({ bolt11, config, logger }) logger.ok('payment successful:', `payment_hash=${hash}`, `preimage=${preimage}`) } catch (err) { const message = err.message || err.toString?.() @@ -41,6 +42,7 @@ export function useWallet (name) { const validate = useCallback(async (values) => { try { // validate should log custom INFO and OK message + // TODO: add timeout return await wallet.validate({ logger, ...values }) } catch (err) { const message = err.message || err.toString?.() diff --git a/components/wallet/lnbits.js b/components/wallet/lnbits.js index c6349d57..b5bf0714 100644 --- a/components/wallet/lnbits.js +++ b/components/wallet/lnbits.js @@ -18,6 +18,7 @@ export const fields = [ export const card = { title: 'LNbits', + subtitle: 'use LNbits for payments', badges: ['send only', 'non-custodialish'] } diff --git a/components/wallet/nwc.js b/components/wallet/nwc.js new file mode 100644 index 00000000..440c7289 --- /dev/null +++ b/components/wallet/nwc.js @@ -0,0 +1,161 @@ +import { NOSTR_PUBKEY_HEX } from '@/lib/nostr' +import { parseNwcUrl } from '@/lib/url' +import { Relay, finalizeEvent, nip04 } from 'nostr-tools' +import { object, string } from 'yup' + +export const name = 'nwc' + +export const fields = [ + { + name: 'nwcUrl', + label: 'connection', + type: 'password' + } +] + +export const card = { + title: 'NWC', + subtitle: 'use Nostr Wallet Connect for payments', + badges: ['send only', 'non-custodialish'] +} + +export const schema = 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 async function validate ({ logger, nwcUrl }) { + const { relayUrl, walletPubkey } = parseNwcUrl(nwcUrl) + + logger.info(`requesting info event from ${relayUrl}`) + const relay = await Relay + .connect(relayUrl) + .catch(() => { + // NOTE: passed error is undefined for some reason + const msg = `failed to connect to ${relayUrl}` + logger.error(msg) + throw new Error(msg) + }) + logger.ok(`connected to ${relayUrl}`) + + try { + return await new Promise((resolve, reject) => { + let found = false + const sub = relay.subscribe([ + { + kinds: [13194], + authors: [walletPubkey] + } + ], { + onevent (event) { + found = true + logger.ok(`received info event from ${relayUrl}`) + resolve(event) + }, + onclose (reason) { + if (!['closed by caller', 'relay connection closed by us'].includes(reason)) { + // only log if not closed by us (caller) + const msg = 'connection closed: ' + (reason || 'unknown reason') + logger.error(msg) + reject(new Error(msg)) + } + }, + oneose () { + if (!found) { + const msg = 'EOSE received without info event' + logger.error(msg) + reject(new Error(msg)) + } + sub?.close() + } + }) + }) + } finally { + // For some reason, websocket is already in CLOSING or CLOSED state. + // relay?.close() + if (relay) logger.info(`closed connection to ${relayUrl}`) + } +} + +export async function sendPayment ({ bolt11, config, logger }) { + const { relayUrl, walletPubkey, secret } = parseNwcUrl(config.nwcUrl) + + const relay = await Relay.connect(relayUrl).catch(() => { + // NOTE: passed error is undefined for some reason + const msg = `failed to connect to ${relayUrl}` + logger.error(msg) + throw new Error(msg) + }) + logger.ok(`connected to ${relayUrl}`) + + try { + const ret = await new Promise(function (resolve, reject) { + (async function () { + const payload = { + method: 'pay_invoice', + params: { invoice: bolt11 } + } + const content = await nip04.encrypt(secret, walletPubkey, JSON.stringify(payload)) + + const request = finalizeEvent({ + kind: 23194, + created_at: Math.floor(Date.now() / 1000), + tags: [['p', walletPubkey]], + content + }, secret) + await relay.publish(request) + + const filter = { + kinds: [23195], + authors: [walletPubkey], + '#e': [request.id] + } + relay.subscribe([filter], { + async onevent (response) { + try { + const content = JSON.parse(await nip04.decrypt(secret, walletPubkey, response.content)) + if (content.error) return reject(new Error(content.error.message)) + if (content.result) return resolve({ preimage: content.result.preimage }) + } catch (err) { + return reject(err) + } + }, + onclose (reason) { + if (!['closed by caller', 'relay connection closed by us'].includes(reason)) { + // only log if not closed by us (caller) + const msg = 'connection closed: ' + (reason || 'unknown reason') + logger.error(msg) + reject(new Error(msg)) + } + } + }) + })().catch(reject) + }) + return ret + } finally { + // For some reason, websocket is already in CLOSING or CLOSED state. + // relay?.close() + if (relay) logger.info(`closed connection to ${relayUrl}`) + } +} diff --git a/lib/validate.js b/lib/validate.js index c84fb48b..7cb7a221 100644 --- a/lib/validate.js +++ b/lib/validate.js @@ -10,7 +10,6 @@ import { msatsToSats, numWithUnits, abbrNum, ensureB64, B64_URL_REGEX } from './ import * as usersFragments from '@/fragments/users' import * as subsFragments from '@/fragments/subs' import { isInvoicableMacaroon, isInvoiceMacaroon } from './macaroon' -import { parseNwcUrl } from './url' import { datePivot } from './time' import { decodeRune } from '@/lib/cln' import bip39Words from './bip39-words' @@ -600,32 +599,6 @@ export const lnAddrSchema = ({ payerData, min, max, commentAllowed } = {}) => return accum }, {}))) -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 lncSchema = object({ pairingPhrase: array() .transform(function (value, originalValue) { diff --git a/pages/settings/wallets/[wallet].js b/pages/settings/wallets/[wallet].js index c8b95069..f44a5061 100644 --- a/pages/settings/wallets/[wallet].js +++ b/pages/settings/wallets/[wallet].js @@ -28,7 +28,7 @@ export default function WalletSettings () { return (

{wallet.card.title}

-
use {wallet.card.title} for payments
+
{wallet.card.subtitle}