diff --git a/.env.development b/.env.development index 4aa4763f..b52e346e 100644 --- a/.env.development +++ b/.env.development @@ -1,5 +1,6 @@ PRISMA_SLOW_LOGS_MS= GRAPHQL_SLOW_LOGS_MS= +NODE_ENV=development ############################################################################ # OPTIONAL SECRETS # diff --git a/.gitignore b/.gitignore index 3344814f..b0e257af 100644 --- a/.gitignore +++ b/.gitignore @@ -20,7 +20,6 @@ node_modules/ .DS_Store *.pem /*.sql -lnbits/ # debug npm-debug.log* diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index c3485efd..5a5c9600 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -1,19 +1,45 @@ -import { getIdentity, createHodlInvoice, createInvoice, decodePaymentRequest, payViaPaymentRequest, cancelHodlInvoice, getInvoice as getInvoiceFromLnd, getNode, authenticatedLndGrpc, deletePayment, getPayment } from 'ln-service' +import { createHodlInvoice, createInvoice, decodePaymentRequest, payViaPaymentRequest, cancelHodlInvoice, getInvoice as getInvoiceFromLnd, getNode, deletePayment, getPayment, getIdentity } from 'ln-service' import { GraphQLError } from 'graphql' import crypto, { timingSafeEqual } from 'crypto' import serialize from './serial' import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor' import { SELECT, itemQueryWithMeta } from './item' -import { lnAddrOptions } from '@/lib/lnurl' -import { msatsToSats, msatsToSatsDecimal, ensureB64 } from '@/lib/format' -import { CLNAutowithdrawSchema, LNDAutowithdrawSchema, amountSchema, lnAddrAutowithdrawSchema, lnAddrSchema, ssValidate, withdrawlSchema } from '@/lib/validate' -import { ANON_BALANCE_LIMIT_MSATS, ANON_INV_PENDING_LIMIT, USER_ID, BALANCE_LIMIT_MSATS, INVOICE_RETENTION_DAYS, INV_PENDING_LIMIT, USER_IDS_BALANCE_NO_LIMIT, Wallet } from '@/lib/constants' +import { msatsToSats, msatsToSatsDecimal } from '@/lib/format' +import { amountSchema, ssValidate, withdrawlSchema, lnAddrSchema, formikValidate } from '@/lib/validate' +import { ANON_BALANCE_LIMIT_MSATS, ANON_INV_PENDING_LIMIT, USER_ID, BALANCE_LIMIT_MSATS, INVOICE_RETENTION_DAYS, INV_PENDING_LIMIT, USER_IDS_BALANCE_NO_LIMIT } from '@/lib/constants' import { datePivot } from '@/lib/time' import assertGofacYourself from './ofac' import assertApiKeyNotPermitted from './apiKey' -import { createInvoice as createInvoiceCLN } from '@/lib/cln' import { bolt11Tags } from '@/lib/bolt11' import { checkInvoice } from 'worker/wallet' +import walletDefs from 'wallets/server' +import { generateResolverName } from '@/lib/wallet' +import { lnAddrOptions } from '@/lib/lnurl' + +function injectResolvers (resolvers) { + console.group('injected GraphQL resolvers:') + for (const w of walletDefs) { + const { fieldValidation, walletType, walletField, testConnectServer } = w + const resolverName = generateResolverName(walletField) + console.log(resolverName) + + // check if wallet uses the form-level validation built into Formik or a Yup schema + const validateArgs = typeof fieldValidation === 'function' + ? { formikValidate: fieldValidation } + : { schema: fieldValidation } + + resolvers.Mutation[resolverName] = async (parent, { settings, ...data }, { me, models }) => { + return await upsertWallet({ + ...validateArgs, + wallet: { field: walletField, type: walletType }, + testConnectServer: (data) => testConnectServer(data, { me, models }) + }, { settings, data }, { me, models }) + } + } + console.groupEnd() + + return resolvers +} export async function getInvoice (parent, { id }, { me, models, lnd }) { const inv = await models.invoice.findUnique({ @@ -93,7 +119,7 @@ export function createHmac (hash) { return crypto.createHmac('sha256', key).update(Buffer.from(hash, 'hex')).digest('hex') } -export default { +const resolvers = { Query: { invoice: getInvoice, wallet: async (parent, { id }, { me, models }) => { @@ -318,9 +344,10 @@ export default { where: { userId: me.id }, - orderBy: { - createdAt: 'asc' - } + orderBy: [ + { createdAt: 'desc' }, + { id: 'desc' } + ] }) } }, @@ -423,85 +450,6 @@ export default { } return { id } }, - upsertWalletLND: async (parent, { settings, ...data }, { me, models }) => { - // make sure inputs are base64 - data.macaroon = ensureB64(data.macaroon) - data.cert = ensureB64(data.cert) - - const wallet = Wallet.LND - return await upsertWallet( - { - schema: LNDAutowithdrawSchema, - wallet, - testConnect: async ({ cert, macaroon, socket }) => { - try { - const { lnd } = await authenticatedLndGrpc({ - cert, - macaroon, - socket - }) - const inv = await createInvoice({ - description: 'SN connection test', - lnd, - tokens: 0, - expires_at: new Date() - }) - // we wrap both calls in one try/catch since connection attempts happen on RPC calls - await addWalletLog({ wallet, level: 'SUCCESS', message: 'connected to LND' }, { me, models }) - return inv - } catch (err) { - // LND errors are in this shape: [code, type, { err: { code, details, metadata } }] - const details = err[2]?.err?.details || err.message || err.toString?.() - await addWalletLog({ wallet, level: 'ERROR', message: `could not connect to LND: ${details}` }, { me, models }) - throw err - } - } - }, - { settings, data }, { me, models }) - }, - upsertWalletCLN: async (parent, { settings, ...data }, { me, models }) => { - data.cert = ensureB64(data.cert) - - const wallet = Wallet.CLN - return await upsertWallet( - { - schema: CLNAutowithdrawSchema, - wallet, - testConnect: async ({ socket, rune, cert }) => { - try { - const inv = await createInvoiceCLN({ - socket, - rune, - cert, - description: 'SN connection test', - msats: 'any', - expiry: 0 - }) - await addWalletLog({ wallet, level: 'SUCCESS', message: 'connected to CLN' }, { me, models }) - return inv - } catch (err) { - const details = err.details || err.message || err.toString?.() - await addWalletLog({ wallet, level: 'ERROR', message: `could not connect to CLN: ${details}` }, { me, models }) - throw err - } - } - }, - { settings, data }, { me, models }) - }, - upsertWalletLNAddr: async (parent, { settings, ...data }, { me, models }) => { - const wallet = Wallet.LnAddr - return await upsertWallet( - { - schema: lnAddrAutowithdrawSchema, - wallet, - testConnect: async ({ address }) => { - const options = await lnAddrOptions(address) - await addWalletLog({ wallet, level: 'SUCCESS', message: 'fetched payment details' }, { me, models }) - return options - } - }, - { settings, data }, { me, models }) - }, removeWallet: async (parent, { id }, { me, models }) => { if (!me) { throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) @@ -514,7 +462,7 @@ export default { await models.$transaction([ models.wallet.delete({ where: { userId: me.id, id: Number(id) } }), - models.walletLog.create({ data: { userId: me.id, wallet: wallet.type, level: 'SUCCESS', message: 'wallet deleted' } }) + models.walletLog.create({ data: { userId: me.id, wallet: wallet.type, level: 'SUCCESS', message: 'wallet detached' } }) ]) return true @@ -598,6 +546,8 @@ export default { } } +export default injectResolvers(resolvers) + export const addWalletLog = async ({ wallet, level, message }, { me, models }) => { try { await models.walletLog.create({ data: { userId: me.id, wallet: wallet.type, level, message } }) @@ -607,26 +557,32 @@ export const addWalletLog = async ({ wallet, level, message }, { me, models }) = } async function upsertWallet ( - { schema, wallet, testConnect }, { settings, data }, { me, models }) { + { schema, formikValidate: validate, wallet, testConnectServer }, { settings, data }, { me, models }) { if (!me) { throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) } assertApiKeyNotPermitted({ me }) - await ssValidate(schema, { ...data, ...settings }, { me, models }) + if (schema) { + await ssValidate(schema, { ...data, ...settings }, { me, models }) + } + if (validate) { + await formikValidate(validate, { ...data, ...settings }) + } - if (testConnect) { + if (testConnectServer) { try { - await testConnect(data) + await testConnectServer(data) } catch (err) { console.error(err) - await addWalletLog({ wallet, level: 'ERROR', message: 'failed to attach wallet' }, { me, models }) - throw new GraphQLError('failed to connect to wallet', { extensions: { code: 'BAD_INPUT' } }) + const message = err.message || err.toString?.() + await addWalletLog({ wallet, level: 'ERROR', message: 'failed to attach: ' + message }, { me, models }) + throw new GraphQLError(message, { extensions: { code: 'BAD_INPUT' } }) } } const { id, ...walletData } = data - const { autoWithdrawThreshold, autoWithdrawMaxFeePercent, priority } = settings + const { autoWithdrawThreshold, autoWithdrawMaxFeePercent, enabled, priority } = settings const txs = [ models.user.update({ @@ -638,24 +594,13 @@ async function upsertWallet ( }) ] - if (priority) { - txs.push( - models.wallet.updateMany({ - where: { - userId: me.id - }, - data: { - priority: 0 - } - })) - } - if (id) { txs.push( models.wallet.update({ where: { id: Number(id), userId: me.id }, data: { - priority: priority ? 1 : 0, + enabled, + priority, [wallet.field]: { update: { where: { walletId: Number(id) }, @@ -663,25 +608,43 @@ async function upsertWallet ( } } } - }), - models.walletLog.create({ data: { userId: me.id, wallet: wallet.type, level: 'SUCCESS', message: 'wallet updated' } }) + }) ) } else { txs.push( models.wallet.create({ data: { - priority: Number(priority), + enabled, + priority, userId: me.id, type: wallet.type, [wallet.field]: { create: walletData } } - }), - models.walletLog.create({ data: { userId: me.id, wallet: wallet.type, level: 'SUCCESS', message: 'wallet created' } }) + }) ) } + txs.push( + models.walletLog.createMany({ + data: { + userId: me.id, + wallet: wallet.type, + level: 'SUCCESS', + message: id ? 'wallet updated' : 'wallet attached' + } + }), + models.walletLog.create({ + data: { + userId: me.id, + wallet: wallet.type, + level: enabled ? 'SUCCESS' : 'INFO', + message: enabled ? 'wallet enabled' : 'wallet disabled' + } + }) + ) + await models.$transaction(txs) return true } @@ -745,12 +708,28 @@ export async function createWithdrawal (parent, { invoice, maxFee }, { me, model } export async function sendToLnAddr (parent, { addr, amount, maxFee, comment, ...payer }, - { me, models, lnd, headers, walletId }) { + { me, models, lnd, headers }) { if (!me) { throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } }) } assertApiKeyNotPermitted({ me }) + const res = await fetchLnAddrInvoice({ addr, amount, maxFee, comment, ...payer }, + { + me, + models, + lnd + }) + + // take pr and createWithdrawl + return await createWithdrawal(parent, { invoice: res.pr, maxFee }, { me, models, lnd, headers }) +} + +export async function fetchLnAddrInvoice ( + { addr, amount, maxFee, comment, ...payer }, + { + me, models, lnd, autoWithdraw = false + }) { const options = await lnAddrOptions(addr) await ssValidate(lnAddrSchema, { addr, amount, maxFee, comment, ...payer }, options) @@ -788,10 +767,10 @@ export async function sendToLnAddr (parent, { addr, amount, maxFee, comment, ... try { const decoded = await decodePaymentRequest({ lnd, request: res.pr }) const ourPubkey = (await getIdentity({ lnd })).public_key - if (walletId && decoded.destination === ourPubkey && process.env.NODE_ENV === 'production') { + if (autoWithdraw && decoded.destination === ourPubkey && process.env.NODE_ENV === 'production') { // unset lnaddr so we don't trigger another withdrawal with same destination await models.wallet.deleteMany({ - where: { userId: me.id, type: Wallet.LnAddr.type } + where: { userId: me.id, type: 'LIGHTNING_ADDRESS' } }) throw new Error('automated withdrawals to other stackers are not allowed') } @@ -803,6 +782,5 @@ export async function sendToLnAddr (parent, { addr, amount, maxFee, comment, ... throw e } - // take pr and createWithdrawl - return await createWithdrawal(parent, { invoice: res.pr, maxFee }, { me, models, lnd, headers, walletId }) + return res } diff --git a/api/typeDefs/wallet.js b/api/typeDefs/wallet.js index faedb976..b8e9935a 100644 --- a/api/typeDefs/wallet.js +++ b/api/typeDefs/wallet.js @@ -1,6 +1,32 @@ import { gql } from 'graphql-tag' +import { generateResolverName } from '@/lib/wallet' -export default gql` +import walletDefs from 'wallets/server' + +function injectTypeDefs (typeDefs) { + console.group('injected GraphQL type defs:') + const injected = walletDefs.map( + (w) => { + let args = 'id: ID, ' + args += w.fields.map(f => { + let arg = `${f.name}: String` + if (!f.optional) { + arg += '!' + } + return arg + }).join(', ') + args += ', settings: AutowithdrawSettings!' + const resolverName = generateResolverName(w.walletField) + const typeDef = `${resolverName}(${args}): Boolean` + console.log(typeDef) + return typeDef + }) + console.groupEnd() + + return `${typeDefs}\n\nextend type Mutation {\n${injected.join('\n')}\n}` +} + +const typeDefs = ` extend type Query { invoice(id: ID!): Invoice! withdrawl(id: ID!): Withdrawl! @@ -19,9 +45,6 @@ export default gql` sendToLnAddr(addr: String!, amount: Int!, maxFee: Int!, comment: String, identifier: Boolean, name: String, email: String): Withdrawl! cancelInvoice(hash: String!, hmac: String!): Invoice! dropBolt11(id: ID): Withdrawl - upsertWalletLND(id: ID, socket: String!, macaroon: String!, cert: String, settings: AutowithdrawSettings!): Boolean - upsertWalletCLN(id: ID, socket: String!, rune: String!, cert: String, settings: AutowithdrawSettings!): Boolean - upsertWalletLNAddr(id: ID, address: String!, settings: AutowithdrawSettings!): Boolean removeWallet(id: ID!): Boolean deleteWalletLogs(wallet: String): Boolean } @@ -30,7 +53,8 @@ export default gql` id: ID! createdAt: Date! type: String! - priority: Boolean! + enabled: Boolean! + priority: Int! wallet: WalletDetails! } @@ -55,7 +79,8 @@ export default gql` input AutowithdrawSettings { autoWithdrawThreshold: Int! autoWithdrawMaxFeePercent: Float! - priority: Boolean! + priority: Int + enabled: Boolean } type Invoice { @@ -123,3 +148,5 @@ export default gql` message: String! } ` + +export default gql`${injectTypeDefs(typeDefs)}` diff --git a/components/autowithdraw-shared.js b/components/autowithdraw-shared.js index 7a0f6416..e5f28244 100644 --- a/components/autowithdraw-shared.js +++ b/components/autowithdraw-shared.js @@ -8,15 +8,14 @@ function autoWithdrawThreshold ({ me }) { return isNumber(me?.privates?.autoWithdrawThreshold) ? me?.privates?.autoWithdrawThreshold : 10000 } -export function autowithdrawInitial ({ me, priority = false }) { +export function autowithdrawInitial ({ me }) { return { - priority, autoWithdrawThreshold: autoWithdrawThreshold({ me }), autoWithdrawMaxFeePercent: isNumber(me?.privates?.autoWithdrawMaxFeePercent) ? me?.privates?.autoWithdrawMaxFeePercent : 1 } } -export function AutowithdrawSettings ({ priority }) { +export function AutowithdrawSettings ({ wallet }) { const me = useMe() const threshold = autoWithdrawThreshold({ me }) @@ -29,9 +28,10 @@ export function AutowithdrawSettings ({ priority }) { return ( <>
@@ -46,12 +46,14 @@ export function AutowithdrawSettings ({ priority }) { }} hint={isNumber(sendThreshold) ? `will attempt auto-withdraw when your balance exceeds ${sendThreshold * 11} sats` : undefined} append={sats} + required /> %} + required />
diff --git a/components/form.js b/components/form.js index de6ac99e..46c0c430 100644 --- a/components/form.js +++ b/components/form.js @@ -802,7 +802,7 @@ export function CheckboxGroup ({ label, groupClassName, children, ...props }) { const StorageKeyPrefixContext = createContext() export function Form ({ - initial, schema, onSubmit, children, initialError, validateImmediately, + initial, validate, schema, onSubmit, children, initialError, validateImmediately, storageKeyPrefix, validateOnChange = true, requireSession, innerRef, ...props }) { @@ -856,6 +856,7 @@ export function Form ({ - {webLnError && !(webLnError instanceof WebLnNotEnabledError) && + {walletError && !(walletError instanceof NoAttachedWalletError) &&
Paying from attached wallet failed: - {webLnError.message} + {walletError.message}
} diff --git a/components/logger.js b/components/logger.js index 28092de8..45ac1607 100644 --- a/components/logger.js +++ b/components/logger.js @@ -1,9 +1,6 @@ -import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' import { useMe } from './me' import fancyNames from '@/lib/fancy-names.json' -import { gql, useMutation, useQuery } from '@apollo/client' -import { WALLET_LOGS } from '@/fragments/wallet' -import { getWalletBy } from '@/lib/constants' const generateFancyName = () => { // 100 adjectives * 100 nouns * 10000 = 100M possible names @@ -44,9 +41,7 @@ export const LoggerContext = createContext() export const LoggerProvider = ({ children }) => { return ( - - {children} - + {children} ) } @@ -122,189 +117,3 @@ function ServiceWorkerLoggerProvider ({ children }) { export function useServiceWorkerLogger () { return useContext(ServiceWorkerLoggerContext) } - -const WalletLoggerContext = createContext() -const WalletLogsContext = createContext() - -const initIndexedDB = async (dbName, storeName) => { - return new Promise((resolve, reject) => { - if (!window.indexedDB) { - return reject(new Error('IndexedDB not supported')) - } - - // https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API/Using_IndexedDB - const request = window.indexedDB.open(dbName, 1) - - let db - request.onupgradeneeded = () => { - // this only runs if version was changed during open - db = request.result - if (!db.objectStoreNames.contains(storeName)) { - const objectStore = db.createObjectStore(storeName, { autoIncrement: true }) - objectStore.createIndex('ts', 'ts') - objectStore.createIndex('wallet_ts', ['wallet', 'ts']) - } - } - - request.onsuccess = () => { - // this gets called after onupgradeneeded finished - db = request.result - resolve(db) - } - - request.onerror = () => { - reject(new Error('failed to open IndexedDB')) - } - }) -} - -const WalletLoggerProvider = ({ children }) => { - const me = useMe() - const [logs, setLogs] = useState([]) - let dbName = 'app:storage' - if (me) { - dbName = `${dbName}:${me.id}` - } - const idbStoreName = 'wallet_logs' - const idb = useRef() - const logQueue = useRef([]) - - useQuery(WALLET_LOGS, { - fetchPolicy: 'network-only', - // required to trigger onCompleted on refetches - notifyOnNetworkStatusChange: true, - onCompleted: ({ walletLogs }) => { - setLogs((prevLogs) => { - const existingIds = prevLogs.map(({ id }) => id) - const logs = walletLogs - .filter(({ id }) => !existingIds.includes(id)) - .map(({ createdAt, wallet: walletType, ...log }) => { - return { - ts: +new Date(createdAt), - wallet: getWalletBy('type', walletType).logTag, - ...log - } - }) - return [...prevLogs, ...logs].sort((a, b) => a.ts - b.ts) - }) - } - }) - - const [deleteServerWalletLogs] = useMutation( - gql` - mutation deleteWalletLogs($wallet: String) { - deleteWalletLogs(wallet: $wallet) - } - `, - { - onCompleted: (_, { variables: { wallet: walletType } }) => { - setLogs((logs) => { - return logs.filter(l => walletType ? l.wallet !== getWalletBy('type', walletType).logTag : false) - }) - } - } - ) - - const saveLog = useCallback((log) => { - if (!idb.current) { - // IDB may not be ready yet - return logQueue.current.push(log) - } - const tx = idb.current.transaction(idbStoreName, 'readwrite') - const request = tx.objectStore(idbStoreName).add(log) - request.onerror = () => console.error('failed to save log:', log) - }, []) - - useEffect(() => { - initIndexedDB(dbName, idbStoreName) - .then(db => { - idb.current = db - - // load all logs from IDB - const tx = idb.current.transaction(idbStoreName, 'readonly') - const store = tx.objectStore(idbStoreName) - const index = store.index('ts') - const request = index.getAll() - request.onsuccess = () => { - const logs = request.result - setLogs((prevLogs) => { - // sort oldest first to keep same order as logs are appended - return [...prevLogs, ...logs].sort((a, b) => a.ts - b.ts) - }) - } - - // flush queued logs to IDB - logQueue.current.forEach(q => { - const isLog = !!q.wallet - if (isLog) saveLog(q) - }) - - logQueue.current = [] - }) - .catch(console.error) - return () => idb.current?.close() - }, []) - - const appendLog = useCallback((wallet, level, message) => { - const log = { wallet: wallet.logTag, level, message, ts: +new Date() } - saveLog(log) - setLogs((prevLogs) => [...prevLogs, log]) - }, [saveLog]) - - const deleteLogs = useCallback(async (wallet) => { - if (!wallet || wallet.server) { - await deleteServerWalletLogs({ variables: { wallet: wallet?.type } }) - } - if (!wallet || !wallet.server) { - const tx = idb.current.transaction(idbStoreName, 'readwrite') - const objectStore = tx.objectStore(idbStoreName) - const idx = objectStore.index('wallet_ts') - const request = wallet ? idx.openCursor(window.IDBKeyRange.bound([wallet.logTag, -Infinity], [wallet.logTag, Infinity])) : idx.openCursor() - request.onsuccess = function (event) { - const cursor = event.target.result - if (cursor) { - cursor.delete() - cursor.continue() - } else { - // finished - setLogs((logs) => logs.filter(l => wallet ? l.wallet !== wallet.logTag : false)) - } - } - } - }, [setLogs]) - - return ( - - - {children} - - - ) -} - -export function useWalletLogger (wallet) { - const { appendLog, deleteLogs: innerDeleteLogs } = useContext(WalletLoggerContext) - - const log = useCallback(level => message => { - // TODO: - // also send this to us if diagnostics was enabled, - // very similar to how the service worker logger works. - appendLog(wallet, level, message) - console[level !== 'error' ? 'info' : 'error'](`[${wallet.logTag}]`, message) - }, [appendLog, wallet]) - - const logger = useMemo(() => ({ - ok: (...message) => log('ok')(message.join(' ')), - info: (...message) => log('info')(message.join(' ')), - error: (...message) => log('error')(message.join(' ')) - }), [log, wallet]) - - const deleteLogs = useCallback((w) => innerDeleteLogs(w || wallet), [innerDeleteLogs, wallet]) - - return { logger, deleteLogs } -} - -export function useWalletLogs (wallet) { - const logs = useContext(WalletLogsContext) - return logs.filter(l => !wallet || l.wallet === wallet.logTag) -} diff --git a/components/nav/common.js b/components/nav/common.js index 5116658d..6f109082 100644 --- a/components/nav/common.js +++ b/components/nav/common.js @@ -6,7 +6,7 @@ import BackArrow from '../../svgs/arrow-left-line.svg' import { useCallback, useEffect, useState } from 'react' import Price from '../price' import SubSelect from '../sub-select' -import { USER_ID, BALANCE_LIMIT_MSATS, Wallet } from '../../lib/constants' +import { USER_ID, BALANCE_LIMIT_MSATS } from '../../lib/constants' import Head from 'next/head' import NoteIcon from '../../svgs/notification-4-fill.svg' import { useMe } from '../me' @@ -22,8 +22,7 @@ import SearchIcon from '../../svgs/search-line.svg' import classNames from 'classnames' import SnIcon from '@/svgs/sn.svg' import { useHasNewNotes } from '../use-has-new-notes' -import { useWalletLogger } from '../logger' -import { useWebLNConfigurator } from '../webln' +import { useWallets } from 'wallets' export function Brand ({ className }) { return ( @@ -257,8 +256,7 @@ export default function LoginButton ({ className }) { export function LogoutDropdownItem () { const { registration: swRegistration, togglePushSubscription } = useServiceWorker() - const webLN = useWebLNConfigurator() - const { deleteLogs } = useWalletLogger() + const wallets = useWallets() return ( { @@ -267,12 +265,9 @@ export function LogoutDropdownItem () { if (pushSubscription) { await togglePushSubscription().catch(console.error) } - // detach wallets - await webLN.clearConfig().catch(console.error) - // delete client wallet logs to prevent leak of private data if a shared device was used - await deleteLogs(Wallet.NWC).catch(console.error) - await deleteLogs(Wallet.LNbits).catch(console.error) - await deleteLogs(Wallet.LNC).catch(console.error) + + await wallets.resetClient().catch(console.error) + await signOut({ callbackUrl: '/' }) }} >logout diff --git a/components/payment.js b/components/payment.js index 2a2262d4..b156c357 100644 --- a/components/payment.js +++ b/components/payment.js @@ -1,7 +1,7 @@ import { useCallback, useMemo } from 'react' import { useMe } from './me' import { gql, useApolloClient, useMutation } from '@apollo/client' -import { useWebLN } from './webln' +import { useWallet } from 'wallets' import { FAST_POLL_INTERVAL, JIT_INVOICE_TIMEOUT_MS } from '@/lib/constants' import { INVOICE } from '@/fragments/wallet' import Invoice from '@/components/invoice' @@ -17,10 +17,10 @@ export class InvoiceCanceledError extends Error { } } -export class WebLnNotEnabledError extends Error { +export class NoAttachedWalletError extends Error { constructor () { - super('no enabled WebLN provider found') - this.name = 'WebLnNotEnabledError' + super('no attached wallet found') + this.name = 'NoAttachedWalletError' } } @@ -126,19 +126,19 @@ export const useInvoice = () => { return { create, waitUntilPaid: waitController.wait, stopWaiting: waitController.stop, cancel } } -export const useWebLnPayment = () => { +export const useWalletPayment = () => { const invoice = useInvoice() - const provider = useWebLN() + const wallet = useWallet() - const waitForWebLnPayment = useCallback(async ({ id, bolt11 }, waitFor) => { - if (!provider) { - throw new WebLnNotEnabledError() + const waitForWalletPayment = useCallback(async ({ id, bolt11 }, waitFor) => { + if (!wallet) { + throw new NoAttachedWalletError() } try { return await new Promise((resolve, reject) => { // can't use await here since we might pay JIT invoices and sendPaymentAsync is not supported yet. // see https://www.webln.guide/building-lightning-apps/webln-reference/webln.sendpaymentasync - provider.sendPayment(bolt11) + wallet.sendPayment(bolt11) // JIT invoice payments will never resolve here // since they only get resolved after settlement which can't happen here .then(resolve) @@ -148,21 +148,21 @@ export const useWebLnPayment = () => { .catch(reject) }) } catch (err) { - console.error('WebLN payment failed:', err) + console.error('payment failed:', err) throw err } finally { invoice.stopWaiting() } - }, [provider, invoice]) + }, [wallet, invoice]) - return waitForWebLnPayment + return waitForWalletPayment } export const useQrPayment = () => { const invoice = useInvoice() const showModal = useShowModal() - const waitForQrPayment = useCallback(async (inv, webLnError, + const waitForQrPayment = useCallback(async (inv, walletError, { keepOpen = true, cancelOnClose = true, @@ -186,8 +186,8 @@ export const useQrPayment = () => { description status='loading' successVerb='received' - webLn={false} - webLnError={webLnError} + useWallet={false} + walletError={walletError} waitFor={waitFor} onCanceled={inv => { onClose(); reject(new InvoiceCanceledError(inv?.hash, inv?.actionError)) }} onPayment={() => { paid = true; onClose(); resolve() }} @@ -204,22 +204,22 @@ export const usePayment = () => { const me = useMe() const feeButton = useFeeButton() const invoice = useInvoice() - const waitForWebLnPayment = useWebLnPayment() + const waitForWalletPayment = useWalletPayment() const waitForQrPayment = useQrPayment() const waitForPayment = useCallback(async (invoice) => { - let webLnError + let walletError try { - return await waitForWebLnPayment(invoice) + return await waitForWalletPayment(invoice) } catch (err) { if (err instanceof InvoiceCanceledError || err instanceof InvoiceExpiredError) { // bail since qr code payment will also fail throw err } - webLnError = err + walletError = err } - return await waitForQrPayment(invoice, webLnError) - }, [waitForWebLnPayment, waitForQrPayment]) + return await waitForQrPayment(invoice, walletError) + }, [waitForWalletPayment, waitForQrPayment]) const request = useCallback(async (amount) => { amount ??= feeButton?.total diff --git a/components/qr.js b/components/qr.js index 4b75e46c..064e80f6 100644 --- a/components/qr.js +++ b/components/qr.js @@ -2,25 +2,25 @@ import QRCode from 'qrcode.react' import { CopyInput, InputSkeleton } from './form' import InvoiceStatus from './invoice-status' import { useEffect } from 'react' -import { useWebLN } from './webln' +import { useWallet } from 'wallets' import Bolt11Info from './bolt11-info' -export default function Qr ({ asIs, value, webLn, statusVariant, description, status }) { +export default function Qr ({ asIs, value, useWallet: automated, statusVariant, description, status }) { const qrValue = asIs ? value : 'lightning:' + value.toUpperCase() - const provider = useWebLN() + const wallet = useWallet() useEffect(() => { async function effect () { - if (webLn && provider) { + if (automated && wallet) { try { - await provider.sendPayment(value) + await wallet.sendPayment(value) } catch (e) { console.log(e?.message) } } } effect() - }, [provider]) + }, [wallet]) return ( <> diff --git a/components/use-local-state.js b/components/use-local-state.js new file mode 100644 index 00000000..c7da0541 --- /dev/null +++ b/components/use-local-state.js @@ -0,0 +1,21 @@ +import { SSR } from '@/lib/constants' +import { useCallback, useState } from 'react' + +export default function useLocalState (storageKey, initialValue = '') { + const [value, innerSetValue] = useState( + initialValue || + (SSR ? null : JSON.parse(window.localStorage.getItem(storageKey))) + ) + + const setValue = useCallback((newValue) => { + window.localStorage.setItem(storageKey, JSON.stringify(newValue)) + innerSetValue(newValue) + }, [storageKey]) + + const clearValue = useCallback(() => { + window.localStorage.removeItem(storageKey) + innerSetValue(null) + }, [storageKey]) + + return [value, setValue, clearValue] +} diff --git a/components/use-paid-mutation.js b/components/use-paid-mutation.js index d93af3fc..43dd7127 100644 --- a/components/use-paid-mutation.js +++ b/components/use-paid-mutation.js @@ -1,6 +1,6 @@ import { useApolloClient, useLazyQuery, useMutation } from '@apollo/client' import { useCallback, useState } from 'react' -import { InvoiceCanceledError, InvoiceExpiredError, useQrPayment, useWebLnPayment } from './payment' +import { InvoiceCanceledError, InvoiceExpiredError, useQrPayment, useWalletPayment } from './payment' import { GET_PAID_ACTION } from '@/fragments/paidAction' /* @@ -22,17 +22,17 @@ export function usePaidMutation (mutation, const [getPaidAction] = useLazyQuery(GET_PAID_ACTION, { fetchPolicy: 'network-only' }) - const waitForWebLnPayment = useWebLnPayment() + const waitForWalletPayment = useWalletPayment() const waitForQrPayment = useQrPayment() const client = useApolloClient() // innerResult is used to store/control the result of the mutation when innerMutate runs const [innerResult, setInnerResult] = useState(result) const waitForPayment = useCallback(async (invoice, { alwaysShowQROnFailure = false, persistOnNavigate = false, waitFor }) => { - let webLnError + let walletError const start = Date.now() try { - return await waitForWebLnPayment(invoice, waitFor) + return await waitForWalletPayment(invoice, waitFor) } catch (err) { if ( (!alwaysShowQROnFailure && Date.now() - start > 1000) || @@ -42,10 +42,10 @@ export function usePaidMutation (mutation, // also bail if the payment took more than 1 second throw err } - webLnError = err + walletError = err } - return await waitForQrPayment(invoice, webLnError, { persistOnNavigate, waitFor }) - }, [waitForWebLnPayment, waitForQrPayment]) + return await waitForQrPayment(invoice, walletError, { persistOnNavigate, waitFor }) + }, [waitForWalletPayment, waitForQrPayment]) const innerMutate = useCallback(async ({ onCompleted: innerOnCompleted, ...innerOptions diff --git a/components/wallet-buttonbar.js b/components/wallet-buttonbar.js new file mode 100644 index 00000000..2b807569 --- /dev/null +++ b/components/wallet-buttonbar.js @@ -0,0 +1,23 @@ +import { Button } from 'react-bootstrap' +import CancelButton from './cancel-button' +import { SubmitButton } from './form' + +export default function WalletButtonBar ({ + wallet, disable, + className, children, onDelete, onCancel, hasCancel = true, + createText = 'attach', deleteText = 'detach', editText = 'save' +}) { + return ( +
+
+ {wallet.isConfigured && + } + {children} +
+ {hasCancel && } + {wallet.isConfigured ? editText : createText} +
+
+
+ ) +} diff --git a/components/wallet-card.js b/components/wallet-card.js index 7abcd304..61cc1323 100644 --- a/components/wallet-card.js +++ b/components/wallet-card.js @@ -1,18 +1,15 @@ -import { Badge, Button, Card } from 'react-bootstrap' +import { Badge, Card } from 'react-bootstrap' import styles from '@/styles/wallet.module.css' import Plug from '@/svgs/plug.svg' import Gear from '@/svgs/settings-5-fill.svg' import Link from 'next/link' -import CancelButton from './cancel-button' -import { SubmitButton } from './form' -import { Status } from './webln' +import { Status } from 'wallets' -export const isConfigured = status => [Status.Enabled, Status.Locked, Status.Error, true].includes(status) +export default function WalletCard ({ wallet }) { + const { card: { title, badges } } = wallet -export function WalletCard ({ title, badges, provider, status }) { - const configured = isConfigured(status) let indicator = styles.disabled - switch (status) { + switch (wallet.status) { case Status.Enabled: case true: indicator = styles.success @@ -42,35 +39,13 @@ export function WalletCard ({ title, badges, provider, status }) { )} - {provider && - - - {configured - ? <>configure - : <>attach} - - } + + + {wallet.isConfigured + ? <>configure + : <>attach} + + ) } - -export function WalletButtonBar ({ - status, disable, - className, children, onDelete, onCancel, hasCancel = true, - createText = 'attach', deleteText = 'detach', editText = 'save' -}) { - const configured = isConfigured(status) - return ( -
-
- {configured && - } - {children} -
- {hasCancel && } - {configured ? editText : createText} -
-
-
- ) -} diff --git a/components/wallet-logger.js b/components/wallet-logger.js new file mode 100644 index 00000000..a68fe2e9 --- /dev/null +++ b/components/wallet-logger.js @@ -0,0 +1,274 @@ +import LogMessage from './log-message' +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' +import styles from '@/styles/log.module.css' +import { Button } from 'react-bootstrap' +import { useToast } from './toast' +import { useShowModal } from './modal' +import { WALLET_LOGS } from '@/fragments/wallet' +import { getWalletByType } from 'wallets' +import { gql, useMutation, useQuery } from '@apollo/client' +import { useMe } from './me' + +export function WalletLogs ({ wallet, embedded }) { + const logs = useWalletLogs(wallet) + + const tableRef = useRef() + const showModal = useShowModal() + + return ( + <> +
+ { + showModal(onClose => ) + }} + >clear logs + +
+
+ {logs.length === 0 &&
empty
} + + + {logs.map((log, i) => )} + +
+
------ start of logs ------
+
+ + ) +} + +function DeleteWalletLogsObstacle ({ wallet, onClose }) { + const toaster = useToast() + const { deleteLogs } = useWalletLogger(wallet) + + const prompt = `Do you really want to delete all ${wallet ? '' : 'wallet'} logs ${wallet ? 'of this wallet' : ''}?` + return ( +
+ {prompt} +
+ cancel + +
+
+ ) +} + +const WalletLoggerContext = createContext() +const WalletLogsContext = createContext() + +const initIndexedDB = async (dbName, storeName) => { + return new Promise((resolve, reject) => { + if (!window.indexedDB) { + return reject(new Error('IndexedDB not supported')) + } + + // https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API/Using_IndexedDB + const request = window.indexedDB.open(dbName, 1) + + let db + request.onupgradeneeded = () => { + // this only runs if version was changed during open + db = request.result + if (!db.objectStoreNames.contains(storeName)) { + const objectStore = db.createObjectStore(storeName, { autoIncrement: true }) + objectStore.createIndex('ts', 'ts') + objectStore.createIndex('wallet_ts', ['wallet', 'ts']) + } + } + + request.onsuccess = () => { + // this gets called after onupgradeneeded finished + db = request.result + resolve(db) + } + + request.onerror = () => { + reject(new Error('failed to open IndexedDB')) + } + }) +} + +export const WalletLoggerProvider = ({ children }) => { + const me = useMe() + const [logs, setLogs] = useState([]) + let dbName = 'app:storage' + if (me) { + dbName = `${dbName}:${me.id}` + } + const idbStoreName = 'wallet_logs' + const idb = useRef() + const logQueue = useRef([]) + + useQuery(WALLET_LOGS, { + fetchPolicy: 'network-only', + // required to trigger onCompleted on refetches + notifyOnNetworkStatusChange: true, + onCompleted: ({ walletLogs }) => { + setLogs((prevLogs) => { + const existingIds = prevLogs.map(({ id }) => id) + const logs = walletLogs + .filter(({ id }) => !existingIds.includes(id)) + .map(({ createdAt, wallet: walletType, ...log }) => { + return { + ts: +new Date(createdAt), + wallet: tag(getWalletByType(walletType)), + ...log + } + }) + return [...prevLogs, ...logs].sort((a, b) => b.ts - a.ts) + }) + } + }) + + const [deleteServerWalletLogs] = useMutation( + gql` + mutation deleteWalletLogs($wallet: String) { + deleteWalletLogs(wallet: $wallet) + } + `, + { + onCompleted: (_, { variables: { wallet: walletType } }) => { + setLogs((logs) => { + return logs.filter(l => walletType ? l.wallet !== getWalletByType(walletType).name : false) + }) + } + } + ) + + const saveLog = useCallback((log) => { + if (!idb.current) { + // IDB may not be ready yet + return logQueue.current.push(log) + } + const tx = idb.current.transaction(idbStoreName, 'readwrite') + const request = tx.objectStore(idbStoreName).add(log) + request.onerror = () => console.error('failed to save log:', log) + }, []) + + useEffect(() => { + initIndexedDB(dbName, idbStoreName) + .then(db => { + idb.current = db + + // load all logs from IDB + const tx = idb.current.transaction(idbStoreName, 'readonly') + const store = tx.objectStore(idbStoreName) + const index = store.index('ts') + const request = index.getAll() + request.onsuccess = () => { + let logs = request.result + setLogs((prevLogs) => { + if (process.env.NODE_ENV !== 'production') { + // in dev mode, useEffect runs twice, so we filter out duplicates here + const existingIds = prevLogs.map(({ id }) => id) + logs = logs.filter(({ id }) => !existingIds.includes(id)) + } + // sort oldest first to keep same order as logs are appended + return [...prevLogs, ...logs].sort((a, b) => b.ts - a.ts) + }) + } + + // flush queued logs to IDB + logQueue.current.forEach(q => { + const isLog = !!q.wallet + if (isLog) saveLog(q) + }) + + logQueue.current = [] + }) + .catch(console.error) + return () => idb.current?.close() + }, []) + + const appendLog = useCallback((wallet, level, message) => { + const log = { wallet: tag(wallet), level, message, ts: +new Date() } + saveLog(log) + setLogs((prevLogs) => [log, ...prevLogs]) + }, [saveLog]) + + const deleteLogs = useCallback(async (wallet) => { + if (!wallet || wallet.walletType) { + await deleteServerWalletLogs({ variables: { wallet: wallet?.walletType } }) + } + if (!wallet || wallet.sendPayment) { + const tx = idb.current.transaction(idbStoreName, 'readwrite') + const objectStore = tx.objectStore(idbStoreName) + const idx = objectStore.index('wallet_ts') + const request = wallet ? idx.openCursor(window.IDBKeyRange.bound([tag(wallet), -Infinity], [tag(wallet), Infinity])) : idx.openCursor() + request.onsuccess = function (event) { + const cursor = event.target.result + if (cursor) { + cursor.delete() + cursor.continue() + } else { + // finished + setLogs((logs) => logs.filter(l => wallet ? l.wallet !== tag(wallet) : false)) + } + } + } + }, [me, setLogs]) + + return ( + + + {children} + + + ) +} + +export function useWalletLogger (wallet) { + const { appendLog, deleteLogs: innerDeleteLogs } = useContext(WalletLoggerContext) + + const log = useCallback(level => message => { + if (!wallet) { + console.error('cannot log: no wallet set') + return + } + + // don't store logs for receiving wallets on client since logs are stored on server + if (wallet.walletType) return + + // TODO: + // also send this to us if diagnostics was enabled, + // very similar to how the service worker logger works. + appendLog(wallet, level, message) + console[level !== 'error' ? 'info' : 'error'](`[${tag(wallet)}]`, message) + }, [appendLog, wallet]) + + const logger = useMemo(() => ({ + ok: (...message) => log('ok')(message.join(' ')), + info: (...message) => log('info')(message.join(' ')), + error: (...message) => log('error')(message.join(' ')) + }), [log, wallet?.name]) + + const deleteLogs = useCallback((w) => innerDeleteLogs(w || wallet), [innerDeleteLogs, wallet]) + + return { logger, deleteLogs } +} + +function tag (wallet) { + return wallet?.shortName || wallet?.name +} + +export function useWalletLogs (wallet) { + const logs = useContext(WalletLogsContext) + return logs.filter(l => !wallet || l.wallet === tag(wallet)) +} diff --git a/components/wallet-logs.js b/components/wallet-logs.js deleted file mode 100644 index c5cb678d..00000000 --- a/components/wallet-logs.js +++ /dev/null @@ -1,121 +0,0 @@ -import { useRouter } from 'next/router' -import LogMessage from './log-message' -import { useWalletLogger, useWalletLogs } from './logger' -import { useEffect, useRef, useState } from 'react' -import { Checkbox, Form } from './form' -import { useField } from 'formik' -import styles from '@/styles/log.module.css' -import { Button } from 'react-bootstrap' -import { useToast } from './toast' -import { useShowModal } from './modal' - -const FollowCheckbox = ({ value, ...props }) => { - const [,, helpers] = useField(props.name) - - useEffect(() => { - helpers.setValue(value) - }, [value]) - - return ( - - ) -} - -export default function WalletLogs ({ wallet, embedded }) { - const logs = useWalletLogs(wallet) - - const router = useRouter() - const { follow: defaultFollow } = router.query - const [follow, setFollow] = useState(defaultFollow ?? true) - const tableRef = useRef() - const scrollY = useRef() - const showModal = useShowModal() - - useEffect(() => { - if (follow) { - tableRef.current?.scroll({ top: tableRef.current.scrollHeight, behavior: 'smooth' }) - } - }, [logs, follow]) - - useEffect(() => { - function onScroll (e) { - const y = e.target.scrollTop - - const down = y - scrollY.current >= -1 - if (!!scrollY.current && !down) { - setFollow(false) - } - - const maxY = e.target.scrollHeight - e.target.clientHeight - const dY = maxY - y - const isBottom = dY >= -1 && dY <= 1 - if (isBottom) { - setFollow(true) - } - - scrollY.current = y - } - tableRef.current?.addEventListener('scroll', onScroll) - return () => tableRef.current?.removeEventListener('scroll', onScroll) - }, []) - - return ( - <> -
-
- - - { - showModal(onClose => ) - }} - >clear - -
-
-
------ start of logs ------
- {logs.length === 0 &&
empty
} - - - {logs.map((log, i) => )} - -
-
- - ) -} - -function DeleteWalletLogsObstacle ({ wallet, onClose }) { - const toaster = useToast() - const { deleteLogs } = useWalletLogger(wallet) - - const prompt = `Do you really want to delete all ${wallet ? '' : 'wallet'} logs ${wallet ? 'of this wallet' : ''}?` - return ( -
- {prompt} -
- cancel - -
-
- ) -} diff --git a/components/webln/index.js b/components/webln/index.js deleted file mode 100644 index 4eed911c..00000000 --- a/components/webln/index.js +++ /dev/null @@ -1,142 +0,0 @@ -import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' -import { LNbitsProvider, useLNbits } from './lnbits' -import { NWCProvider, useNWC } from './nwc' -import { LNCProvider, useLNC } from './lnc' - -const WebLNContext = createContext({}) - -const isEnabled = p => [Status.Enabled, Status.Locked].includes(p?.status) - -const syncProvider = (array, provider) => { - const idx = array.findIndex(({ name }) => provider.name === name) - const enabled = isEnabled(provider) - if (idx === -1) { - // add provider to end if enabled - return enabled ? [...array, provider] : array - } - return [ - ...array.slice(0, idx), - // remove provider if not enabled - ...enabled ? [provider] : [], - ...array.slice(idx + 1) - ] -} - -const storageKey = 'webln:providers' - -export const Status = { - Initialized: 'Initialized', - Enabled: 'Enabled', - Locked: 'Locked', - Error: 'Error' -} - -export function migrateLocalStorage (oldStorageKey, newStorageKey) { - const item = window.localStorage.getItem(oldStorageKey) - if (item) { - window.localStorage.setItem(newStorageKey, item) - window.localStorage.removeItem(oldStorageKey) - } - return item -} - -function RawWebLNProvider ({ children }) { - const lnbits = useLNbits() - const nwc = useNWC() - const lnc = useLNC() - const availableProviders = [lnbits, nwc, lnc] - const [enabledProviders, setEnabledProviders] = useState([]) - - // restore order on page reload - useEffect(() => { - const storedOrder = window.localStorage.getItem(storageKey) - if (!storedOrder) return - const providerNames = JSON.parse(storedOrder) - setEnabledProviders(providers => { - return providerNames.map(name => { - for (const p of availableProviders) { - if (p.name === name) return p - } - console.warn(`Stored provider with name ${name} not available`) - return null - }) - }) - }, []) - - // keep list in sync with underlying providers - useEffect(() => { - setEnabledProviders(providers => { - // Sync existing provider state with new provider state - // in the list while keeping the order they are in. - // If provider does not exist but is enabled, it is just added to the end of the list. - // This can be the case if we're syncing from a page reload - // where the providers are initially not enabled. - // If provider is no longer enabled, it is removed from the list. - const isInitialized = p => [Status.Enabled, Status.Locked, Status.Initialized].includes(p.status) - const newProviders = availableProviders.filter(isInitialized).reduce(syncProvider, providers) - const newOrder = newProviders.map(({ name }) => name) - window.localStorage.setItem(storageKey, JSON.stringify(newOrder)) - return newProviders - }) - }, [...availableProviders]) - - // first provider in list is the default provider - // TODO: implement fallbacks via provider priority - const provider = enabledProviders[0] - - const setProvider = useCallback((defaultProvider) => { - // move provider to the start to set it as default - setEnabledProviders(providers => { - const idx = providers.findIndex(({ name }) => defaultProvider.name === name) - if (idx === -1) { - console.warn(`tried to set unenabled provider ${defaultProvider.name} as default`) - return providers - } - return [defaultProvider, ...providers.slice(0, idx), ...providers.slice(idx + 1)] - }) - }, [setEnabledProviders]) - - const clearConfig = useCallback(async () => { - lnbits.clearConfig() - nwc.clearConfig() - await lnc.clearConfig() - }, []) - - const value = useMemo(() => ({ - provider: isEnabled(provider) - ? { name: provider.name, sendPayment: provider.sendPayment } - : null, - enabledProviders, - setProvider, - clearConfig - }), [provider, enabledProviders, setProvider]) - - return ( - - {children} - - ) -} - -export function WebLNProvider ({ children }) { - return ( - - - - - {children} - - - - - ) -} - -export function useWebLN () { - const { provider } = useContext(WebLNContext) - return provider -} - -export function useWebLNConfigurator () { - return useContext(WebLNContext) -} diff --git a/components/webln/lnbits.js b/components/webln/lnbits.js deleted file mode 100644 index 68eddcd9..00000000 --- a/components/webln/lnbits.js +++ /dev/null @@ -1,210 +0,0 @@ -import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' -import { useWalletLogger } from '../logger' -import { Status, migrateLocalStorage } from '.' -import { bolt11Tags } from '@/lib/bolt11' -import { Wallet } from '@/lib/constants' -import { useMe } from '../me' - -// Reference: https://github.com/getAlby/bitcoin-connect/blob/v3.2.0-alpha/src/connectors/LnbitsConnector.ts - -const LNbitsContext = createContext() - -const getWallet = async (baseUrl, adminKey) => { - const url = baseUrl.replace(/\/+$/, '') - const path = '/api/v1/wallet' - - const headers = new Headers() - headers.append('Accept', 'application/json') - headers.append('Content-Type', 'application/json') - headers.append('X-Api-Key', adminKey) - - const res = await fetch(url + path, { method: 'GET', headers }) - if (!res.ok) { - const errBody = await res.json() - throw new Error(errBody.detail) - } - const wallet = await res.json() - return wallet -} - -const postPayment = async (baseUrl, adminKey, bolt11) => { - const url = baseUrl.replace(/\/+$/, '') - const path = '/api/v1/payments' - - const headers = new Headers() - headers.append('Accept', 'application/json') - headers.append('Content-Type', 'application/json') - headers.append('X-Api-Key', adminKey) - - const body = JSON.stringify({ bolt11, out: true }) - - const res = await fetch(url + path, { method: 'POST', headers, body }) - if (!res.ok) { - const errBody = await res.json() - throw new Error(errBody.detail) - } - const payment = await res.json() - return payment -} - -const getPayment = async (baseUrl, adminKey, paymentHash) => { - const url = baseUrl.replace(/\/+$/, '') - const path = `/api/v1/payments/${paymentHash}` - - const headers = new Headers() - headers.append('Accept', 'application/json') - headers.append('Content-Type', 'application/json') - headers.append('X-Api-Key', adminKey) - - const res = await fetch(url + path, { method: 'GET', headers }) - if (!res.ok) { - const errBody = await res.json() - throw new Error(errBody.detail) - } - const payment = await res.json() - return payment -} - -export function LNbitsProvider ({ children }) { - const me = useMe() - const [url, setUrl] = useState('') - const [adminKey, setAdminKey] = useState('') - const [status, setStatus] = useState() - const { logger } = useWalletLogger(Wallet.LNbits) - - let storageKey = 'webln:provider:lnbits' - if (me) { - storageKey = `${storageKey}:${me.id}` - } - - const getInfo = useCallback(async () => { - const response = await getWallet(url, adminKey) - return { - node: { - alias: response.name, - pubkey: '' - }, - methods: [ - 'getInfo', - 'getBalance', - 'sendPayment' - ], - version: '1.0', - supports: ['lightning'] - } - }, [url, adminKey]) - - const sendPayment = useCallback(async (bolt11) => { - const hash = bolt11Tags(bolt11).payment_hash - logger.info('sending payment:', `payment_hash=${hash}`) - - try { - const response = await postPayment(url, adminKey, bolt11) - const checkResponse = await getPayment(url, adminKey, response.payment_hash) - if (!checkResponse.preimage) { - throw new Error('No preimage') - } - const preimage = checkResponse.preimage - logger.ok('payment successful:', `payment_hash=${hash}`, `preimage=${preimage}`) - return { preimage } - } catch (err) { - logger.error('payment failed:', `payment_hash=${hash}`, err.message || err.toString?.()) - throw err - } - }, [logger, url, adminKey]) - - const loadConfig = useCallback(async () => { - let configStr = window.localStorage.getItem(storageKey) - setStatus(Status.Initialized) - if (!configStr) { - if (me) { - // backwards compatibility: try old storageKey - const oldStorageKey = storageKey.split(':').slice(0, -1).join(':') - configStr = migrateLocalStorage(oldStorageKey, storageKey) - } - if (!configStr) { - logger.info('no existing config found') - return - } - } - - const config = JSON.parse(configStr) - - const { url, adminKey } = config - setUrl(url) - setAdminKey(adminKey) - - logger.info( - 'loaded wallet config: ' + - 'adminKey=****** ' + - `url=${url}`) - - try { - // validate config by trying to fetch wallet - logger.info('trying to fetch wallet') - await getWallet(url, adminKey) - logger.ok('wallet found') - setStatus(Status.Enabled) - logger.ok('wallet enabled') - } catch (err) { - logger.error('invalid config:', err) - setStatus(Status.Error) - logger.info('wallet disabled') - throw err - } - }, [me, logger]) - - const saveConfig = useCallback(async (config) => { - // immediately store config so it's not lost even if config is invalid - setUrl(config.url) - setAdminKey(config.adminKey) - - // XXX This is insecure, XSS vulns could lead to loss of funds! - // -> check how mutiny encrypts their wallet and/or check if we can leverage web workers - // https://thenewstack.io/leveraging-web-workers-to-safely-store-access-tokens/ - window.localStorage.setItem(storageKey, JSON.stringify(config)) - - logger.info( - 'saved wallet config: ' + - 'adminKey=****** ' + - `url=${config.url}`) - - try { - // validate config by trying to fetch wallet - logger.info('trying to fetch wallet') - await getWallet(config.url, config.adminKey) - logger.ok('wallet found') - setStatus(Status.Enabled) - logger.ok('wallet enabled') - } catch (err) { - logger.error('invalid config:', err) - setStatus(Status.Error) - logger.info('wallet disabled') - throw err - } - }, []) - - const clearConfig = useCallback(() => { - window.localStorage.removeItem(storageKey) - setUrl('') - setAdminKey('') - setStatus(undefined) - }, []) - - useEffect(() => { - loadConfig().catch(console.error) - }, []) - - const value = useMemo( - () => ({ name: 'LNbits', url, adminKey, status, saveConfig, clearConfig, getInfo, sendPayment }), - [url, adminKey, status, saveConfig, clearConfig, getInfo, sendPayment]) - return ( - - {children} - - ) -} - -export function useLNbits () { - return useContext(LNbitsContext) -} diff --git a/components/webln/lnc.js b/components/webln/lnc.js deleted file mode 100644 index 58aac5ab..00000000 --- a/components/webln/lnc.js +++ /dev/null @@ -1,215 +0,0 @@ -import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' -import { useWalletLogger } from '../logger' -import { Status, migrateLocalStorage } from '.' -import { bolt11Tags } from '@/lib/bolt11' -import useModal from '../modal' -import { Form, PasswordInput, SubmitButton } from '../form' -import CancelButton from '../cancel-button' -import { Mutex } from 'async-mutex' -import { Wallet } from '@/lib/constants' -import { useMe } from '../me' -import { InvoiceCanceledError, InvoiceExpiredError } from '@/components/payment' - -const LNCContext = createContext() -const mutex = new Mutex() - -async function getLNC ({ me }) { - if (window.lnc) return window.lnc - const { default: LNC } = await import('@lightninglabs/lnc-web') - // backwards compatibility: migrate to new storage key - if (me) migrateLocalStorage('lnc-web:default', `lnc-web:stacker:${me.id}`) - window.lnc = new LNC({ namespace: me?.id ? `stacker:${me.id}` : undefined }) - return window.lnc -} - -// default password if the user hasn't set one -export const XXX_DEFAULT_PASSWORD = 'password' - -function validateNarrowPerms (lnc) { - if (!lnc.hasPerms('lnrpc.Lightning.SendPaymentSync')) { - throw new Error('missing permission: lnrpc.Lightning.SendPaymentSync') - } - if (lnc.hasPerms('lnrpc.Lightning.SendCoins')) { - throw new Error('too broad permission: lnrpc.Wallet.SendCoins') - } - // TODO: need to check for more narrow permissions - // blocked by https://github.com/lightninglabs/lnc-web/issues/112 -} - -export function LNCProvider ({ children }) { - const me = useMe() - const { logger } = useWalletLogger(Wallet.LNC) - const [config, setConfig] = useState({}) - const [lnc, setLNC] = useState() - const [status, setStatus] = useState() - const [modal, showModal] = useModal() - - const getInfo = useCallback(async () => { - logger.info('getInfo called') - return await lnc.lightning.getInfo() - }, [logger, lnc]) - - const unlock = useCallback(async (connect) => { - if (status === Status.Enabled) return config.password - - return await new Promise((resolve, reject) => { - const cancelAndReject = async () => { - reject(new Error('password canceled')) - } - showModal(onClose => { - return ( -
{ - try { - lnc.credentials.password = values?.password - setStatus(Status.Enabled) - setConfig({ pairingPhrase: lnc.credentials.pairingPhrase, password: values.password }) - logger.ok('wallet enabled') - onClose() - resolve(values.password) - } catch (err) { - logger.error('failed attempt to unlock wallet', err) - throw err - } - }} - > -

Unlock LNC

- -
- { onClose(); cancelAndReject() }} /> - unlock -
- - ) - }, { onClose: cancelAndReject }) - }) - }, [logger, showModal, setConfig, lnc, status]) - - const sendPayment = useCallback(async (bolt11) => { - const hash = bolt11Tags(bolt11).payment_hash - logger.info('sending payment:', `payment_hash=${hash}`) - - return await mutex.runExclusive(async () => { - try { - const password = await unlock() - // credentials need to be decrypted before connecting after a disconnect - lnc.credentials.password = password || XXX_DEFAULT_PASSWORD - await lnc.connect() - const { paymentError, paymentPreimage: preimage } = - await lnc.lnd.lightning.sendPaymentSync({ payment_request: bolt11 }) - - if (paymentError) throw new Error(paymentError) - if (!preimage) throw new Error('No preimage in response') - - logger.ok('payment successful:', `payment_hash=${hash}`, `preimage=${preimage}`) - return { preimage } - } catch (err) { - const msg = err.message || err.toString?.() - logger.error('payment failed:', `payment_hash=${hash}`, msg) - if (msg.includes('invoice expired')) { - throw new InvoiceExpiredError(hash) - } - if (msg.includes('canceled')) { - throw new InvoiceCanceledError(hash) - } - throw err - } finally { - try { - lnc.disconnect() - logger.info('disconnecting after:', `payment_hash=${hash}`) - // wait for lnc to disconnect before releasing the mutex - await new Promise((resolve, reject) => { - let counter = 0 - const interval = setInterval(() => { - if (lnc.isConnected) { - if (counter++ > 100) { - logger.error('failed to disconnect from lnc') - clearInterval(interval) - reject(new Error('failed to disconnect from lnc')) - } - return - } - clearInterval(interval) - resolve() - }) - }, 50) - } catch (err) { - logger.error('failed to disconnect from lnc', err) - } - } - }) - }, [logger, lnc, unlock]) - - const saveConfig = useCallback(async config => { - setConfig(config) - - try { - lnc.credentials.pairingPhrase = config.pairingPhrase - await lnc.connect() - await validateNarrowPerms(lnc) - lnc.credentials.password = config?.password || XXX_DEFAULT_PASSWORD - setStatus(Status.Enabled) - logger.ok('wallet enabled') - } catch (err) { - logger.error('invalid config:', err) - setStatus(Status.Error) - logger.info('wallet disabled') - throw err - } finally { - lnc.disconnect() - } - }, [logger, lnc]) - - const clearConfig = useCallback(async () => { - await lnc.credentials.clear(false) - if (lnc.isConnected) lnc.disconnect() - setStatus(undefined) - setConfig({}) - logger.info('cleared config') - }, [logger, lnc]) - - useEffect(() => { - (async () => { - try { - const lnc = await getLNC({ me }) - setLNC(lnc) - setStatus(Status.Initialized) - if (lnc.credentials.isPaired) { - try { - // try the default password - lnc.credentials.password = XXX_DEFAULT_PASSWORD - } catch (err) { - setStatus(Status.Locked) - logger.info('wallet needs password before enabling') - return - } - setStatus(Status.Enabled) - setConfig({ pairingPhrase: lnc.credentials.pairingPhrase, password: lnc.credentials.password }) - } - } catch (err) { - logger.error('wallet could not be loaded:', err) - setStatus(Status.Error) - } - })() - }, [me, setStatus, setConfig, logger]) - - const value = useMemo( - () => ({ name: 'lnc', status, unlock, getInfo, sendPayment, config, saveConfig, clearConfig }), - [status, unlock, getInfo, sendPayment, config, saveConfig, clearConfig]) - return ( - - {children} - {modal} - - ) -} - -export function useLNC () { - return useContext(LNCContext) -} diff --git a/components/webln/nwc.js b/components/webln/nwc.js deleted file mode 100644 index 212c848a..00000000 --- a/components/webln/nwc.js +++ /dev/null @@ -1,287 +0,0 @@ -// https://github.com/getAlby/js-sdk/blob/master/src/webln/NostrWeblnProvider.ts - -import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' -import { Relay, finalizeEvent, nip04 } from 'nostr-tools' -import { parseNwcUrl } from '@/lib/url' -import { useWalletLogger } from '../logger' -import { Status, migrateLocalStorage } from '.' -import { bolt11Tags } from '@/lib/bolt11' -import { JIT_INVOICE_TIMEOUT_MS, Wallet } from '@/lib/constants' -import { useMe } from '../me' -import { InvoiceExpiredError } from '../payment' - -const NWCContext = createContext() - -export function NWCProvider ({ children }) { - const me = useMe() - const [nwcUrl, setNwcUrl] = useState('') - const [walletPubkey, setWalletPubkey] = useState() - const [relayUrl, setRelayUrl] = useState() - const [secret, setSecret] = useState() - const [status, setStatus] = useState() - const { logger } = useWalletLogger(Wallet.NWC) - - let storageKey = 'webln:provider:nwc' - if (me) { - storageKey = `${storageKey}:${me.id}` - } - - const getInfo = useCallback(async (relayUrl, walletPubkey) => { - logger.info(`requesting info event from ${relayUrl}`) - - let relay - try { - relay = await Relay.connect(relayUrl) - logger.ok(`connected to ${relayUrl}`) - } catch (err) { - const msg = `failed to connect to ${relayUrl}` - logger.error(msg) - throw new Error(msg) - } - - try { - return await new Promise((resolve, reject) => { - const timeout = 5000 - const timer = setTimeout(() => { - const msg = 'timeout waiting for info event' - logger.error(msg) - reject(new Error(msg)) - }, timeout) - - let found = false - relay.subscribe([ - { - kinds: [13194], - authors: [walletPubkey] - } - ], { - onevent (event) { - clearTimeout(timer) - found = true - logger.ok(`received info event from ${relayUrl}`) - resolve(event) - }, - onclose (reason) { - clearTimeout(timer) - 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 () { - clearTimeout(timer) - if (!found) { - const msg = 'EOSE received without info event' - logger.error(msg) - reject(new Error(msg)) - } - } - }) - }) - } finally { - relay?.close()?.catch() - if (relay) logger.info(`closed connection to ${relayUrl}`) - } - }, [logger]) - - const validateParams = useCallback(async ({ relayUrl, walletPubkey }) => { - // validate connection by fetching info event - // function needs to throw an error for formik validation to fail - const event = await getInfo(relayUrl, walletPubkey) - const supported = event.content.split(/[\s,]+/) // handle both spaces and commas - logger.info('supported methods:', supported) - if (!supported.includes('pay_invoice')) { - const msg = 'wallet does not support pay_invoice' - logger.error(msg) - throw new Error(msg) - } - logger.ok('wallet supports pay_invoice') - }, [logger]) - - const loadConfig = useCallback(async () => { - let configStr = window.localStorage.getItem(storageKey) - setStatus(Status.Initialized) - if (!configStr) { - if (me) { - // backwards compatibility: try old storageKey - const oldStorageKey = storageKey.split(':').slice(0, -1).join(':') - configStr = migrateLocalStorage(oldStorageKey, storageKey) - } - if (!configStr) { - logger.info('no existing config found') - return - } - } - - const config = JSON.parse(configStr) - - const { nwcUrl } = config - setNwcUrl(nwcUrl) - - const params = parseNwcUrl(nwcUrl) - setRelayUrl(params.relayUrl) - setWalletPubkey(params.walletPubkey) - setSecret(params.secret) - - logger.info( - 'loaded wallet config: ' + - 'secret=****** ' + - `pubkey=${params.walletPubkey.slice(0, 6)}..${params.walletPubkey.slice(-6)} ` + - `relay=${params.relayUrl}`) - - try { - await validateParams(params) - setStatus(Status.Enabled) - logger.ok('wallet enabled') - } catch (err) { - logger.error('invalid config:', err) - setStatus(Status.Error) - logger.info('wallet disabled') - throw err - } - }, [me, validateParams, logger]) - - const saveConfig = useCallback(async (config) => { - // immediately store config so it's not lost even if config is invalid - const { nwcUrl } = config - setNwcUrl(nwcUrl) - if (!nwcUrl) { - setStatus(undefined) - return - } - - const params = parseNwcUrl(nwcUrl) - setRelayUrl(params.relayUrl) - setWalletPubkey(params.walletPubkey) - setSecret(params.secret) - - // XXX Even though NWC allows to configure budget, - // this is definitely not ideal from a security perspective. - window.localStorage.setItem(storageKey, JSON.stringify(config)) - - logger.info( - 'saved wallet config: ' + - 'secret=****** ' + - `pubkey=${params.walletPubkey.slice(0, 6)}..${params.walletPubkey.slice(-6)} ` + - `relay=${params.relayUrl}`) - - try { - await validateParams(params) - setStatus(Status.Enabled) - logger.ok('wallet enabled') - } catch (err) { - logger.error('invalid config:', err) - setStatus(Status.Error) - logger.info('wallet disabled') - throw err - } - }, [validateParams, logger]) - - const clearConfig = useCallback(() => { - window.localStorage.removeItem(storageKey) - setNwcUrl('') - setRelayUrl(undefined) - setWalletPubkey(undefined) - setSecret(undefined) - setStatus(undefined) - }, []) - - const sendPayment = useCallback(async (bolt11) => { - const hash = bolt11Tags(bolt11).payment_hash - logger.info('sending payment:', `payment_hash=${hash}`) - - let relay - try { - relay = await Relay.connect(relayUrl) - logger.ok(`connected to ${relayUrl}`) - } catch (err) { - const msg = `failed to connect to ${relayUrl}` - logger.error(msg) - throw new Error(msg) - } - - try { - const ret = await new Promise(function (resolve, reject) { - (async function () { - // timeout since NWC is async (user needs to confirm payment in wallet) - // timeout is same as invoice expiry - const timeout = JIT_INVOICE_TIMEOUT_MS - const timer = setTimeout(() => { - const msg = 'timeout waiting for payment' - logger.error(msg) - reject(new InvoiceExpiredError(hash)) - }, timeout) - - 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) { - clearTimeout(timer) - 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) { - clearTimeout(timer) - 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) - }) - const preimage = ret.preimage - logger.ok('payment successful:', `payment_hash=${hash}`, `preimage=${preimage}`) - return ret - } catch (err) { - logger.error('payment failed:', `payment_hash=${hash}`, err.message || err.toString?.()) - throw err - } finally { - relay?.close()?.catch() - if (relay) logger.info(`closed connection to ${relayUrl}`) - } - }, [walletPubkey, relayUrl, secret, logger]) - - useEffect(() => { - loadConfig().catch(err => logger.error(err.message || err.toString?.())) - }, []) - - const value = useMemo( - () => ({ name: 'NWC', nwcUrl, relayUrl, walletPubkey, secret, status, saveConfig, clearConfig, getInfo, sendPayment }), - [nwcUrl, relayUrl, walletPubkey, secret, status, saveConfig, clearConfig, getInfo, sendPayment]) - return ( - - {children} - - ) -} - -export function useNWC () { - return useContext(NWCContext) -} diff --git a/fragments/wallet.js b/fragments/wallet.js index 6acf0c3e..b1515f88 100644 --- a/fragments/wallet.js +++ b/fragments/wallet.js @@ -100,27 +100,6 @@ export const SEND_TO_LNADDR = gql` } }` -export const UPSERT_WALLET_LNADDR = -gql` -mutation upsertWalletLNAddr($id: ID, $address: String!, $settings: AutowithdrawSettings!) { - upsertWalletLNAddr(id: $id, address: $address, settings: $settings) -} -` - -export const UPSERT_WALLET_LND = -gql` -mutation upsertWalletLND($id: ID, $socket: String!, $macaroon: String!, $cert: String, $settings: AutowithdrawSettings!) { - upsertWalletLND(id: $id, socket: $socket, macaroon: $macaroon, cert: $cert, settings: $settings) -} -` - -export const UPSERT_WALLET_CLN = -gql` -mutation upsertWalletCLN($id: ID, $socket: String!, $rune: String!, $cert: String, $settings: AutowithdrawSettings!) { - upsertWalletCLN(id: $id, socket: $socket, rune: $rune, cert: $cert, settings: $settings) -} -` - export const REMOVE_WALLET = gql` mutation removeWallet($id: ID!) { @@ -160,6 +139,7 @@ export const WALLET_BY_TYPE = gql` walletByType(type: $type) { id createdAt + enabled priority type wallet { diff --git a/lib/constants.js b/lib/constants.js index 525d6698..45074c46 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -140,21 +140,4 @@ export const NORMAL_POLL_INTERVAL = Number(process.env.NEXT_PUBLIC_NORMAL_POLL_I export const LONG_POLL_INTERVAL = Number(process.env.NEXT_PUBLIC_LONG_POLL_INTERVAL) export const EXTRA_LONG_POLL_INTERVAL = Number(process.env.NEXT_PUBLIC_EXTRA_LONG_POLL_INTERVAL) -// attached wallets -export const Wallet = { - LND: { logTag: 'lnd', server: true, type: 'LND', field: 'walletLND' }, - CLN: { logTag: 'cln', server: true, type: 'CLN', field: 'walletCLN' }, - LnAddr: { logTag: 'lnAddr', server: true, type: 'LIGHTNING_ADDRESS', field: 'walletLightningAddress' }, - NWC: { logTag: 'nwc', server: false }, - LNbits: { logTag: 'lnbits', server: false }, - LNC: { logTag: 'lnc', server: false } -} - -export const getWalletBy = (key, value) => { - for (const w of Object.values(Wallet)) { - if (w[key] === value) return w - } - throw new Error(`wallet not found: ${key}=${value}`) -} - export const ZAP_UNDO_DELAY_MS = 5_000 diff --git a/lib/macaroon.js b/lib/macaroon.js index d0137b52..dc65fbd6 100644 --- a/lib/macaroon.js +++ b/lib/macaroon.js @@ -22,7 +22,7 @@ function macaroonOPs (macaroon) { } } } catch (e) { - console.error('macaroonOPs error:', e) + console.error('macaroonOPs error:', e.message) } return [] diff --git a/lib/validate.js b/lib/validate.js index d3fba4b3..982a9bd1 100644 --- a/lib/validate.js +++ b/lib/validate.js @@ -33,6 +33,14 @@ export async function ssValidate (schema, data, args) { } } +export async function formikValidate (validate, data) { + const errors = await validate(data) + if (Object.keys(errors).length > 0) { + const [key, message] = Object.entries(errors)[0] + throw new Error(`${key}: ${message}`) + } +} + addMethod(string, 'or', function (schemas, msg) { return this.test({ name: 'or', @@ -153,7 +161,7 @@ const floatValidator = number().typeError('must be a number') const lightningAddressValidator = process.env.NODE_ENV === 'development' ? string().or( - [string().matches(/^[\w_]+@localhost:\d+$/), string().email()], + [string().matches(/^[\w_]+@localhost:\d+$/), string().matches(/^[\w_]+@app:\d+$/), string().email()], 'address is no good') : string().email('address is no good') @@ -305,63 +313,55 @@ export function advSchema (args) { }) } -export function lnAddrAutowithdrawSchema ({ me } = {}) { - return object({ - address: lightningAddressValidator.required('required').test({ - name: 'address', - test: addr => !addr.endsWith('@stacker.news'), - message: 'automated withdrawals must be external' - }), - ...autowithdrawSchemaMembers({ me }) - }) +export const autowithdrawSchemaMembers = { + enabled: boolean(), + autoWithdrawThreshold: intValidator.required('required').min(0, 'must be at least 0').max(msatsToSats(BALANCE_LIMIT_MSATS), `must be at most ${abbrNum(msatsToSats(BALANCE_LIMIT_MSATS))}`), + autoWithdrawMaxFeePercent: floatValidator.required('required').min(0, 'must be at least 0').max(50, 'must not exceed 50') } -export function LNDAutowithdrawSchema ({ me } = {}) { - return object({ - socket: string().socket().required('required'), - macaroon: hexOrBase64Validator.required('required').test({ - name: 'macaroon', - test: v => isInvoiceMacaroon(v) || isInvoicableMacaroon(v), - message: 'not an invoice macaroon or an invoicable macaroon' - }), - cert: hexOrBase64Validator, - ...autowithdrawSchemaMembers({ me }) - }) -} +export const lnAddrAutowithdrawSchema = object({ + address: lightningAddressValidator.required('required').test({ + name: 'address', + test: addr => !addr.endsWith('@stacker.news'), + message: 'automated withdrawals must be external' + }), + ...autowithdrawSchemaMembers +}) -export function CLNAutowithdrawSchema ({ me } = {}) { - return object({ - socket: string().socket().required('required'), - rune: string().matches(B64_URL_REGEX, { message: 'invalid rune' }).required('required') - .test({ - name: 'rune', - test: (v, context) => { - const decoded = decodeRune(v) - if (!decoded) return context.createError({ message: 'invalid rune' }) - if (decoded.restrictions.length === 0) { - return context.createError({ message: 'rune must be restricted to method=invoice' }) - } - if (decoded.restrictions.length !== 1 || decoded.restrictions[0].alternatives.length !== 1) { - return context.createError({ message: 'rune must be restricted to method=invoice only' }) - } - if (decoded.restrictions[0].alternatives[0] !== 'method=invoice') { - return context.createError({ message: 'rune must be restricted to method=invoice only' }) - } - return true +export const LNDAutowithdrawSchema = object({ + socket: string().socket().required('required'), + macaroon: hexOrBase64Validator.required('required').test({ + name: 'macaroon', + test: v => isInvoiceMacaroon(v) || isInvoicableMacaroon(v), + message: 'not an invoice macaroon or an invoicable macaroon' + }), + cert: hexOrBase64Validator, + ...autowithdrawSchemaMembers +}) + +export const CLNAutowithdrawSchema = object({ + socket: string().socket().required('required'), + rune: string().matches(B64_URL_REGEX, { message: 'invalid rune' }).required('required') + .test({ + name: 'rune', + test: (v, context) => { + const decoded = decodeRune(v) + if (!decoded) return context.createError({ message: 'invalid rune' }) + if (decoded.restrictions.length === 0) { + return context.createError({ message: 'rune must be restricted to method=invoice' }) } - }), - cert: hexOrBase64Validator, - ...autowithdrawSchemaMembers({ me }) - }) -} - -export function autowithdrawSchemaMembers ({ me } = {}) { - return { - priority: boolean(), - autoWithdrawThreshold: intValidator.required('required').min(0, 'must be at least 0').max(msatsToSats(BALANCE_LIMIT_MSATS), `must be at most ${abbrNum(msatsToSats(BALANCE_LIMIT_MSATS))}`), - autoWithdrawMaxFeePercent: floatValidator.required('required').min(0, 'must be at least 0').max(50, 'must not exceed 50') - } -} + if (decoded.restrictions.length !== 1 || decoded.restrictions[0].alternatives.length !== 1) { + return context.createError({ message: 'rune must be restricted to method=invoice only' }) + } + if (decoded.restrictions[0].alternatives[0] !== 'method=invoice') { + return context.createError({ message: 'rune must be restricted to method=invoice only' }) + } + return true + } + }), + cert: hexOrBase64Validator, + ...autowithdrawSchemaMembers +}) export function bountySchema (args) { return object({ @@ -622,7 +622,7 @@ export const lnbitsSchema = object({ } return true }), - adminKey: string().length(32) + adminKey: string().length(32).required('required') }) export const nwcSchema = object({ @@ -657,13 +657,21 @@ export const lncSchema = object({ if (this.isType(value) && value !== null) { return value } - return originalValue ? originalValue.split(/[\s]+/) : [] + return originalValue ? originalValue.trim().split(/[\s]+/) : [] + }) + .test(async (words, context) => { + for (const w of words) { + try { + await string().oneOf(bip39Words).validate(w) + } catch { + return context.createError({ message: `'${w}' is not a valid pairing phrase word` }) + } + } + return true }) - .of(string().trim().oneOf(bip39Words, ({ value }) => `'${value}' is not a valid pairing phrase word`)) .min(2, 'needs at least two words') .max(10, 'max 10 words') - .required('required'), - password: string() + .required('required') }) export const bioSchema = object({ diff --git a/lib/wallet.js b/lib/wallet.js new file mode 100644 index 00000000..bff1d915 --- /dev/null +++ b/lib/wallet.js @@ -0,0 +1,4 @@ +export function generateResolverName (walletField) { + const capitalized = walletField[0].toUpperCase() + walletField.slice(1) + return `upsertWallet${capitalized}` +} diff --git a/next.config.js b/next.config.js index 00b51f49..6a6c02ed 100644 --- a/next.config.js +++ b/next.config.js @@ -232,7 +232,10 @@ module.exports = withPlausibleProxy()({ }) } + // const ignorePlugin = new webpack.IgnorePlugin({ resourceRegExp: /server\.js$/ }) + config.plugins.push(workboxPlugin) + // config.plugins.push(ignorePlugin) } config.module.rules.push( diff --git a/pages/_app.js b/pages/_app.js index 94d4eb90..9161017e 100644 --- a/pages/_app.js +++ b/pages/_app.js @@ -17,8 +17,8 @@ import { SSR } from '@/lib/constants' import NProgress from 'nprogress' import 'nprogress/nprogress.css' import { LoggerProvider } from '@/components/logger' +import { WalletLoggerProvider } from '@/components/wallet-logger' import { ChainFeeProvider } from '@/components/chain-fee.js' -import { WebLNProvider } from '@/components/webln' import dynamic from 'next/dynamic' import { HasNewNotesProvider } from '@/components/use-has-new-notes' @@ -105,11 +105,11 @@ export default function MyApp ({ Component, pageProps: { ...props } }) { - - - - - + + + + + @@ -120,11 +120,11 @@ export default function MyApp ({ Component, pageProps: { ...props } }) { - - - - - + + + + + diff --git a/pages/api/lnurlp/[username]/index.js b/pages/api/lnurlp/[username]/index.js index 66d81c7b..bc8be79c 100644 --- a/pages/api/lnurlp/[username]/index.js +++ b/pages/api/lnurlp/[username]/index.js @@ -9,8 +9,9 @@ export default async ({ query: { username } }, res) => { return res.status(400).json({ status: 'ERROR', reason: `user @${username} does not exist` }) } + const url = process.env.NODE_ENV === 'development' ? process.env.SELF_URL : process.env.NEXT_PUBLIC_URL return res.status(200).json({ - callback: `${process.env.NEXT_PUBLIC_URL}/api/lnurlp/${username}/pay`, // The URL from LN SERVICE which will accept the pay request parameters + callback: `${url}/api/lnurlp/${username}/pay`, // The URL from LN SERVICE which will accept the pay request parameters minSendable: 1000, // Min amount LN SERVICE is willing to receive, can not be less than 1 or more than `maxSendable` maxSendable: 1000000000, metadata: lnurlPayMetadataString(username), // Metadata json which must be presented as raw string here, this is required to pass signature verification at a later step diff --git a/pages/invoices/[id].js b/pages/invoices/[id].js index 653d3fb6..182fcd02 100644 --- a/pages/invoices/[id].js +++ b/pages/invoices/[id].js @@ -12,7 +12,7 @@ export default function FullInvoice () { return ( - + ) } diff --git a/pages/settings/wallets/[wallet].js b/pages/settings/wallets/[wallet].js new file mode 100644 index 00000000..3b95edd8 --- /dev/null +++ b/pages/settings/wallets/[wallet].js @@ -0,0 +1,142 @@ +import { getGetServerSideProps } from '@/api/ssrApollo' +import { Form, ClientInput, ClientCheckbox, PasswordInput } from '@/components/form' +import { CenterLayout } from '@/components/layout' +import { WalletSecurityBanner } from '@/components/banners' +import { WalletLogs } from '@/components/wallet-logger' +import { useToast } from '@/components/toast' +import { useRouter } from 'next/router' +import { useWallet, Status } from 'wallets' +import Info from '@/components/info' +import Text from '@/components/text' +import { AutowithdrawSettings } from '@/components/autowithdraw-shared' +import dynamic from 'next/dynamic' + +const WalletButtonBar = dynamic(() => import('@/components/wallet-buttonbar.js'), { ssr: false }) + +export const getServerSideProps = getGetServerSideProps({ authRequired: true }) + +export default function WalletSettings () { + const toaster = useToast() + const router = useRouter() + const { wallet: name } = router.query + const wallet = useWallet(name) + + const initial = wallet.fields.reduce((acc, field) => { + // We still need to run over all wallet fields via reduce + // even though we use wallet.config as the initial value + // since wallet.config is empty when wallet is not configured. + // Also, wallet.config includes general fields like + // 'enabled' and 'priority' which are not defined in wallet.fields. + return { + ...acc, + [field.name]: wallet.config?.[field.name] || '' + } + }, wallet.config) + + // check if wallet uses the form-level validation built into Formik or a Yup schema + const validateProps = typeof wallet.fieldValidation === 'function' + ? { validate: wallet.fieldValidation } + : { schema: wallet.fieldValidation } + + return ( + +

{wallet.card.title}

+
{wallet.card.subtitle}
+ {!wallet.walletType && } +
{ + try { + const newConfig = !wallet.isConfigured + + // enable wallet if wallet was just configured + if (newConfig) { + values.enabled = true + } + + await wallet.save(values) + + if (values.enabled) wallet.enable() + else wallet.disable() + + toaster.success('saved settings') + router.push('/settings/wallets') + } catch (err) { + console.error(err) + const message = 'failed to attach: ' + err.message || err.toString?.() + toaster.danger(message) + } + }} + > + + {wallet.walletType + ? + : ( + + )} + { + try { + await wallet.delete() + toaster.success('saved settings') + router.push('/settings/wallets') + } catch (err) { + console.error(err) + const message = 'failed to detach: ' + err.message || err.toString?.() + toaster.danger(message) + } + }} + /> + +
+ +
+
+ ) +} + +function WalletFields ({ wallet: { config, fields, isConfigured } }) { + return fields + .map(({ name, label, type, help, optional, editable, ...props }, i) => { + const rawProps = { + ...props, + name, + initialValue: config?.[name], + readOnly: isConfigured && editable === false, + groupClassName: props.hidden ? 'd-none' : undefined, + label: label + ? ( +
+ {label} + {/* help can be a string or object to customize the label */} + {help && ( + + {help.text || help} + + )} + {optional && ( + + {typeof optional === 'boolean' ? 'optional' : {optional}} + + )} +
+ ) + : undefined, + required: !optional, + autoFocus: i === 0 + } + if (type === 'text') { + return + } + if (type === 'password') { + return + } + return null + }) +} diff --git a/pages/settings/wallets/cln.js b/pages/settings/wallets/cln.js deleted file mode 100644 index b8456075..00000000 --- a/pages/settings/wallets/cln.js +++ /dev/null @@ -1,137 +0,0 @@ -import { getGetServerSideProps } from '@/api/ssrApollo' -import { Form, Input } from '@/components/form' -import { CenterLayout } from '@/components/layout' -import { useMe } from '@/components/me' -import { WalletButtonBar, WalletCard } from '@/components/wallet-card' -import { useApolloClient, useMutation } from '@apollo/client' -import { useToast } from '@/components/toast' -import { CLNAutowithdrawSchema } from '@/lib/validate' -import { useRouter } from 'next/router' -import { AutowithdrawSettings, autowithdrawInitial } from '@/components/autowithdraw-shared' -import { REMOVE_WALLET, UPSERT_WALLET_CLN, WALLET_BY_TYPE } from '@/fragments/wallet' -import WalletLogs from '@/components/wallet-logs' -import Info from '@/components/info' -import Text from '@/components/text' -import { Wallet } from '@/lib/constants' - -const variables = { type: Wallet.CLN.type } -export const getServerSideProps = getGetServerSideProps({ query: WALLET_BY_TYPE, variables, authRequired: true }) - -export default function CLN ({ ssrData }) { - const me = useMe() - const toaster = useToast() - const router = useRouter() - const client = useApolloClient() - const [upsertWalletCLN] = useMutation(UPSERT_WALLET_CLN, { - refetchQueries: ['WalletLogs'], - onError: (err) => { - client.refetchQueries({ include: ['WalletLogs'] }) - throw err - } - }) - const [removeWallet] = useMutation(REMOVE_WALLET, { - refetchQueries: ['WalletLogs'], - onError: (err) => { - client.refetchQueries({ include: ['WalletLogs'] }) - throw err - } - }) - - const { walletByType: wallet } = ssrData || {} - - return ( - -

CLN

-
autowithdraw to your Core Lightning node via CLNRest
-
{ - try { - await upsertWalletCLN({ - variables: { - id: wallet?.id, - socket, - rune, - cert, - settings: { - ...settings, - autoWithdrawThreshold: Number(settings.autoWithdrawThreshold), - autoWithdrawMaxFeePercent: Number(settings.autoWithdrawMaxFeePercent) - } - } - }) - toaster.success('saved settings') - router.push('/settings/wallets') - } catch (err) { - console.error(err) - } - }} - > - - invoice only rune - - - {'We only accept runes that *only* allow `method=invoice`.\nRun this to generate one:\n\n```lightning-cli createrune restrictions=\'["method=invoice"]\'```'} - - - - } - name='rune' - clear - hint='must be restricted to method=invoice' - placeholder='S34KtUW-6gqS_hD_9cc_PNhfF-NinZyBOCgr1aIrark9NCZtZXRob2Q9aW52b2ljZQ==' - required - /> - cert optional if from CA (e.g. voltage)} - name='cert' - clear - hint='hex or base64 encoded' - placeholder='LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNNVENDQWRpZ0F3SUJBZ0lRSHBFdFdrcGJwZHV4RVF2eVBPc3NWVEFLQmdncWhrak9QUVFEQWpBdk1SOHcKSFFZRFZRUUtFeFpzYm1RZ1lYVjBiMmRsYm1WeVlYUmxaQ0JqWlhKME1Rd3dDZ1lEVlFRREV3TmliMkl3SGhjTgpNalF3TVRBM01qQXhORE0wV2hjTk1qVXdNekF6TWpBeE5ETTBXakF2TVI4d0hRWURWUVFLRXhac2JtUWdZWFYwCmIyZGxibVZ5WVhSbFpDQmpaWEowTVF3d0NnWURWUVFERXdOaWIySXdXVEFUQmdjcWhrak9QUUlCQmdncWhrak8KUFFNQkJ3TkNBQVJUS3NMVk5oZnhqb1FLVDlkVVdDbzUzSmQwTnBuL1BtYi9LUE02M1JxbU52dFYvdFk4NjJJZwpSbE41cmNHRnBEajhUeFc2OVhIK0pTcHpjWDdlN3N0Um80SFZNSUhTTUE0R0ExVWREd0VCL3dRRUF3SUNwREFUCkJnTlZIU1VFRERBS0JnZ3JCZ0VGQlFjREFUQVBCZ05WSFJNQkFmOEVCVEFEQVFIL01CMEdBMVVkRGdRV0JCVDAKMnh3V25GeHRUNzI0MWxwZlNoNm9FWi9UMWpCN0JnTlZIUkVFZERCeWdnTmliMktDQ1d4dlkyRnNhRzl6ZElJRApZbTlpZ2d4d2IyeGhjaTF1TVMxaWIyS0NGR2h2YzNRdVpHOWphMlZ5TG1sdWRHVnlibUZzZ2dSMWJtbDRnZ3AxCmJtbDRjR0ZqYTJWMGdnZGlkV1pqYjI1dWh3Ui9BQUFCaHhBQUFBQUFBQUFBQUFBQUFBQUFBQUFCaHdTc0VnQUQKTUFvR0NDcUdTTTQ5QkFNQ0EwY0FNRVFDSUEwUTlkRXdoNXpPRnpwL3hYeHNpemh5SkxNVG5yazU1VWx1NHJPRwo4WW52QWlBVGt4U3p3Y3hZZnFscGx0UlNIbmd0NUJFcDBzcXlHL05nenBzb2pmMGNqQT09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K' - /> - - { - try { - await removeWallet({ variables: { id: wallet?.id } }) - toaster.success('saved settings') - router.push('/settings/wallets') - } catch (err) { - console.error(err) - } - }} - /> - -
- -
-
- ) -} - -export function CLNCard ({ wallet }) { - return ( - - ) -} diff --git a/pages/settings/wallets/index.js b/pages/settings/wallets/index.js index 8adbcec5..3bb77c3e 100644 --- a/pages/settings/wallets/index.js +++ b/pages/settings/wallets/index.js @@ -1,29 +1,75 @@ import { getGetServerSideProps } from '@/api/ssrApollo' import Layout from '@/components/layout' import styles from '@/styles/wallet.module.css' -import { WalletCard } from '@/components/wallet-card' -import { LightningAddressWalletCard } from './lightning-address' -import { LNbitsCard } from './lnbits' -import { NWCCard } from './nwc' -import { LNDCard } from './lnd' -import { CLNCard } from './cln' -import { WALLETS } from '@/fragments/wallet' -import { useQuery } from '@apollo/client' -import PageLoading from '@/components/page-loading' -import { LNCCard } from './lnc' import Link from 'next/link' -import { Wallet as W } from '@/lib/constants' +import { useWallets, walletPrioritySort } from 'wallets' +import { useEffect, useState } from 'react' +import dynamic from 'next/dynamic' -export const getServerSideProps = getGetServerSideProps({ query: WALLETS, authRequired: true }) +const WalletCard = dynamic(() => import('@/components/wallet-card'), { ssr: false }) + +export const getServerSideProps = getGetServerSideProps({ authRequired: true }) + +async function reorder (wallets, sourceIndex, targetIndex) { + const newOrder = [...wallets] + + const [source] = newOrder.splice(sourceIndex, 1) + const newTargetIndex = sourceIndex < targetIndex ? targetIndex - 1 : targetIndex + const append = sourceIndex < targetIndex + + newOrder.splice(newTargetIndex + (append ? 1 : 0), 0, source) + + await Promise.all( + newOrder.map((w, i) => + w.setPriority(i).catch(console.error) + ) + ) +} export default function Wallet ({ ssrData }) { - const { data } = useQuery(WALLETS) + const { wallets } = useWallets() - if (!data && !ssrData) return - const { wallets } = data || ssrData - const lnd = wallets.find(w => w.type === W.LND.type) - const lnaddr = wallets.find(w => w.type === W.LnAddr.type) - const cln = wallets.find(w => w.type === W.CLN.type) + const [mounted, setMounted] = useState(false) + const [sourceIndex, setSourceIndex] = useState(null) + const [targetIndex, setTargetIndex] = useState(null) + + useEffect(() => { + // mounted is required since draggable is false + // for wallets only available on the client during SSR + // and thus we need to render the component again on the client + setMounted(true) + }, []) + + const onDragStart = (i) => (e) => { + // e.dataTransfer.dropEffect = 'move' + // We can only use the DataTransfer API inside the drop event + // see https://html.spec.whatwg.org/multipage/dnd.html#security-risks-in-the-drag-and-drop-model + // e.dataTransfer.setData('text/plain', name) + // That's why we use React state instead + setSourceIndex(i) + } + + const onDragEnter = (i) => (e) => { + setTargetIndex(i) + } + + const onDragEnd = async (e) => { + setSourceIndex(null) + setTargetIndex(null) + + if (sourceIndex === targetIndex) return + + await reorder(wallets, sourceIndex, targetIndex) + } + + const onTouchStart = (i) => async (e) => { + if (sourceIndex !== null) { + await reorder(wallets, sourceIndex, i) + setSourceIndex(null) + } else { + setSourceIndex(i) + } + } return ( @@ -35,16 +81,42 @@ export default function Wallet ({ ssrData }) { wallet logs -
- - - - - - - - - +
+ {wallets + .sort((w1, w2) => { + // enabled/configured wallets always come before disabled/unconfigured wallets + if ((w1.enabled && !w2.enabled) || (w1.isConfigured && !w2.isConfigured)) { + return -1 + } else if ((w2.enabled && !w1.enabled) || (w2.isConfigured && !w1.isConfigured)) { + return 1 + } + + return walletPrioritySort(w1, w2) + }) + .map((w, i) => { + const draggable = mounted && w.enabled + + return ( +
+ +
+ ) + } + )} +
diff --git a/pages/settings/wallets/lightning-address.js b/pages/settings/wallets/lightning-address.js deleted file mode 100644 index 38713c8a..00000000 --- a/pages/settings/wallets/lightning-address.js +++ /dev/null @@ -1,106 +0,0 @@ -import { getGetServerSideProps } from '@/api/ssrApollo' -import { Form, Input } from '@/components/form' -import { CenterLayout } from '@/components/layout' -import { useMe } from '@/components/me' -import { WalletButtonBar, WalletCard } from '@/components/wallet-card' -import { useApolloClient, useMutation } from '@apollo/client' -import { useToast } from '@/components/toast' -import { lnAddrAutowithdrawSchema } from '@/lib/validate' -import { useRouter } from 'next/router' -import { AutowithdrawSettings, autowithdrawInitial } from '@/components/autowithdraw-shared' -import { REMOVE_WALLET, UPSERT_WALLET_LNADDR, WALLET_BY_TYPE } from '@/fragments/wallet' -import WalletLogs from '@/components/wallet-logs' -import { Wallet } from '@/lib/constants' - -const variables = { type: Wallet.LnAddr.type } -export const getServerSideProps = getGetServerSideProps({ query: WALLET_BY_TYPE, variables, authRequired: true }) - -export default function LightningAddress ({ ssrData }) { - const me = useMe() - const toaster = useToast() - const router = useRouter() - const client = useApolloClient() - const [upsertWalletLNAddr] = useMutation(UPSERT_WALLET_LNADDR, { - refetchQueries: ['WalletLogs'], - onError: (err) => { - client.refetchQueries({ include: ['WalletLogs'] }) - throw err - } - }) - const [removeWallet] = useMutation(REMOVE_WALLET, { - refetchQueries: ['WalletLogs'], - onError: (err) => { - client.refetchQueries({ include: ['WalletLogs'] }) - throw err - } - }) - - const { walletByType: wallet } = ssrData || {} - - return ( - -

lightning address

-
autowithdraw to a lightning address
-
{ - try { - await upsertWalletLNAddr({ - variables: { - id: wallet?.id, - address, - settings: { - ...settings, - autoWithdrawThreshold: Number(settings.autoWithdrawThreshold), - autoWithdrawMaxFeePercent: Number(settings.autoWithdrawMaxFeePercent) - } - } - }) - toaster.success('saved settings') - router.push('/settings/wallets') - } catch (err) { - console.error(err) - } - }} - > - - - { - try { - await removeWallet({ variables: { id: wallet?.id } }) - toaster.success('saved settings') - router.push('/settings/wallets') - } catch (err) { - console.error(err) - } - }} - /> - -
- -
-
- ) -} - -export function LightningAddressWalletCard ({ wallet }) { - return ( - - ) -} diff --git a/pages/settings/wallets/lnbits.js b/pages/settings/wallets/lnbits.js deleted file mode 100644 index b1905c0b..00000000 --- a/pages/settings/wallets/lnbits.js +++ /dev/null @@ -1,99 +0,0 @@ -import { getGetServerSideProps } from '@/api/ssrApollo' -import { Form, ClientInput, ClientCheckbox, PasswordInput } from '@/components/form' -import { CenterLayout } from '@/components/layout' -import { WalletButtonBar, WalletCard, isConfigured } from '@/components/wallet-card' -import { lnbitsSchema } from '@/lib/validate' -import { useToast } from '@/components/toast' -import { useRouter } from 'next/router' -import { useLNbits } from '@/components/webln/lnbits' -import { WalletSecurityBanner } from '@/components/banners' -import { useWebLNConfigurator } from '@/components/webln' -import WalletLogs from '@/components/wallet-logs' -import { Wallet } from '@/lib/constants' - -export const getServerSideProps = getGetServerSideProps({ authRequired: true }) - -export default function LNbits () { - const { provider, enabledProviders, setProvider } = useWebLNConfigurator() - const lnbits = useLNbits() - const { name, url, adminKey, saveConfig, clearConfig, status } = lnbits - const isDefault = provider?.name === name - const configured = isConfigured(status) - const toaster = useToast() - const router = useRouter() - - return ( - -

LNbits

-
use LNbits for payments
- -
{ - try { - await saveConfig(values) - if (isDefault) setProvider(lnbits) - toaster.success('saved settings') - router.push('/settings/wallets') - } catch (err) { - console.error(err) - toaster.danger('failed to attach: ' + err.message || err.toString?.()) - } - }} - > - - - - { - try { - await clearConfig() - toaster.success('saved settings') - router.push('/settings/wallets') - } catch (err) { - console.error(err) - toaster.danger('failed to detach: ' + err.message || err.toString?.()) - } - }} - /> - -
- -
-
- ) -} - -export function LNbitsCard () { - const { status } = useLNbits() - return ( - - ) -} diff --git a/pages/settings/wallets/lnc.js b/pages/settings/wallets/lnc.js deleted file mode 100644 index 6f97c191..00000000 --- a/pages/settings/wallets/lnc.js +++ /dev/null @@ -1,122 +0,0 @@ -import { getGetServerSideProps } from '@/api/ssrApollo' -import { WalletSecurityBanner } from '@/components/banners' -import { ClientCheckbox, Form, PasswordInput } from '@/components/form' -import Info from '@/components/info' -import { CenterLayout } from '@/components/layout' -import Text from '@/components/text' -import { useToast } from '@/components/toast' -import { WalletButtonBar, WalletCard, isConfigured } from '@/components/wallet-card' -import WalletLogs from '@/components/wallet-logs' -import { Status, useWebLNConfigurator } from '@/components/webln' -import { XXX_DEFAULT_PASSWORD, useLNC } from '@/components/webln/lnc' -import { lncSchema } from '@/lib/validate' -import { useRouter } from 'next/router' -import { useEffect, useRef } from 'react' -import { Wallet } from '@/lib/constants' - -export const getServerSideProps = getGetServerSideProps({ authRequired: true }) - -export default function LNC () { - const { provider, enabledProviders, setProvider } = useWebLNConfigurator() - const toaster = useToast() - const router = useRouter() - const lnc = useLNC() - const { status, clearConfig, saveConfig, config, name, unlock } = lnc - const isDefault = provider?.name === name - const unlocking = useRef(false) - const configured = isConfigured(status) - - useEffect(() => { - if (!unlocking.current && status === Status.Locked) { - unlocking.current = true - unlock() - } - }, [status, unlock]) - - const defaultPassword = config?.password === XXX_DEFAULT_PASSWORD - - return ( - -

Lightning Node Connect for LND

-
use Lightning Node Connect for LND payments
- -
{ - try { - await saveConfig(values) - if (isDefault) setProvider(lnc) - toaster.success('saved settings') - router.push('/settings/wallets') - } catch (err) { - console.error(err) - toaster.danger('failed to attach: ' + err.message || err.toString?.()) - } - }} - > - pairing phrase - - - {'We only need permissions for the uri `/lnrpc.Lightning/SendPaymentSync`\n\nCreate a budgeted account with narrow permissions:\n\n```$ litcli accounts create --balance ```\n\n```$ litcli sessions add --type custom --label --account_id --uri /lnrpc.Lightning/SendPaymentSync```\n\nGrab the `pairing_secret_mnemonic` from the output and paste it here.'} - - - - } - name='pairingPhrase' - initialValue={config?.pairingPhrase} - newPass={config?.pairingPhrase === undefined} - readOnly={configured} - required - autoFocus - /> - password optional} - name='password' - initialValue={defaultPassword ? '' : config?.password} - newPass={config?.password === undefined || defaultPassword} - readOnly={configured} - hint='encrypts your pairing phrase when stored locally' - /> - - { - try { - await clearConfig() - toaster.success('saved settings') - router.push('/settings/wallets') - } catch (err) { - console.error(err) - toaster.danger('failed to detach: ' + err.message || err.toString?.()) - } - }} - /> - -
- -
-
- ) -} - -export function LNCCard () { - const { status } = useLNC() - return ( - - ) -} diff --git a/pages/settings/wallets/lnd.js b/pages/settings/wallets/lnd.js deleted file mode 100644 index 1d5d0858..00000000 --- a/pages/settings/wallets/lnd.js +++ /dev/null @@ -1,137 +0,0 @@ -import { getGetServerSideProps } from '@/api/ssrApollo' -import { Form, Input } from '@/components/form' -import { CenterLayout } from '@/components/layout' -import { useMe } from '@/components/me' -import { WalletButtonBar, WalletCard } from '@/components/wallet-card' -import { useApolloClient, useMutation } from '@apollo/client' -import { useToast } from '@/components/toast' -import { LNDAutowithdrawSchema } from '@/lib/validate' -import { useRouter } from 'next/router' -import { AutowithdrawSettings, autowithdrawInitial } from '@/components/autowithdraw-shared' -import { REMOVE_WALLET, UPSERT_WALLET_LND, WALLET_BY_TYPE } from '@/fragments/wallet' -import Info from '@/components/info' -import Text from '@/components/text' -import WalletLogs from '@/components/wallet-logs' -import { Wallet } from '@/lib/constants' - -const variables = { type: Wallet.LND.type } -export const getServerSideProps = getGetServerSideProps({ query: WALLET_BY_TYPE, variables, authRequired: true }) - -export default function LND ({ ssrData }) { - const me = useMe() - const toaster = useToast() - const router = useRouter() - const client = useApolloClient() - const [upsertWalletLND] = useMutation(UPSERT_WALLET_LND, { - refetchQueries: ['WalletLogs'], - onError: (err) => { - client.refetchQueries({ include: ['WalletLogs'] }) - throw err - } - }) - const [removeWallet] = useMutation(REMOVE_WALLET, { - refetchQueries: ['WalletLogs'], - onError: (err) => { - client.refetchQueries({ include: ['WalletLogs'] }) - throw err - } - }) - - const { walletByType: wallet } = ssrData || {} - - return ( - -

LND

-
autowithdraw to your Lightning Labs node
-
{ - try { - await upsertWalletLND({ - variables: { - id: wallet?.id, - socket, - macaroon, - cert, - settings: { - ...settings, - autoWithdrawThreshold: Number(settings.autoWithdrawThreshold), - autoWithdrawMaxFeePercent: Number(settings.autoWithdrawMaxFeePercent) - } - } - }) - toaster.success('saved settings') - router.push('/settings/wallets') - } catch (err) { - console.error(err) - } - }} - > - - invoice macaroon - - - {'We accept a prebaked ***invoice.macaroon*** for your convenience. To gain better privacy, generate a new macaroon as follows:\n\n```lncli bakemacaroon invoices:write invoices:read```'} - - - - } - name='macaroon' - clear - hint='hex or base64 encoded' - placeholder='AgEDbG5kAlgDChCn7YgfWX7uTkQQgXZ2uahNEgEwGhYKB2FkZHJlc3MSBHJlYWQSBXdyaXRlGhcKCGludm9pY2VzEgRyZWFkEgV3cml0ZRoPCgdvbmNoYWluEgRyZWFkAAAGIJkMBrrDV0npU90JV0TGNJPrqUD8m2QYoTDjolaL6eBs' - required - /> - cert optional if from CA (e.g. voltage)} - name='cert' - clear - hint='hex or base64 encoded' - placeholder='LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNNVENDQWRpZ0F3SUJBZ0lRSHBFdFdrcGJwZHV4RVF2eVBPc3NWVEFLQmdncWhrak9QUVFEQWpBdk1SOHcKSFFZRFZRUUtFeFpzYm1RZ1lYVjBiMmRsYm1WeVlYUmxaQ0JqWlhKME1Rd3dDZ1lEVlFRREV3TmliMkl3SGhjTgpNalF3TVRBM01qQXhORE0wV2hjTk1qVXdNekF6TWpBeE5ETTBXakF2TVI4d0hRWURWUVFLRXhac2JtUWdZWFYwCmIyZGxibVZ5WVhSbFpDQmpaWEowTVF3d0NnWURWUVFERXdOaWIySXdXVEFUQmdjcWhrak9QUUlCQmdncWhrak8KUFFNQkJ3TkNBQVJUS3NMVk5oZnhqb1FLVDlkVVdDbzUzSmQwTnBuL1BtYi9LUE02M1JxbU52dFYvdFk4NjJJZwpSbE41cmNHRnBEajhUeFc2OVhIK0pTcHpjWDdlN3N0Um80SFZNSUhTTUE0R0ExVWREd0VCL3dRRUF3SUNwREFUCkJnTlZIU1VFRERBS0JnZ3JCZ0VGQlFjREFUQVBCZ05WSFJNQkFmOEVCVEFEQVFIL01CMEdBMVVkRGdRV0JCVDAKMnh3V25GeHRUNzI0MWxwZlNoNm9FWi9UMWpCN0JnTlZIUkVFZERCeWdnTmliMktDQ1d4dlkyRnNhRzl6ZElJRApZbTlpZ2d4d2IyeGhjaTF1TVMxaWIyS0NGR2h2YzNRdVpHOWphMlZ5TG1sdWRHVnlibUZzZ2dSMWJtbDRnZ3AxCmJtbDRjR0ZqYTJWMGdnZGlkV1pqYjI1dWh3Ui9BQUFCaHhBQUFBQUFBQUFBQUFBQUFBQUFBQUFCaHdTc0VnQUQKTUFvR0NDcUdTTTQ5QkFNQ0EwY0FNRVFDSUEwUTlkRXdoNXpPRnpwL3hYeHNpemh5SkxNVG5yazU1VWx1NHJPRwo4WW52QWlBVGt4U3p3Y3hZZnFscGx0UlNIbmd0NUJFcDBzcXlHL05nenBzb2pmMGNqQT09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K' - /> - - { - try { - await removeWallet({ variables: { id: wallet?.id } }) - toaster.success('saved settings') - router.push('/settings/wallets') - } catch (err) { - console.error(err) - } - }} - /> - -
- -
-
- ) -} - -export function LNDCard ({ wallet }) { - return ( - - ) -} diff --git a/pages/settings/wallets/nwc.js b/pages/settings/wallets/nwc.js deleted file mode 100644 index 81800832..00000000 --- a/pages/settings/wallets/nwc.js +++ /dev/null @@ -1,92 +0,0 @@ -import { getGetServerSideProps } from '@/api/ssrApollo' -import { Form, ClientCheckbox, PasswordInput } from '@/components/form' -import { CenterLayout } from '@/components/layout' -import { WalletButtonBar, WalletCard, isConfigured } from '@/components/wallet-card' -import { nwcSchema } from '@/lib/validate' -import { useToast } from '@/components/toast' -import { useRouter } from 'next/router' -import { useNWC } from '@/components/webln/nwc' -import { WalletSecurityBanner } from '@/components/banners' -import { useWebLNConfigurator } from '@/components/webln' -import WalletLogs from '@/components/wallet-logs' -import { Wallet } from '@/lib/constants' - -export const getServerSideProps = getGetServerSideProps({ authRequired: true }) - -export default function NWC () { - const { provider, enabledProviders, setProvider } = useWebLNConfigurator() - const nwc = useNWC() - const { name, nwcUrl, saveConfig, clearConfig, status } = nwc - const isDefault = provider?.name === name - const configured = isConfigured(status) - const toaster = useToast() - const router = useRouter() - - return ( - -

Nostr Wallet Connect

-
use Nostr Wallet Connect for payments
- -
{ - try { - await saveConfig(values) - if (isDefault) setProvider(nwc) - toaster.success('saved settings') - router.push('/settings/wallets') - } catch (err) { - console.error(err) - toaster.danger('failed to attach: ' + err.message || err.toString?.()) - } - }} - > - - - { - try { - await clearConfig() - toaster.success('saved settings') - router.push('/settings/wallets') - } catch (err) { - console.error(err) - toaster.danger('failed to detach: ' + err.message || err.toString?.()) - } - }} - /> - -
- -
-
- ) -} - -export function NWCCard () { - const { status } = useNWC() - return ( - - ) -} diff --git a/pages/wallet/logs.js b/pages/wallet/logs.js index debad4c2..e207a32c 100644 --- a/pages/wallet/logs.js +++ b/pages/wallet/logs.js @@ -1,6 +1,6 @@ import { CenterLayout } from '@/components/layout' import { getGetServerSideProps } from '@/api/ssrApollo' -import WalletLogs from '@/components/wallet-logs' +import { WalletLogs } from '@/components/wallet-logger' export const getServerSideProps = getGetServerSideProps({ query: null }) diff --git a/prisma/migrations/20240705062557_wallet_enabled/migration.sql b/prisma/migrations/20240705062557_wallet_enabled/migration.sql new file mode 100644 index 00000000..519ef61e --- /dev/null +++ b/prisma/migrations/20240705062557_wallet_enabled/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Wallet" ADD COLUMN "enabled" BOOLEAN NOT NULL DEFAULT true; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9aef402c..101953bd 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -173,6 +173,7 @@ model Wallet { updatedAt DateTime @default(now()) @updatedAt @map("updated_at") userId Int label String? + enabled Boolean @default(true) priority Int @default(0) user User @relation(fields: [userId], references: [id], onDelete: Cascade) diff --git a/styles/wallet.module.css b/styles/wallet.module.css index eb1605ea..b4efe607 100644 --- a/styles/wallet.module.css +++ b/styles/wallet.module.css @@ -7,6 +7,14 @@ margin-top: 3rem; } +.drag { + opacity: 33%; +} + +.drop { + box-shadow: 0 0 10px var(--bs-info); +} + .card { width: 160px; height: 180px; diff --git a/wallets/README.md b/wallets/README.md new file mode 100644 index 00000000..4f5ff71f --- /dev/null +++ b/wallets/README.md @@ -0,0 +1,236 @@ +# Wallets + +Every wallet that you can see at [/settings/wallets](https://stacker.news/settings/wallets) is implemented as a plugin in this directory. + +This README explains how you can add another wallet for use with Stacker News. + +> [!NOTE] +> Plugin means here that you only have to implement a common interface in this directory to add a wallet. + +## Plugin interface + +Every wallet is defined inside its own directory. Every directory must contain an _index.js_ and a _client.js_ file. + +An index.js file exports properties that can be shared by the client and server. + +Wallets that have spending permissions / can pay invoices export the payment interface in client.js. These permissions are stored on the client.[^1] + +[^1]: unencrypted in local storage until we have implemented encrypted local storage. + +A _server.js_ file is only required for wallets that support receiving by exposing the corresponding interface in that file. These wallets are stored on the server because payments are coordinated on the server so the server needs to generate these invoices for receiving. Additionally, permissions to receive a payment are not as sensitive as permissions to send a payment (highly sensitive!). + +> [!NOTE] +> Every wallet must have a client.js file (even if it does not support paying invoices) because every wallet is imported on the client. This is not the case on the server. On the client, wallets are imported via +> +> ```js +> import wallet from 'wallets//client' +> ``` +> +> vs +> +> ```js +> import wallet from 'wallets//server' +> ``` +> +> on the server. +> +> To have access to the properties that can be shared between client and server, server.js and client.js always reexport everything in index.js with a line like this: +> +> ```js +> export * from 'wallets/' +> ``` +> +> If a wallet does not support paying invoices, this is all that client.js of this wallet does. The reason for this structure is to make sure the client does not import dependencies that can only be imported on the server and would thus break the build. + +> [!WARNING] +> Wallets that support spending **AND** receiving have not been tested yet. For now, only implement either the interface for spending **OR** receiving until this warning is removed. + +> [!TIP] +> Don't hesitate to use the implementation of existing wallets as a reference. + +### index.js + +An index.js file exports the following properties that are shared by imports of this wallet on the server and wallet: + +- `name: string` + +This acts as an ID for this wallet on the client. It therefore must be unique across all wallets and is used throughout the code to reference this wallet. This name is also shown in the [wallet logs](https://stacker.news/wallet/logs). + +- `shortName?: string` + +Since `name` will also be used in [wallet logs](https://stacker.news/wallet/logs), you can specify a shorter name here which will be used in logs instead. + +- `fields: WalletField[]` + +Wallet fields define what this wallet requires for configuration and thus are used to construct the forms like the one you can see at [/settings/wallets/lnbits](https://stacker.news/settings/walletslnbits). + +- `card: WalletCard` + +Wallet cards are the components you can see at [/settings/wallets](https://stacker.news/settings/wallets). This property customizes this card for this wallet. + +- `fieldValidation: (config) => { [key: string]: string } | Yup.ObjectSchema` + +This property defines how Formik should perform form-level validation. As mentioned in the [documentation](https://formik.org/docs/guides/validation#form-level-validation), Formik supports two ways to perform such validation. + +If a function is used for `fieldValidation`, the built-in form-level validation is used via the [`validate`](https://formik.org/docs/guides/validation#validate) property of the Formik form component. + +If a [Yup object schema](https://github.com/jquense/yup?tab=readme-ov-file#object) is set, [`validationSchema`](https://formik.org/docs/guides/validation#validationschema) will be used instead. + +This validation is triggered on every submit and on every change after the first submit attempt. + +Refer to the [Formik documentation](https://formik.org/docs/guides/validation) for more details. + +- `walletType?: string` + +This field is only required if this wallet supports receiving payments. It must match a value of the enum `WalletType` in the database. + +- `walletField?: string` + +Just like `walletType`, this field is only required if this wallet supports receiving payments. It must match a column in the `Wallet` table. + +> [!NOTE] +> This is the only exception where you have to write code outside this directory for a wallet that supports receiving: you need to write a database migration to add a new enum value to `WalletType` and column to `Wallet`. See the top-level [README](../README.md#database-migrations) for how to do this. + +#### WalletField + +A wallet field is an object with the following properties: + +- `name: string` + +The configuration key. This is used by [Formik](https://formik.org/docs/overview) to map values to the correct input. This key is also what is used to save values in local storage or the database. For wallets that are stored on the server, this must therefore match a column in the corresponding table for wallets of this type. + +- `label: string` + +The label of the configuration key. Will be shown to the user in the form. + +- `type: 'text' | 'password'` + +The input type that should be used for this value. For example, if the type is `password`, the input value will be hidden by default using a component for passwords. + +- `optional?: boolean | string = false` + +This property can be used to mark a wallet field as optional. If it is not set, we will assume this field is required else 'optional' will be shown to the user next to the label. You can use Markdown to customize this text. + +- `help?: string | { label: string, text: string }` + +If this property is set, a help icon will be shown to the user. On click, the specified text in Markdown is shown. If you additionally want to customize the icon label, you can use the object syntax. + +- `editable?: boolean = true` + +If this property is set to `false`, you can only configure this value once. Afterwards, it's read-only. To configure it again, you have to detach the wallet first. + +- `placeholder?: string = ''` + +Placeholder text to show an example value to the user before they click into the input. + +- `hint?: string = ''` + +If a hint is set, it will be shown below the input. + +- `clear?: boolean = false` + +If a button to clear the input after it has been set should be shown, set this property to `true`. + +- `autoComplete?: HTMLAttribute<'autocomplete'>` + +This property controls the HTML `autocomplete` attribute. See [the documentation](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete) for possible values. Not setting it usually means that the user agent can use autocompletion. This property has no effect for passwords. Autocompletion is always turned off for passwords to prevent passwords getting saved for security reasons. + +#### WalletCard + +- `title: string` + +The card title. + +- `subtitle: string` + +The subtitle that is shown below the title if you enter the configuration form of a wallet. + +- `badges: string[]` + +The badges that are shown inside the card. + +### client.js + +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` + +`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`. + +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. + +This function must throw an error if the configuration was found to be invalid. + +The `context` argument is an object. It makes the wallet logger for this wallet as returned by `useWalletLogger` available under `context.logger`. See [components/wallet-logger.js](../components/wallet-logger.js). + +- `sendPayment: async (bolt11: string, config, context) => Promise<{ preimage: string }>` + +`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`. + +> [!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: +> +> ```js +> export * from 'wallets/' +> ``` +> +> where `` is the wallet directory name. + +> [!IMPORTANT] +> After you're done implementing the interface, you need to import this wallet in _wallets/client.js_ and add it to the array that is the default export of that file to make this wallet available across the code: +> +> ```diff +> // wallets/client.js +> import * as nwc from 'wallets/nwc/client' +> import * as lnbits from 'wallets/lnbits/client' +> import * as lnc from 'wallets/lnc/client' +> import * as lnAddr from 'wallets/lightning-address/client' +> import * as cln from 'wallets/cln/client' +> import * as lnd from 'wallets/lnd/client' +> + import * as newWallet from 'wallets//client' +> +> - export default [nwc, lnbits, lnc, lnAddr, cln, lnd] +> + export default [nwc, lnbits, lnc, lnAddr, cln, lnd, newWallet] +> ``` + +### server.js + +A wallet that supports receiving file 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` + +`testConnectServer` 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. However, unlike `testConnectClient`, 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. 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. + + +> [!IMPORTANT] +> Don't forget to include the following line: +> +> ```js +> export * from 'wallets/' +> ``` +> +> where `` is the wallet directory name. + +> [!IMPORTANT] +> After you're done implementing the interface, you need to import this wallet in _wallets/server.js_ and add it to the array that is the default export of that file to make this wallet available across the code: +> +> ```diff +> // wallets/server.js +> import * as lnd from 'wallets/lnd/server' +> import * as cln from 'wallets/cln/server' +> import * as lnAddr from 'wallets/lightning-address/server' +> + import * as newWallet from 'wallets//client' +> +> - export default [lnd, cln, lnAddr] +> + export default [lnd, cln, lnAddr, newWallet] +> ``` \ No newline at end of file diff --git a/wallets/client.js b/wallets/client.js new file mode 100644 index 00000000..2cfffcec --- /dev/null +++ b/wallets/client.js @@ -0,0 +1,8 @@ +import * as nwc from 'wallets/nwc/client' +import * as lnbits from 'wallets/lnbits/client' +import * as lnc from 'wallets/lnc/client' +import * as lnAddr from 'wallets/lightning-address/client' +import * as cln from 'wallets/cln/client' +import * as lnd from 'wallets/lnd/client' + +export default [nwc, lnbits, lnc, lnAddr, cln, lnd] diff --git a/wallets/cln/ATTACH.md b/wallets/cln/ATTACH.md new file mode 100644 index 00000000..6d282628 --- /dev/null +++ b/wallets/cln/ATTACH.md @@ -0,0 +1,25 @@ +For testing cln as an attached receiving wallet, you'll need a rune and the cert. + +# host and port + +`stacker_cln:3010` + +# create rune + +```bash +sndev stacker_clncli --regtest createrune restrictions='["method=invoice"]' +``` + +# get cert + +This is static in dev env so you can use this one: + +```bash +LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tDQpNSUlCY2pDQ0FSaWdBd0lCQWdJSkFOclN2UFovWTNLRU1Bb0dDQ3FHU000OUJBTUNNQll4RkRBU0JnTlZCQU1NDQpDMk5zYmlCU2IyOTBJRU5CTUNBWERUYzFNREV3TVRBd01EQXdNRm9ZRHpRd09UWXdNVEF4TURBd01EQXdXakFXDQpNUlF3RWdZRFZRUUREQXRqYkc0Z1VtOXZkQ0JEUVRCWk1CTUdCeXFHU000OUFnRUdDQ3FHU000OUF3RUhBMElBDQpCQmptYUh1dWxjZ3dTR09ubExBSFlRbFBTUXdHWEROSld5ZnpWclY5aFRGYUJSZFFrMVl1Y3VqVFE5QXFybkVJDQpyRmR6MS9PeisyWFhENmdBMnhPbmIrNmpUVEJMTUJrR0ExVWRFUVFTTUJDQ0EyTnNib0lKYkc5allXeG9iM04wDQpNQjBHQTFVZERnUVdCQlNFY21OLzlyelMyaFI2RzdFSWdzWCs1MU4wQ2pBUEJnTlZIUk1CQWY4RUJUQURBUUgvDQpNQW9HQ0NxR1NNNDlCQU1DQTBnQU1FVUNJSENlUHZOU3Z5aUJZYXdxS2dRcXV3OUoyV1Z5SnhuMk1JWUlxejlTDQpRTDE4QWlFQWg4QlZEejhwWDdOc2xsOHNiMGJPMFJaNDljdnFRb2NDZ1ZhYnFKdVN1aWs9DQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tDQo= +``` + +Which is generated with the following command + +```bash +openssl base64 -A -in docker/cln/ca.pem +``` \ No newline at end of file diff --git a/wallets/cln/client.js b/wallets/cln/client.js new file mode 100644 index 00000000..d9192b05 --- /dev/null +++ b/wallets/cln/client.js @@ -0,0 +1 @@ +export * from 'wallets/cln' diff --git a/wallets/cln/index.js b/wallets/cln/index.js new file mode 100644 index 00000000..644b7748 --- /dev/null +++ b/wallets/cln/index.js @@ -0,0 +1,46 @@ +import { CLNAutowithdrawSchema } from '@/lib/validate' + +export const name = 'cln' + +export const fields = [ + { + name: 'socket', + label: 'rest host and port', + type: 'text', + placeholder: '55.5.555.55:3010', + hint: 'tor or clearnet', + clear: true + }, + { + name: 'rune', + label: 'invoice only rune', + help: { + text: 'We only accept runes that *only* allow `method=invoice`.\nRun this to generate one:\n\n```lightning-cli createrune restrictions=\'["method=invoice"]\'```' + }, + type: 'text', + placeholder: 'S34KtUW-6gqS_hD_9cc_PNhfF-NinZyBOCgr1aIrark9NCZtZXRob2Q9aW52b2ljZQ==', + hint: 'must be restricted to method=invoice', + clear: true + }, + { + name: 'cert', + label: 'cert', + type: 'text', + placeholder: 'LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNNVENDQWRpZ0F3SUJBZ0lRSHBFdFdrcGJwZHV4RVF2eVBPc3NWVEFLQmdncWhrak9QUVFEQWpBdk1SOHcKSFFZRFZRUUtFeFpzYm1RZ1lYVjBiMmRsYm1WeVlYUmxaQ0JqWlhKME1Rd3dDZ1lEVlFRREV3TmliMkl3SGhjTgpNalF3TVRBM01qQXhORE0wV2hjTk1qVXdNekF6TWpBeE5ETTBXakF2TVI4d0hRWURWUVFLRXhac2JtUWdZWFYwCmIyZGxibVZ5WVhSbFpDQmpaWEowTVF3d0NnWURWUVFERXdOaWIySXdXVEFUQmdjcWhrak9QUUlCQmdncWhrak8KUFFNQkJ3TkNBQVJUS3NMVk5oZnhqb1FLVDlkVVdDbzUzSmQwTnBuL1BtYi9LUE02M1JxbU52dFYvdFk4NjJJZwpSbE41cmNHRnBEajhUeFc2OVhIK0pTcHpjWDdlN3N0Um80SFZNSUhTTUE0R0ExVWREd0VCL3dRRUF3SUNwREFUCkJnTlZIU1VFRERBS0JnZ3JCZ0VGQlFjREFUQVBCZ05WSFJNQkFmOEVCVEFEQVFIL01CMEdBMVVkRGdRV0JCVDAKMnh3V25GeHRUNzI0MWxwZlNoNm9FWi9UMWpCN0JnTlZIUkVFZERCeWdnTmliMktDQ1d4dlkyRnNhRzl6ZElJRApZbTlpZ2d4d2IyeGhjaTF1TVMxaWIyS0NGR2h2YzNRdVpHOWphMlZ5TG1sdWRHVnlibUZzZ2dSMWJtbDRnZ3AxCmJtbDRjR0ZqYTJWMGdnZGlkV1pqYjI1dWh3Ui9BQUFCaHhBQUFBQUFBQUFBQUFBQUFBQUFBQUFCaHdTc0VnQUQKTUFvR0NDcUdTTTQ5QkFNQ0EwY0FNRVFDSUEwUTlkRXdoNXpPRnpwL3hYeHNpemh5SkxNVG5yazU1VWx1NHJPRwo4WW52QWlBVGt4U3p3Y3hZZnFscGx0UlNIbmd0NUJFcDBzcXlHL05nenBzb2pmMGNqQT09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K', + optional: 'optional if from [CA](https://en.wikipedia.org/wiki/Certificate_authority) (e.g. voltage)', + hint: 'hex or base64 encoded', + clear: true + } +] + +export const card = { + title: 'CLN', + subtitle: 'autowithdraw to your Core Lightning node via [CLNRest](https://docs.corelightning.org/docs/rest)', + badges: ['receive only'] +} + +export const fieldValidation = CLNAutowithdrawSchema + +export const walletType = 'CLN' + +export const walletField = 'walletCLN' diff --git a/wallets/cln/server.js b/wallets/cln/server.js new file mode 100644 index 00000000..cb16e210 --- /dev/null +++ b/wallets/cln/server.js @@ -0,0 +1,40 @@ +import { ensureB64 } from '@/lib/format' +import { createInvoice as clnCreateInvoice } from '@/lib/cln' +import { addWalletLog } from '@/api/resolvers/wallet' + +export * from 'wallets/cln' + +export const testConnectServer = async ( + { socket, rune, cert }, + { me, models } +) => { + cert = ensureB64(cert) + const inv = await clnCreateInvoice({ + socket, + rune, + cert, + description: 'SN connection test', + msats: 'any', + expiry: 0 + }) + await addWalletLog({ wallet: { type: 'CLN' }, level: 'SUCCESS', message: 'connected to CLN' }, { me, models }) + return inv +} + +export const createInvoice = async ( + { amount }, + { socket, rune, cert }, + { me, models, lnd } +) => { + cert = ensureB64(cert) + + const inv = await clnCreateInvoice({ + socket, + rune, + cert, + description: me.hideInvoiceDesc ? undefined : 'autowithdraw to CLN from SN', + msats: amount + 'sat', + expiry: 360 + }) + return inv.bolt11 +} diff --git a/wallets/index.js b/wallets/index.js new file mode 100644 index 00000000..aadbd63a --- /dev/null +++ b/wallets/index.js @@ -0,0 +1,321 @@ +import { useCallback } from 'react' +import { useMe } from '@/components/me' +import useLocalConfig from '@/components/use-local-state' +import { useWalletLogger } from '@/components/wallet-logger' +import { SSR } from '@/lib/constants' +import { bolt11Tags } from '@/lib/bolt11' + +import walletDefs from 'wallets/client' +import { gql, useApolloClient, useQuery } from '@apollo/client' +import { REMOVE_WALLET, WALLET_BY_TYPE } from '@/fragments/wallet' +import { autowithdrawInitial } from '@/components/autowithdraw-shared' +import { useShowModal } from '@/components/modal' +import { useToast } from '../components/toast' +import { generateResolverName } from '@/lib/wallet' + +export const Status = { + Initialized: 'Initialized', + Enabled: 'Enabled', + Locked: 'Locked', + Error: 'Error' +} + +export function useWallet (name) { + const me = useMe() + const showModal = useShowModal() + const toaster = useToast() + + const wallet = name ? getWalletByName(name) : getEnabledWallet(me) + const { logger, deleteLogs } = useWalletLogger(wallet) + + const [config, saveConfig, clearConfig] = useConfig(wallet) + const _isConfigured = isConfigured({ ...wallet, config }) + + const status = config?.enabled ? Status.Enabled : Status.Initialized + const enabled = status === Status.Enabled + const priority = config?.priority + + const sendPayment = useCallback(async (bolt11) => { + const hash = bolt11Tags(bolt11).payment_hash + logger.info('sending payment:', `payment_hash=${hash}`) + try { + const { preimage } = await wallet.sendPayment(bolt11, config, { me, logger, status, showModal }) + logger.ok('payment successful:', `payment_hash=${hash}`, `preimage=${preimage}`) + } catch (err) { + const message = err.message || err.toString?.() + logger.error('payment failed:', `payment_hash=${hash}`, message) + throw err + } + }, [me, wallet, config, logger, status]) + + const enable = useCallback(() => { + enableWallet(name, me) + logger.ok('wallet enabled') + }, [name, me, logger]) + + const disable = useCallback(() => { + disableWallet(name, me) + logger.info('wallet disabled') + }, [name, me, logger]) + + const setPriority = useCallback(async (priority) => { + if (_isConfigured && priority !== config.priority) { + try { + await saveConfig({ ...config, priority }) + } catch (err) { + toaster.danger(`failed to change priority of ${wallet.name} wallet: ${err.message}`) + } + } + }, [wallet, config, logger, toaster]) + + const save = useCallback(async (newConfig) => { + try { + // testConnectClient should log custom INFO and OK message + // testConnectClient is optional since validation might happen during save on server + // TODO: add timeout + const validConfig = await wallet.testConnectClient?.(newConfig, { me, logger }) + await saveConfig(validConfig ?? newConfig) + logger.ok(_isConfigured ? 'wallet updated' : 'wallet attached') + } catch (err) { + const message = err.message || err.toString?.() + logger.error('failed to attach: ' + message) + throw err + } + }, [_isConfigured, saveConfig, me, logger]) + + // delete is a reserved keyword + const delete_ = useCallback(async () => { + try { + await clearConfig() + logger.ok('wallet detached') + disable() + } catch (err) { + const message = err.message || err.toString?.() + logger.error(message) + throw err + } + }, [clearConfig, logger, disable]) + + if (!wallet) return null + + return { + ...wallet, + sendPayment, + config, + save, + delete: delete_, + deleteLogs, + enable, + disable, + setPriority, + isConfigured: _isConfigured, + status, + enabled, + priority, + logger + } +} + +function useConfig (wallet) { + const me = useMe() + + const storageKey = getStorageKey(wallet?.name, me) + const [localConfig, setLocalConfig, clearLocalConfig] = useLocalConfig(storageKey) + + const [serverConfig, setServerConfig, clearServerConfig] = useServerConfig(wallet) + + const hasLocalConfig = !!wallet?.sendPayment + const hasServerConfig = !!wallet?.walletType + + const config = { + // only include config if it makes sense for this wallet + // since server config always returns default values for autowithdraw settings + // which might be confusing to have for wallets that don't support autowithdraw + ...(hasLocalConfig ? localConfig : {}), + ...(hasServerConfig ? serverConfig : {}) + } + + const saveConfig = useCallback(async (config) => { + if (hasLocalConfig) setLocalConfig(config) + if (hasServerConfig) await setServerConfig(config) + }, [wallet]) + + const clearConfig = useCallback(async () => { + if (hasLocalConfig) clearLocalConfig() + if (hasServerConfig) await clearServerConfig() + }, [wallet]) + + return [config, saveConfig, clearConfig] +} + +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] + }) + + return val +} + +function useServerConfig (wallet) { + const client = useApolloClient() + const me = useMe() + + const { data, refetch: refetchConfig } = useQuery(WALLET_BY_TYPE, { variables: { type: wallet?.walletType }, skip: !wallet?.walletType }) + + const walletId = data?.walletByType?.id + const serverConfig = { + id: walletId, + priority: data?.walletByType?.priority, + enabled: data?.walletByType?.enabled, + ...data?.walletByType?.wallet + } + const autowithdrawSettings = autowithdrawInitial({ me }) + const config = { ...serverConfig, ...autowithdrawSettings } + + const saveConfig = useCallback(async ({ + autoWithdrawThreshold, + autoWithdrawMaxFeePercent, + priority, + enabled, + ...config + }) => { + try { + const mutation = generateMutation(wallet) + return await client.mutate({ + mutation, + variables: { + id: walletId, + ...config, + settings: { + autoWithdrawThreshold: Number(autoWithdrawThreshold), + autoWithdrawMaxFeePercent: Number(autoWithdrawMaxFeePercent), + priority, + enabled + } + } + }) + } finally { + client.refetchQueries({ include: ['WalletLogs'] }) + refetchConfig() + } + }, [client, walletId]) + + const clearConfig = useCallback(async () => { + try { + await client.mutate({ + mutation: REMOVE_WALLET, + variables: { id: walletId } + }) + } finally { + client.refetchQueries({ include: ['WalletLogs'] }) + refetchConfig() + } + }, [client, walletId]) + + return [config, saveConfig, clearConfig] +} + +function generateMutation (wallet) { + const resolverName = generateResolverName(wallet.walletField) + + let headerArgs = '$id: ID, ' + headerArgs += wallet.fields.map(f => { + let arg = `$${f.name}: String` + if (!f.optional) { + arg += '!' + } + return arg + }).join(', ') + headerArgs += ', $settings: AutowithdrawSettings!' + + let inputArgs = 'id: $id, ' + inputArgs += wallet.fields.map(f => `${f.name}: $${f.name}`).join(', ') + inputArgs += ', settings: $settings' + + return gql`mutation ${resolverName}(${headerArgs}) { + ${resolverName}(${inputArgs}) + }` +} + +export function getWalletByName (name) { + return walletDefs.find(def => def.name === name) +} + +export function getWalletByType (type) { + return walletDefs.find(def => def.walletType === type) +} + +export function getEnabledWallet (me) { + return walletDefs + .filter(def => !!def.sendPayment) + .map(def => { + // populate definition with properties from useWallet that are required for sorting + const key = getStorageKey(def.name, me) + const config = SSR ? null : JSON.parse(window?.localStorage.getItem(key)) + const priority = config?.priority + return { ...def, config, priority } + }) + .filter(({ config }) => config?.enabled) + .sort(walletPrioritySort)[0] +} + +export function walletPrioritySort (w1, w2) { + const delta = w1.priority - w2.priority + // delta is NaN if either priority is undefined + if (!Number.isNaN(delta) && delta !== 0) return delta + + // if one wallet has a priority but the other one doesn't, the one with the priority comes first + if (w1.priority !== undefined && w2.priority === undefined) return -1 + if (w1.priority === undefined && w2.priority !== undefined) return 1 + + // both wallets have no priority set, falling back to other methods + + // if both wallets have an id, use that as tie breaker + // since that's the order in which autowithdrawals are attempted + if (w1.config?.id && w2.config?.id) return Number(w1.config.id) - Number(w2.config.id) + + // else we will use the card title as tie breaker + return w1.card.title < w2.card.title ? -1 : 1 +} + +export function useWallets () { + const wallets = walletDefs.map(def => useWallet(def.name)) + + const resetClient = useCallback(async (wallet) => { + for (const w of wallets) { + if (w.sendPayment) { + await w.delete() + } + await w.deleteLogs() + } + }, [wallets]) + + return { wallets, resetClient } +} + +function getStorageKey (name, me) { + let storageKey = `wallet:${name}` + if (me) { + storageKey = `${storageKey}:${me.id}` + } + return storageKey +} + +function enableWallet (name, me) { + const key = getStorageKey(name, me) + const config = JSON.parse(window.localStorage.getItem(key)) + if (!config) return + config.enabled = true + window.localStorage.setItem(key, JSON.stringify(config)) +} + +function disableWallet (name, me) { + const key = getStorageKey(name, me) + const config = JSON.parse(window.localStorage.getItem(key)) + if (!config) return + config.enabled = false + window.localStorage.setItem(key, JSON.stringify(config)) +} diff --git a/wallets/lightning-address/ATTACH.md b/wallets/lightning-address/ATTACH.md new file mode 100644 index 00000000..a8ca4ad2 --- /dev/null +++ b/wallets/lightning-address/ATTACH.md @@ -0,0 +1,3 @@ +For testing lightning address autowithdraw, you'll need to reference a host reachable by the worker, e.g. `app:3000`. + +You'll want to deposit in another nym's account using an address like: `nym@app:3000`. \ No newline at end of file diff --git a/wallets/lightning-address/client.js b/wallets/lightning-address/client.js new file mode 100644 index 00000000..004c4e76 --- /dev/null +++ b/wallets/lightning-address/client.js @@ -0,0 +1 @@ +export * from 'wallets/lightning-address' diff --git a/wallets/lightning-address/index.js b/wallets/lightning-address/index.js new file mode 100644 index 00000000..ff502a3a --- /dev/null +++ b/wallets/lightning-address/index.js @@ -0,0 +1,25 @@ +import { lnAddrAutowithdrawSchema } from '@/lib/validate' + +export const name = 'lightning-address' +export const shortName = 'lnAddr' + +export const fields = [ + { + name: 'address', + label: 'lightning address', + type: 'text', + autoComplete: 'off' + } +] + +export const card = { + title: 'lightning address', + subtitle: 'autowithdraw to a lightning address', + badges: ['receive only'] +} + +export const fieldValidation = lnAddrAutowithdrawSchema + +export const walletType = 'LIGHTNING_ADDRESS' + +export const walletField = 'walletLightningAddress' diff --git a/wallets/lightning-address/server.js b/wallets/lightning-address/server.js new file mode 100644 index 00000000..e4e97878 --- /dev/null +++ b/wallets/lightning-address/server.js @@ -0,0 +1,28 @@ +import { addWalletLog, fetchLnAddrInvoice } from '@/api/resolvers/wallet' +import { lnAddrOptions } from '@/lib/lnurl' + +export * from 'wallets/lightning-address' + +export const testConnectServer = async ( + { address }, + { me, models } +) => { + const options = await lnAddrOptions(address) + await addWalletLog({ wallet: { type: 'LIGHTNING_ADDRESS' }, level: 'SUCCESS', message: 'fetched payment details' }, { me, models }) + return options +} + +export const createInvoice = async ( + { amount, maxFee }, + { address }, + { me, models, lnd, lnService } +) => { + const res = await fetchLnAddrInvoice({ addr: address, amount, maxFee }, { + me, + models, + lnd, + lnService, + autoWithdraw: true + }) + return res.pr +} diff --git a/wallets/lnbits/client.js b/wallets/lnbits/client.js new file mode 100644 index 00000000..38c74f66 --- /dev/null +++ b/wallets/lnbits/client.js @@ -0,0 +1,80 @@ +export * from 'wallets/lnbits' + +export async function testConnectClient ({ url, adminKey }, { logger }) { + logger.info('trying to fetch wallet') + + url = url.replace(/\/+$/, '') + await getWallet({ url, adminKey }) + + logger.ok('wallet found') +} + +export async function sendPayment (bolt11, { url, adminKey }) { + url = url.replace(/\/+$/, '') + + const response = await postPayment(bolt11, { url, adminKey }) + + const checkResponse = await getPayment(response.payment_hash, { url, adminKey }) + if (!checkResponse.preimage) { + throw new Error('No preimage') + } + + const preimage = checkResponse.preimage + return { preimage } +} + +async function getWallet ({ url, adminKey }) { + const path = '/api/v1/wallet' + + const headers = new Headers() + headers.append('Accept', 'application/json') + headers.append('Content-Type', 'application/json') + headers.append('X-Api-Key', adminKey) + + const res = await fetch(url + path, { method: 'GET', headers }) + if (!res.ok) { + const errBody = await res.json() + throw new Error(errBody.detail) + } + + const wallet = await res.json() + return wallet +} + +async function postPayment (bolt11, { url, adminKey }) { + const path = '/api/v1/payments' + + const headers = new Headers() + headers.append('Accept', 'application/json') + headers.append('Content-Type', 'application/json') + headers.append('X-Api-Key', adminKey) + + const body = JSON.stringify({ bolt11, out: true }) + + const res = await fetch(url + path, { method: 'POST', headers, body }) + if (!res.ok) { + const errBody = await res.json() + throw new Error(errBody.detail) + } + + const payment = await res.json() + return payment +} + +async function getPayment (paymentHash, { url, adminKey }) { + const path = `/api/v1/payments/${paymentHash}` + + const headers = new Headers() + headers.append('Accept', 'application/json') + headers.append('Content-Type', 'application/json') + headers.append('X-Api-Key', adminKey) + + const res = await fetch(url + path, { method: 'GET', headers }) + if (!res.ok) { + const errBody = await res.json() + throw new Error(errBody.detail) + } + + const payment = await res.json() + return payment +} diff --git a/wallets/lnbits/index.js b/wallets/lnbits/index.js new file mode 100644 index 00000000..7d228e33 --- /dev/null +++ b/wallets/lnbits/index.js @@ -0,0 +1,24 @@ +import { lnbitsSchema } from '@/lib/validate' + +export const name = 'lnbits' + +export const fields = [ + { + name: 'url', + label: 'lnbits url', + type: 'text' + }, + { + name: 'adminKey', + label: 'admin key', + type: 'password' + } +] + +export const card = { + title: 'LNbits', + subtitle: 'use [LNbits](https://lnbits.com/) for payments', + badges: ['send only'] +} + +export const fieldValidation = lnbitsSchema diff --git a/wallets/lnc/ATTACH.md b/wallets/lnc/ATTACH.md new file mode 100644 index 00000000..a003823e --- /dev/null +++ b/wallets/lnc/ATTACH.md @@ -0,0 +1,35 @@ +For testing litd as an attached receiving wallet, you'll need a pairing phrase: + +This can be done one of two ways: + +# cli + +We only need permissions for the uri `/lnrpc.Lightning/SendPaymentSync` + +```bash +$ sndev stacker_litcli accounts create --balance +``` + +Grab the `account.id` from the output and use it here: +```bash +$ sndev stacker_litcli sessions add --type custom --label --account_id --uri /lnrpc.Lightning/SendPaymentSync +``` + +Grab the `pairing_secret_mnemonic` from the output and that's your pairing phrase. + +# gui + +To open the gui, run: + +```bash +sndev open litd +``` + +Or navigate to `http://localhost:8443` in your browser. + +1. If it's not open click on the hamburger menu in the top left. +2. Click `Lightning Node Connect` +3. Click on `Create a new session`, give it a label, select `Custom` in perimissions, and click `Submit`. +4. Select `Custodial Account`, fill in the balance, and click `Submit`. +5. Copy using the copy icon in the bottom left of the session card. + diff --git a/wallets/lnc/client.js b/wallets/lnc/client.js new file mode 100644 index 00000000..36dea17a --- /dev/null +++ b/wallets/lnc/client.js @@ -0,0 +1,161 @@ +import { InvoiceCanceledError, InvoiceExpiredError } from '@/components/payment' +import { bolt11Tags } from '@/lib/bolt11' +import { Mutex } from 'async-mutex' +export * from 'wallets/lnc' + +async function disconnect (lnc, logger) { + if (lnc) { + try { + lnc.disconnect() + logger.info('disconnecting...') + // wait for lnc to disconnect before releasing the mutex + await new Promise((resolve, reject) => { + let counter = 0 + const interval = setInterval(() => { + if (lnc?.isConnected) { + if (counter++ > 100) { + logger.error('failed to disconnect from lnc') + clearInterval(interval) + reject(new Error('failed to disconnect from lnc')) + } + return + } + clearInterval(interval) + resolve() + }) + }, 50) + } catch (err) { + logger.error('failed to disconnect from lnc', err) + } + } +} + +export async function testConnectClient (credentials, { logger }) { + let lnc + try { + lnc = await getLNC(credentials) + + logger.info('connecting ...') + await lnc.connect() + logger.ok('connected') + + logger.info('validating permissions ...') + await validateNarrowPerms(lnc) + logger.ok('permissions ok') + + return lnc.credentials.credentials + } finally { + await disconnect(lnc, logger) + } +} + +const mutex = new Mutex() + +export async function sendPayment (bolt11, credentials, { logger }) { + const hash = bolt11Tags(bolt11).payment_hash + + return await mutex.runExclusive(async () => { + let lnc + try { + lnc = await getLNC(credentials) + + await lnc.connect() + const { paymentError, paymentPreimage: preimage } = + await lnc.lnd.lightning.sendPaymentSync({ payment_request: bolt11 }) + + if (paymentError) throw new Error(paymentError) + if (!preimage) throw new Error('No preimage in response') + + return { preimage } + } catch (err) { + const msg = err.message || err.toString?.() + if (msg.includes('invoice expired')) { + throw new InvoiceExpiredError(hash) + } + if (msg.includes('canceled')) { + throw new InvoiceCanceledError(hash) + } + throw err + } finally { + await disconnect(lnc, logger) + } + }) +} + +async function getLNC (credentials = {}) { + const { default: { default: LNC } } = await import('@lightninglabs/lnc-web') + return new LNC({ + credentialStore: new LncCredentialStore({ ...credentials, serverHost: 'mailbox.terminal.lightning.today:443' }) + }) +} + +function validateNarrowPerms (lnc) { + if (!lnc.hasPerms('lnrpc.Lightning.SendPaymentSync')) { + throw new Error('missing permission: lnrpc.Lightning.SendPaymentSync') + } + if (lnc.hasPerms('lnrpc.Lightning.SendCoins')) { + throw new Error('too broad permission: lnrpc.Wallet.SendCoins') + } + // TODO: need to check for more narrow permissions + // blocked by https://github.com/lightninglabs/lnc-web/issues/112 +} + +// default credential store can go fuck itself +class LncCredentialStore { + credentials = { + localKey: '', + remoteKey: '', + pairingPhrase: '', + serverHost: '' + } + + constructor (credentials = {}) { + this.credentials = { ...this.credentials, ...credentials } + } + + get password () { + return '' + } + + set password (password) { } + + get serverHost () { + return this.credentials.serverHost + } + + set serverHost (host) { + this.credentials.serverHost = host + } + + get pairingPhrase () { + return this.credentials.pairingPhrase + } + + set pairingPhrase (phrase) { + this.credentials.pairingPhrase = phrase + } + + get localKey () { + return this.credentials.localKey + } + + set localKey (key) { + this.credentials.localKey = key + } + + get remoteKey () { + return this.credentials.remoteKey + } + + set remoteKey (key) { + this.credentials.remoteKey = key + } + + get isPaired () { + return !!this.credentials.remoteKey || !!this.credentials.pairingPhrase + } + + clear () { + this.credentials = {} + } +} diff --git a/wallets/lnc/index.js b/wallets/lnc/index.js new file mode 100644 index 00000000..e349b6dd --- /dev/null +++ b/wallets/lnc/index.js @@ -0,0 +1,39 @@ +import { lncSchema } from '@/lib/validate' + +export const name = 'lnc' + +export const fields = [ + { + name: 'pairingPhrase', + label: 'pairing phrase', + type: 'password', + help: 'We only need permissions for the uri `/lnrpc.Lightning/SendPaymentSync`\n\nCreate a budgeted account with narrow permissions:\n\n```$ litcli accounts create --balance ```\n\n```$ litcli sessions add --type custom --label --account_id --uri /lnrpc.Lightning/SendPaymentSync```\n\nGrab the `pairing_secret_mnemonic` from the output and paste it here.', + editable: false + }, + { + name: 'localKey', + type: 'text', + optional: true, + hidden: true + }, + { + name: 'remoteKey', + type: 'text', + optional: true, + hidden: true + }, + { + name: 'serverHost', + type: 'text', + optional: true, + hidden: true + } +] + +export const card = { + title: 'LNC', + subtitle: 'use Lightning Node Connect for LND payments', + badges: ['send only', 'budgetable'] +} + +export const fieldValidation = lncSchema diff --git a/wallets/lnd/ATTACH.md b/wallets/lnd/ATTACH.md new file mode 100644 index 00000000..1d508c3c --- /dev/null +++ b/wallets/lnd/ATTACH.md @@ -0,0 +1,25 @@ +For testing lnd as an attached receiving wallet, you'll need a macaroon and the cert. + +# host and port + +`stacker_lnd:10009` + +# generate macaroon + +```bash +sndev stacker_lndcli -n regtest bakemacaroon invoices:write invoices:read +``` + +# get cert + +This is static in dev env so you can use this one: + +```bash +LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNSekNDQWUyZ0F3SUJBZ0lRYzA2dldJQnVQOXVLZVFOSEtiRmxsREFLQmdncWhrak9QUVFEQWpBNE1SOHcKSFFZRFZRUUtFeFpzYm1RZ1lYVjBiMmRsYm1WeVlYUmxaQ0JqWlhKME1SVXdFd1lEVlFRREV3dzRZMk00TkRGawpNalkyTXpnd0hoY05NalF3TXpBM01UY3dNakU1V2hjTk1qVXdOVEF5TVRjd01qRTVXakE0TVI4d0hRWURWUVFLCkV4WnNibVFnWVhWMGIyZGxibVZ5WVhSbFpDQmpaWEowTVJVd0V3WURWUVFERXd3NFkyTTROREZrTWpZMk16Z3cKV1RBVEJnY3Foa2pPUFFJQkJnZ3Foa2pPUFFNQkJ3TkNBQVFUL253dk1IYVZDZmRWYWVJZ3Y4TUtTK1NIQVM5YwpFbGlmN1hxYTdxc1Z2UGlXN1ZuaDRNRFZFQmxNNXJnMG5rYUg2VjE3c0NDM3JzZS9PcVBMZlZZMW80SFlNSUhWCk1BNEdBMVVkRHdFQi93UUVBd0lDcERBVEJnTlZIU1VFRERBS0JnZ3JCZ0VGQlFjREFUQVBCZ05WSFJNQkFmOEUKQlRBREFRSC9NQjBHQTFVZERnUVdCQlFtYW1Wbi9LY1JxSG9OUjlkazlDMWcyTStqU1RCK0JnTlZIUkVFZHpCMQpnZ3c0WTJNNE5ERmtNalkyTXppQ0NXeHZZMkZzYUc5emRJSUxjM1JoWTJ0bGNsOXNibVNDRkdodmMzUXVaRzlqCmEyVnlMbWx1ZEdWeWJtRnNnZ1IxYm1sNGdncDFibWw0Y0dGamEyVjBnZ2RpZFdaamIyNXVod1IvQUFBQmh4QUEKQUFBQUFBQUFBQUFBQUFBQUFBQUJod1NzR3dBR01Bb0dDQ3FHU000OUJBTUNBMGdBTUVVQ0lGRDI3M1dCY01LegpVUG9PTDhid3ExNUpYdHJTR2VQS3BBZU4xVGJsWTRRNUFpRUF2S3R1aytzc3g5V1FGWkJFaVd4Q1NqVzVnZUtrCjZIQjdUZHhzVStaYmZMZz0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo= +``` + +Which is generated with the following command + +```bash +openssl base64 -A -in docker/lnd/stacker/tls.cert +``` \ No newline at end of file diff --git a/wallets/lnd/client.js b/wallets/lnd/client.js new file mode 100644 index 00000000..2aeb5534 --- /dev/null +++ b/wallets/lnd/client.js @@ -0,0 +1 @@ +export * from 'wallets/lnd' diff --git a/wallets/lnd/index.js b/wallets/lnd/index.js new file mode 100644 index 00000000..c884b909 --- /dev/null +++ b/wallets/lnd/index.js @@ -0,0 +1,47 @@ +import { LNDAutowithdrawSchema } from '@/lib/validate' + +export const name = 'lnd' + +export const fields = [ + { + name: 'socket', + label: 'grpc host and port', + type: 'text', + placeholder: '55.5.555.55:10001', + hint: 'tor or clearnet', + clear: true + }, + { + name: 'macaroon', + label: 'invoice macaroon', + help: { + label: 'privacy tip', + text: 'We accept a prebaked ***invoice.macaroon*** for your convenience. To gain better privacy, generate a new macaroon as follows:\n\n```lncli bakemacaroon invoices:write invoices:read```' + }, + type: 'text', + placeholder: 'AgEDbG5kAlgDChCn7YgfWX7uTkQQgXZ2uahNEgEwGhYKB2FkZHJlc3MSBHJlYWQSBXdyaXRlGhcKCGludm9pY2VzEgRyZWFkEgV3cml0ZRoPCgdvbmNoYWluEgRyZWFkAAAGIJkMBrrDV0npU90JV0TGNJPrqUD8m2QYoTDjolaL6eBs', + hint: 'hex or base64 encoded', + clear: true + }, + { + name: 'cert', + label: 'cert', + type: 'text', + placeholder: 'LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNNVENDQWRpZ0F3SUJBZ0lRSHBFdFdrcGJwZHV4RVF2eVBPc3NWVEFLQmdncWhrak9QUVFEQWpBdk1SOHcKSFFZRFZRUUtFeFpzYm1RZ1lYVjBiMmRsYm1WeVlYUmxaQ0JqWlhKME1Rd3dDZ1lEVlFRREV3TmliMkl3SGhjTgpNalF3TVRBM01qQXhORE0wV2hjTk1qVXdNekF6TWpBeE5ETTBXakF2TVI4d0hRWURWUVFLRXhac2JtUWdZWFYwCmIyZGxibVZ5WVhSbFpDQmpaWEowTVF3d0NnWURWUVFERXdOaWIySXdXVEFUQmdjcWhrak9QUUlCQmdncWhrak8KUFFNQkJ3TkNBQVJUS3NMVk5oZnhqb1FLVDlkVVdDbzUzSmQwTnBuL1BtYi9LUE02M1JxbU52dFYvdFk4NjJJZwpSbE41cmNHRnBEajhUeFc2OVhIK0pTcHpjWDdlN3N0Um80SFZNSUhTTUE0R0ExVWREd0VCL3dRRUF3SUNwREFUCkJnTlZIU1VFRERBS0JnZ3JCZ0VGQlFjREFUQVBCZ05WSFJNQkFmOEVCVEFEQVFIL01CMEdBMVVkRGdRV0JCVDAKMnh3V25GeHRUNzI0MWxwZlNoNm9FWi9UMWpCN0JnTlZIUkVFZERCeWdnTmliMktDQ1d4dlkyRnNhRzl6ZElJRApZbTlpZ2d4d2IyeGhjaTF1TVMxaWIyS0NGR2h2YzNRdVpHOWphMlZ5TG1sdWRHVnlibUZzZ2dSMWJtbDRnZ3AxCmJtbDRjR0ZqYTJWMGdnZGlkV1pqYjI1dWh3Ui9BQUFCaHhBQUFBQUFBQUFBQUFBQUFBQUFBQUFCaHdTc0VnQUQKTUFvR0NDcUdTTTQ5QkFNQ0EwY0FNRVFDSUEwUTlkRXdoNXpPRnpwL3hYeHNpemh5SkxNVG5yazU1VWx1NHJPRwo4WW52QWlBVGt4U3p3Y3hZZnFscGx0UlNIbmd0NUJFcDBzcXlHL05nenBzb2pmMGNqQT09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K', + optional: 'optional if from [CA](https://en.wikipedia.org/wiki/Certificate_authority) (e.g. voltage)', + hint: 'hex or base64 encoded', + clear: true + } +] + +export const card = { + title: 'LND', + subtitle: 'autowithdraw to your Lightning Labs node', + badges: ['receive only'] +} + +export const fieldValidation = LNDAutowithdrawSchema + +export const walletType = 'LND' + +export const walletField = 'walletLND' diff --git a/wallets/lnd/server.js b/wallets/lnd/server.js new file mode 100644 index 00000000..12eb954f --- /dev/null +++ b/wallets/lnd/server.js @@ -0,0 +1,59 @@ +import { ensureB64 } from '@/lib/format' +import { datePivot } from '@/lib/time' +import { authenticatedLndGrpc, createInvoice as lndCreateInvoice } from 'ln-service' +import { addWalletLog } from '@/api/resolvers/wallet' + +export * from 'wallets/lnd' + +export const testConnectServer = async ( + { cert, macaroon, socket }, + { me, models } +) => { + try { + cert = ensureB64(cert) + macaroon = ensureB64(macaroon) + + const { lnd } = await authenticatedLndGrpc({ + cert, + macaroon, + socket + }) + + const inv = await lndCreateInvoice({ + description: 'SN connection test', + lnd, + tokens: 0, + expires_at: new Date() + }) + + // we wrap both calls in one try/catch since connection attempts happen on RPC calls + await addWalletLog({ wallet: { type: 'LND' }, level: 'SUCCESS', message: 'connected to LND' }, { me, models }) + + return inv + } catch (err) { + // LND errors are in this shape: [code, type, { err: { code, details, metadata } }] + const details = err[2]?.err?.details || err.message || err.toString?.() + throw new Error(details) + } +} + +export const createInvoice = async ( + { amount }, + { cert, macaroon, socket }, + { me } +) => { + const { lnd } = await authenticatedLndGrpc({ + cert, + macaroon, + socket + }) + + const invoice = await lndCreateInvoice({ + description: me.hideInvoiceDesc ? undefined : 'autowithdraw to LND from SN', + lnd, + tokens: amount, + expires_at: datePivot(new Date(), { seconds: 360 }) + }) + + return invoice.request +} diff --git a/wallets/nwc/ATTACH.md b/wallets/nwc/ATTACH.md new file mode 100644 index 00000000..808bc5d1 --- /dev/null +++ b/wallets/nwc/ATTACH.md @@ -0,0 +1,7 @@ +The nwc string is printed in the nwc container logs on startup ... + +Open the nwc container logs like this: + +```bash +$ sndev logs nwc +``` \ No newline at end of file diff --git a/wallets/nwc/client.js b/wallets/nwc/client.js new file mode 100644 index 00000000..d6bbf61e --- /dev/null +++ b/wallets/nwc/client.js @@ -0,0 +1,118 @@ +import { parseNwcUrl } from '@/lib/url' +import { Relay, finalizeEvent, nip04 } from 'nostr-tools' + +export * from 'wallets/nwc' + +export async function testConnectClient ({ nwcUrl }, { logger }) { + 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 { + 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, this throws 'WebSocket is already in CLOSING or CLOSED state' + // even though relay connection is still open here + relay?.close()?.catch() + if (relay) logger.info(`closed connection to ${relayUrl}`) + } +} + +export async function sendPayment (bolt11, { nwcUrl }, { logger }) { + const { relayUrl, walletPubkey, secret } = parseNwcUrl(nwcUrl) + + const relay = await Relay.connect(relayUrl).catch(() => { + // NOTE: passed error is undefined for some reason + throw new Error(`failed to connect to ${relayUrl}`) + }) + 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') + reject(new Error(msg)) + } + } + }) + })().catch(reject) + }) + return ret + } finally { + // For some reason, this throws 'WebSocket is already in CLOSING or CLOSED state' + // even though relay connection is still open here + relay?.close()?.catch() + if (relay) logger.info(`closed connection to ${relayUrl}`) + } +} diff --git a/wallets/nwc/index.js b/wallets/nwc/index.js new file mode 100644 index 00000000..7bdaa85a --- /dev/null +++ b/wallets/nwc/index.js @@ -0,0 +1,19 @@ +import { nwcSchema } from '@/lib/validate' + +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'] +} + +export const fieldValidation = nwcSchema diff --git a/wallets/package.json b/wallets/package.json new file mode 100644 index 00000000..96ae6e57 --- /dev/null +++ b/wallets/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} \ No newline at end of file diff --git a/wallets/server.js b/wallets/server.js new file mode 100644 index 00000000..b625824d --- /dev/null +++ b/wallets/server.js @@ -0,0 +1,5 @@ +import * as lnd from 'wallets/lnd/server' +import * as cln from 'wallets/cln/server' +import * as lnAddr from 'wallets/lightning-address/server' + +export default [lnd, cln, lnAddr] diff --git a/worker/autowithdraw.js b/worker/autowithdraw.js index 5f35c96d..3f6be3db 100644 --- a/worker/autowithdraw.js +++ b/worker/autowithdraw.js @@ -1,9 +1,6 @@ -import { authenticatedLndGrpc, createInvoice } from 'ln-service' import { msatsToSats, satsToMsats } from '@/lib/format' -import { datePivot } from '@/lib/time' -import { createWithdrawal, sendToLnAddr, addWalletLog } from '@/api/resolvers/wallet' -import { createInvoice as createInvoiceCLN } from '@/lib/cln' -import { Wallet } from '@/lib/constants' +import { createWithdrawal, addWalletLog } from '@/api/resolvers/wallet' +import walletDefs from 'wallets/server' export async function autoWithdraw ({ data: { id }, models, lnd }) { const user = await models.user.findUnique({ where: { id } }) @@ -41,29 +38,28 @@ export async function autoWithdraw ({ data: { id }, models, lnd }) { // get the wallets in order of priority const wallets = await models.wallet.findMany({ - where: { userId: user.id }, - orderBy: { priority: 'desc' } + where: { userId: user.id, enabled: true }, + orderBy: [ + { priority: 'asc' }, + // use id as tie breaker (older wallet first) + { id: 'asc' } + ] }) for (const wallet of wallets) { + const w = walletDefs.find(w => w.walletType === wallet.type) try { - if (wallet.type === Wallet.LND.type) { - await autowithdrawLND( - { amount, maxFee }, - { models, me: user, lnd }) - } else if (wallet.type === Wallet.CLN.type) { - await autowithdrawCLN( - { amount, maxFee }, - { models, me: user, lnd }) - } else if (wallet.type === Wallet.LnAddr.type) { - await autowithdrawLNAddr( - { amount, maxFee }, - { models, me: user, lnd }) - } - - return + const { walletType, walletField, createInvoice } = w + return await autowithdraw( + { walletType, walletField, createInvoice }, + { amount, maxFee }, + { me: user, models, lnd } + ) } catch (error) { console.error(error) + + // TODO: I think this is a bug, `walletCreateInvoice` in `autowithdraw` should parse the error + // LND errors are in this shape: [code, type, { err: { code, details, metadata } }] const details = error[2]?.err?.details || error.message || error.toString?.() await addWalletLog({ @@ -77,9 +73,10 @@ export async function autoWithdraw ({ data: { id }, models, lnd }) { // none of the wallets worked } -async function autowithdrawLNAddr ( +async function autowithdraw ( + { walletType, walletField, createInvoice: walletCreateInvoice }, { amount, maxFee }, - { me, models, lnd, headers, autoWithdraw = false }) { + { me, models, lnd }) { if (!me) { throw new Error('me not specified') } @@ -87,86 +84,25 @@ async function autowithdrawLNAddr ( const wallet = await models.wallet.findFirst({ where: { userId: me.id, - type: Wallet.LnAddr.type + type: walletType }, include: { - walletLightningAddress: true + [walletField]: true } }) - if (!wallet || !wallet.walletLightningAddress) { - throw new Error('no lightning address wallet found') + if (!wallet || !wallet[walletField]) { + throw new Error(`no ${walletType} wallet found`) } - const { walletLightningAddress: { address } } = wallet - return await sendToLnAddr(null, { addr: address, amount, maxFee }, { me, models, lnd, walletId: wallet.id }) -} - -async function autowithdrawLND ({ amount, maxFee }, { me, models, lnd }) { - if (!me) { - throw new Error('me not specified') - } - - const wallet = await models.wallet.findFirst({ - where: { - userId: me.id, - type: Wallet.LND.type - }, - include: { - walletLND: true - } - }) - - if (!wallet || !wallet.walletLND) { - throw new Error('no lnd wallet found') - } - - const { walletLND: { cert, macaroon, socket } } = wallet - const { lnd: lndOut } = await authenticatedLndGrpc({ - cert, - macaroon, - socket - }) - - const invoice = await createInvoice({ - description: me.hideInvoiceDesc ? undefined : 'autowithdraw to LND from SN', - lnd: lndOut, - tokens: amount, - expires_at: datePivot(new Date(), { seconds: 360 }) - }) - - return await createWithdrawal(null, { invoice: invoice.request, maxFee }, { me, models, lnd, walletId: wallet.id }) -} - -async function autowithdrawCLN ({ amount, maxFee }, { me, models, lnd }) { - if (!me) { - throw new Error('me not specified') - } - - const wallet = await models.wallet.findFirst({ - where: { - userId: me.id, - type: Wallet.CLN.type - }, - include: { - walletCLN: true - } - }) - - if (!wallet || !wallet.walletCLN) { - throw new Error('no cln wallet found') - } - - const { walletCLN: { cert, rune, socket } } = wallet - - const inv = await createInvoiceCLN({ - socket, - rune, - cert, - description: me.hideInvoiceDesc ? undefined : 'autowithdraw to CLN from SN', - msats: amount + 'sat', - expiry: 360 - }) - - return await createWithdrawal(null, { invoice: inv.bolt11, maxFee }, { me, models, lnd, walletId: wallet.id }) + const bolt11 = await walletCreateInvoice( + { amount, maxFee }, + wallet[walletField], + { + me, + models, + lnd + }) + + return await createWithdrawal(null, { invoice: bolt11, maxFee }, { me, models, lnd, walletId: wallet.id }) } diff --git a/worker/wallet.js b/worker/wallet.js index 028c20e3..7f406524 100644 --- a/worker/wallet.js +++ b/worker/wallet.js @@ -253,7 +253,7 @@ async function checkWithdrawal ({ data: { hash }, boss, models, lnd }) { if (dbWdrwl.wallet) { // this was an autowithdrawal const message = `autowithdrawal of ${numWithUnits(msatsToSats(paid), { abbreviate: false })} with ${numWithUnits(msatsToSats(fee), { abbreviate: false })} as fee` - await addWalletLog({ wallet: dbWdrwl.wallet.type, level: 'SUCCESS', message }, { models, me: { id: dbWdrwl.userId } }) + await addWalletLog({ wallet: dbWdrwl.wallet, level: 'SUCCESS', message }, { models, me: { id: dbWdrwl.userId } }) } } } else if (wdrwl?.is_failed || notFound) { @@ -281,7 +281,7 @@ async function checkWithdrawal ({ data: { hash }, boss, models, lnd }) { if (code === 0 && dbWdrwl.wallet) { // add error into log for autowithdrawal await addWalletLog({ - wallet: dbWdrwl.wallet.type, + wallet: dbWdrwl.wallet, level: 'ERROR', message: 'autowithdrawal failed: ' + message }, { models, me: { id: dbWdrwl.userId } })