From 15fb7f446b68611306e0dd0c5a0fa460834be991 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Thu, 4 Apr 2024 00:27:21 +0200 Subject: [PATCH] Wallet Logs (#994) * nwc wallet logs * persist logs in IndexedDB * Potential fix for empty error message * load logs limited to 5m ago from IDB * load logs from past via query param * Add 5m, 1h, 6h links for earlier logs * Show end of log * Clamp to logStart * Add log.module.css * Remove TODO about persistence * Use table for logs * fixes bad format with fixed width and message overflow into start of next row * also using ---start of log--- instead of ---end of log--- now * removed time string in header nav * Rename .header to .logNav * Simply load all logs and remove navigation I realized the code for navigation was most likely premature optimization which even resulted in worse UX: Using the buttons to load logs from 5m, 1h, 6h ago sometimes meant that nothing happened at all since there were no logs from 5m, 1h, 6h ago. That's why I added a time string as "start of logs" so it's at least visible that it changed but that looked bad so I removed it. But all of this was not necessary: I can simply load all logs at once and then the user can scroll around however they like. I was worried that it would be bad for performance to load all logs at once since we might store a lot of logs but as mentioned, that's probably premature optimization. WHEN a lot of logs are stored AND this becomes a problem (What problem even? Slow page load?), THEN we can think about this. If page load ever becomes slow because of loading logs, we could probably simply not load the logs at page load but only when /wallet/logs is visited. But for now, this works fine. * Add follow checkbox * Create WalletLogs component * Embed wallet logs * Remove test error * Fix level padding * Add LNbits logs * Add logs for attaching LND and lnAddr * Use err.message || err.toString?.() consistently * Autowithdrawal logs * Use details from LND error * Don't log test invoice individually * Also refetch logs on error * Remove obsolete and annoying toasts * Replace scrollIntoView with scroll * Use constant embedded max-height * Fix missing width: 100% for embedded logs * Show full payment hash and preimage in logs * Also parse details from LND errors on autowithdrawal failures * Remove TODO * Fix accidental removal of wss:// check * Fix alignment of start marker and show empty if empty * Fix sendPayment loop * Split context in two --- api/resolvers/wallet.js | 95 ++++++-- api/typeDefs/wallet.js | 9 + components/log-message.js | 15 ++ components/log-message.module.css | 23 ++ components/logger.js | 168 +++++++++++++- components/serviceworker.js | 4 +- components/wallet-logs.js | 77 +++++++ components/webln/lnbits.js | 50 ++++- components/webln/nwc.js | 205 +++++++++++------- fragments/wallet.js | 12 + pages/settings/index.js | 4 +- pages/settings/wallets/lightning-address.js | 25 ++- pages/settings/wallets/lnbits.js | 4 + pages/settings/wallets/lnd.js | 25 ++- pages/settings/wallets/nwc.js | 4 + pages/{wallet.js => wallet/index.js} | 17 +- pages/wallet/logs.js | 16 ++ .../20240403013755_wallet_logs/migration.sql | 20 ++ prisma/schema.prisma | 14 ++ styles/log.module.css | 24 ++ worker/autowithdraw.js | 22 +- 21 files changed, 698 insertions(+), 135 deletions(-) create mode 100644 components/log-message.js create mode 100644 components/log-message.module.css create mode 100644 components/wallet-logs.js rename pages/{wallet.js => wallet/index.js} (97%) create mode 100644 pages/wallet/logs.js create mode 100644 prisma/migrations/20240403013755_wallet_logs/migration.sql create mode 100644 styles/log.module.css diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index 7ac8a6cc..b6e22c77 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -305,6 +305,20 @@ export default { cursor: history.length === LIMIT ? nextCursorEncoded(decodedCursor) : null, facts: history } + }, + walletLogs: async (parent, args, { me, models }) => { + if (!me) { + throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } }) + } + + return await models.walletLog.findMany({ + where: { + userId: me.id + }, + orderBy: { + createdAt: 'asc' + } + }) } }, WalletDetails: { @@ -416,35 +430,49 @@ export default { data.macaroon = ensureB64(data.macaroon) data.cert = ensureB64(data.cert) + const wallet = 'walletLND' return await upsertWallet( { schema: LNDAutowithdrawSchema, - walletName: 'walletLND', + walletName: wallet, walletType: 'LND', testConnect: async ({ cert, macaroon, socket }) => { - const { lnd } = await authenticatedLndGrpc({ - cert, - macaroon, - socket - }) - return await createInvoice({ - description: 'SN connection test', - lnd, - tokens: 0, - expires_at: new Date() - }) + 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 }) }, upsertWalletLNAddr: async (parent, { settings, ...data }, { me, models }) => { + const wallet = 'walletLightningAddress' return await upsertWallet( { schema: lnAddrAutowithdrawSchema, - walletName: 'walletLightningAddress', + walletName: wallet, walletType: 'LIGHTNING_ADDRESS', testConnect: async ({ address }) => { - return await lnAddrOptions(address) + const options = await lnAddrOptions(address) + await addWalletLog({ wallet, level: 'SUCCESS', message: 'fetched payment details' }, { me, models }) + return options } }, { settings, data }, { me, models }) @@ -454,7 +482,23 @@ export default { throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) } - await models.wallet.delete({ where: { userId: me.id, id: Number(id) } }) + const wallet = await models.wallet.findUnique({ where: { userId: me.id, id: Number(id) } }) + if (!wallet) { + throw new GraphQLError('wallet not found', { extensions: { code: 'BAD_INPUT' } }) + } + + // determine wallet name for logging + let walletName = '' + if (wallet.type === 'LND') { + walletName = 'walletLND' + } else if (wallet.type === 'LIGHTNING_ADDRESS') { + walletName = 'walletLightningAddress' + } + + await models.$transaction([ + models.wallet.delete({ where: { userId: me.id, id: Number(id) } }), + models.walletLog.create({ data: { userId: me.id, wallet: walletName, level: 'SUCCESS', message: 'wallet deleted' } }) + ]) return true } @@ -488,6 +532,14 @@ export default { } } +export const addWalletLog = async ({ wallet, level, message }, { me, models }) => { + try { + await models.walletLog.create({ data: { userId: me.id, wallet, level, message } }) + } catch (err) { + console.error('error creating wallet log:', err) + } +} + async function upsertWallet ( { schema, walletName, walletType, testConnect }, { settings, data }, { me, models }) { if (!me) { @@ -500,8 +552,9 @@ async function upsertWallet ( if (testConnect) { try { await testConnect(data) - } catch (error) { - console.error(error) + } catch (err) { + console.error(err) + await addWalletLog({ wallet: walletName, level: 'ERROR', message: 'failed to attach wallet' }, { me, models }) throw new GraphQLError('failed to connect to wallet', { extensions: { code: 'BAD_INPUT' } }) } } @@ -544,7 +597,9 @@ async function upsertWallet ( } } } - })) + }), + models.walletLog.create({ data: { userId: me.id, wallet: walletName, level: 'SUCCESS', message: 'wallet updated' } }) + ) } else { txs.push( models.wallet.create({ @@ -556,7 +611,9 @@ async function upsertWallet ( create: walletData } } - })) + }), + models.walletLog.create({ data: { userId: me.id, wallet: walletName, level: 'SUCCESS', message: 'wallet created' } }) + ) } await models.$transaction(txs) diff --git a/api/typeDefs/wallet.js b/api/typeDefs/wallet.js index c2cca6ae..50357e7b 100644 --- a/api/typeDefs/wallet.js +++ b/api/typeDefs/wallet.js @@ -10,6 +10,7 @@ export default gql` wallets: [Wallet!]! wallet(id: ID!): Wallet walletByType(type: String!): Wallet + walletLogs: [WalletLog]! } extend type Mutation { @@ -99,4 +100,12 @@ export default gql` facts: [Fact!]! cursor: String } + + type WalletLog { + id: ID! + createdAt: Date! + wallet: ID! + level: String! + message: String! + } ` diff --git a/components/log-message.js b/components/log-message.js new file mode 100644 index 00000000..c94be1a7 --- /dev/null +++ b/components/log-message.js @@ -0,0 +1,15 @@ +import { timeSince } from '@/lib/time' +import styles from './log-message.module.css' + +export default function LogMessage ({ wallet, level, message, ts }) { + level = level.toLowerCase() + const levelClassName = ['ok', 'success'].includes(level) ? 'text-success' : level === 'error' ? 'text-danger' : level === 'info' ? 'text-info' : '' + return ( + + + + + + + ) +} diff --git a/components/log-message.module.css b/components/log-message.module.css new file mode 100644 index 00000000..0e5872a6 --- /dev/null +++ b/components/log-message.module.css @@ -0,0 +1,23 @@ +.line { + font-family: monospace; + color: var(--theme-grey) !important; /* .text-muted */ +} + +.timestamp { + vertical-align: top; + text-align: end; + text-wrap: nowrap; + justify-self: first baseline; +} + +.wallet { + vertical-align: top; + font-weight: bold; +} + +.level { + font-weight: bold; + vertical-align: top; + text-transform: uppercase; + padding-right: 0.5em; +} \ No newline at end of file diff --git a/components/logger.js b/components/logger.js index d3a6f94f..b64c6c00 100644 --- a/components/logger.js +++ b/components/logger.js @@ -1,6 +1,8 @@ -import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' import { useMe } from './me' import fancyNames from '@/lib/fancy-names.json' +import { useQuery } from '@apollo/client' +import { WALLET_LOGS } from '@/fragments/wallet' const generateFancyName = () => { // 100 adjectives * 100 nouns * 10000 = 100M possible names @@ -38,7 +40,19 @@ export function detectOS () { export const LoggerContext = createContext() -export function LoggerProvider ({ children }) { +export const LoggerProvider = ({ children }) => { + return ( + + + {children} + + + ) +} + +const ServiceWorkerLoggerContext = createContext() + +function ServiceWorkerLoggerProvider ({ children }) { const me = useMe() const [name, setName] = useState() const [os, setOS] = useState() @@ -98,12 +112,154 @@ export function LoggerProvider ({ children }) { }, [logger]) return ( - + {children} - + ) } -export function useLogger () { - return useContext(LoggerContext) +export function useServiceWorkerLogger () { + return useContext(ServiceWorkerLoggerContext) +} + +const WalletLoggerContext = createContext() +const WalletLogsContext = createContext() + +const initIndexedDB = async (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('app:storage', 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 renameWallet = (wallet) => { + if (wallet === 'walletLightningAddress') return 'lnAddr' + if (wallet === 'walletLND') return 'lnd' + return wallet +} + +const WalletLoggerProvider = ({ children }) => { + const [logs, setLogs] = useState([]) + 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, ...log }) => ({ ts: +new Date(createdAt), wallet: renameWallet(wallet), ...log })) + return [...prevLogs, ...logs].sort((a, b) => a.ts - b.ts) + }) + } + }) + + 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(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, level, message, ts: +new Date() } + saveLog(log) + setLogs((prevLogs) => [...prevLogs, log]) + }, [setLogs, saveLog]) + + return ( + + + {children} + + + ) +} + +export function useWalletLogger (wallet) { + const appendLog = 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}]`, 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]) + + return logger +} + +export function useWalletLogs (wallet) { + const logs = useContext(WalletLogsContext) + return logs.filter(l => !wallet || l.wallet === wallet) } diff --git a/components/serviceworker.js b/components/serviceworker.js index 1d9005e9..bef556cd 100644 --- a/components/serviceworker.js +++ b/components/serviceworker.js @@ -1,7 +1,7 @@ import { createContext, useContext, useEffect, useState, useCallback, useMemo } from 'react' import { Workbox } from 'workbox-window' import { gql, useMutation } from '@apollo/client' -import { detectOS, useLogger } from './logger' +import { detectOS, useServiceWorkerLogger } from './logger' const applicationServerKey = process.env.NEXT_PUBLIC_VAPID_PUBKEY @@ -44,7 +44,7 @@ export const ServiceWorkerProvider = ({ children }) => { } } `) - const logger = useLogger() + const logger = useServiceWorkerLogger() // I am not entirely sure if this is needed since at least in Brave, // using `registration.pushManager.subscribe` also prompts the user. diff --git a/components/wallet-logs.js b/components/wallet-logs.js new file mode 100644 index 00000000..f8f95bf5 --- /dev/null +++ b/components/wallet-logs.js @@ -0,0 +1,77 @@ +import { useRouter } from 'next/router' +import LogMessage from './log-message' +import { 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' + +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() + + 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 ( + <> +
+ + +
+
------ start of logs ------
+ {logs.length === 0 &&
empty
} +
{timeSince(new Date(ts))}[{wallet}]{level}{message}
+ + {logs.map((log, i) => )} + +
+ + + ) +} diff --git a/components/webln/lnbits.js b/components/webln/lnbits.js index ced064cc..d18e0cce 100644 --- a/components/webln/lnbits.js +++ b/components/webln/lnbits.js @@ -1,4 +1,6 @@ import { createContext, useCallback, useContext, useEffect, useState } from 'react' +import { useWalletLogger } from '../logger' +import lnpr from 'bolt11' // Reference: https://github.com/getAlby/bitcoin-connect/blob/v3.2.0-alpha/src/connectors/LnbitsConnector.ts @@ -65,6 +67,7 @@ export function LNbitsProvider ({ children }) { const [adminKey, setAdminKey] = useState('') const [enabled, setEnabled] = useState() const [initialized, setInitialized] = useState(false) + const logger = useWalletLogger('lnbits') const name = 'LNbits' const storageKey = 'webln:provider:lnbits' @@ -87,19 +90,30 @@ export function LNbitsProvider ({ children }) { }, [url, adminKey]) const sendPayment = useCallback(async (bolt11) => { - 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 inv = lnpr.decode(bolt11) + const hash = inv.tagsObject.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 } - return { preimage: checkResponse.preimage } - }, [url, adminKey]) + }, [logger, url, adminKey]) const loadConfig = useCallback(async () => { const configStr = window.localStorage.getItem(storageKey) if (!configStr) { setEnabled(undefined) setInitialized(true) + logger.info('no existing config found') return } @@ -109,18 +123,27 @@ export function LNbitsProvider ({ children }) { 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') setEnabled(true) + logger.ok('wallet enabled') } catch (err) { - console.error('invalid LNbits config:', err) + logger.error('invalid config:', err) setEnabled(false) + logger.info('wallet disabled') throw err } finally { setInitialized(true) } - }, []) + }, [logger]) const saveConfig = useCallback(async (config) => { // immediately store config so it's not lost even if config is invalid @@ -132,15 +155,24 @@ export function LNbitsProvider ({ children }) { // 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') } catch (err) { - console.error('invalid LNbits config:', err) + logger.error('invalid config:', err) setEnabled(false) + logger.info('wallet disabled') throw err } setEnabled(true) + logger.ok('wallet enabled') }, []) const clearConfig = useCallback(() => { diff --git a/components/webln/nwc.js b/components/webln/nwc.js index bca9be50..f0072040 100644 --- a/components/webln/nwc.js +++ b/components/webln/nwc.js @@ -3,6 +3,8 @@ import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react' import { Relay, finalizeEvent, nip04 } from 'nostr-tools' import { parseNwcUrl } from '@/lib/url' +import { useWalletLogger } from '../logger' +import lnpr from 'bolt11' const NWCContext = createContext() @@ -13,6 +15,7 @@ export function NWCProvider ({ children }) { const [secret, setSecret] = useState() const [enabled, setEnabled] = useState() const [initialized, setInitialized] = useState(false) + const logger = useWalletLogger('nwc') const relayRef = useRef() @@ -21,8 +24,14 @@ export function NWCProvider ({ children }) { const updateRelay = async (relayUrl) => { try { - relayRef.current?.close() - if (relayUrl) relayRef.current = await Relay.connect(relayUrl) + if (relayRef.current) { + relayRef.current.close() + logger.info('disconnected from', relayRef.current.url) + } + if (relayUrl) { + relayRef.current = await Relay.connect(relayUrl) + logger.ok(`connected to ${relayUrl}`) + } } catch (err) { console.error(err) } @@ -33,6 +42,7 @@ export function NWCProvider ({ children }) { if (!configStr) { setEnabled(undefined) setInitialized(true) + logger.info('no existing config found') return } @@ -46,18 +56,28 @@ export function NWCProvider ({ children }) { 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) - setEnabled(true) + logger.info(`requesting info event from ${params.relayUrl}`) + await validateParams({ ...params, logger }) + logger.ok('info event received') await updateRelay(params.relayUrl) + setEnabled(true) + logger.ok('wallet enabled') } catch (err) { - console.error('invalid NWC config:', err) + logger.error('invalid config:', err) setEnabled(false) + logger.info('wallet disabled') throw err } finally { setInitialized(true) } - }, []) + }, [logger]) const saveConfig = useCallback(async (config) => { // immediately store config so it's not lost even if config is invalid @@ -77,16 +97,26 @@ export function NWCProvider ({ children }) { // 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) - setEnabled(true) + logger.info(`requesting info event from ${params.relayUrl}`) + await validateParams({ ...params, logger }) + logger.ok('info event received') await updateRelay(params.relayUrl) + setEnabled(true) + logger.ok('wallet enabled') } catch (err) { - console.error('invalid NWC config:', err) + logger.error('invalid config:', err) setEnabled(false) + logger.info('wallet disabled') throw err } - }, []) + }, [logger]) const clearConfig = useCallback(() => { window.localStorage.removeItem(storageKey) @@ -97,82 +127,93 @@ export function NWCProvider ({ children }) { setEnabled(undefined) }, []) - const sendPayment = useCallback((bolt11) => { - return new Promise(function (resolve, reject) { - const relay = relayRef.current - if (!relay) { - return reject(new Error('not connected to relay')) - } - (async function () { + const sendPayment = useCallback(async (bolt11) => { + const inv = lnpr.decode(bolt11) + const hash = inv.tagsObject.payment_hash + logger.info('sending payment:', `payment_hash=${hash}`) + try { + const ret = await new Promise(function (resolve, reject) { + const relay = relayRef.current + if (!relay) { + return reject(new Error('not connected to relay')) + } + (async function () { // XXX set this to mock NWC relays - const MOCK_NWC_RELAY = false + const MOCK_NWC_RELAY = false - // timeout since NWC is async (user needs to confirm payment in wallet) - // timeout is same as invoice expiry - const timeout = MOCK_NWC_RELAY ? 3000 : 180_000 - let timer - const resetTimer = () => { - clearTimeout(timer) - timer = setTimeout(() => { - sub?.close() - if (MOCK_NWC_RELAY) { - const heads = Math.random() < 0.5 - if (heads) { - return resolve({ preimage: null }) - } - return reject(new Error('mock error')) - } - return reject(new Error('timeout')) - }, timeout) - } - if (MOCK_NWC_RELAY) return resetTimer() - - 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) - resetTimer() - - const filter = { - kinds: [23195], - authors: [walletPubkey], - '#e': [request.id] - } - const sub = relay.subscribe([filter], { - async onevent (response) { - resetTimer() - 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) - } finally { - clearTimeout(timer) - sub.close() - } - }, - onclose (reason) { + // timeout since NWC is async (user needs to confirm payment in wallet) + // timeout is same as invoice expiry + const timeout = MOCK_NWC_RELAY ? 3000 : 180_000 + let timer + const resetTimer = () => { clearTimeout(timer) - reject(new Error(reason)) + timer = setTimeout(() => { + sub?.close() + if (MOCK_NWC_RELAY) { + const heads = Math.random() < 0.5 + if (heads) { + return resolve({ preimage: null }) + } + return reject(new Error('mock error')) + } + return reject(new Error('timeout')) + }, timeout) } - }) - })().catch(reject) - }) - }, [walletPubkey, secret]) + if (MOCK_NWC_RELAY) return resetTimer() + + 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) + resetTimer() + + const filter = { + kinds: [23195], + authors: [walletPubkey], + '#e': [request.id] + } + const sub = relay.subscribe([filter], { + async onevent (response) { + resetTimer() + 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) + } finally { + clearTimeout(timer) + sub.close() + } + }, + onclose (reason) { + clearTimeout(timer) + reject(new Error(reason)) + } + }) + })().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 + } + }, [walletPubkey, secret, logger]) const getInfo = useCallback(() => getInfoWithRelay(relayRef?.current, walletPubkey), [relayRef?.current, walletPubkey]) useEffect(() => { - loadConfig().catch(console.error) + loadConfig().catch(err => logger.error(err.message || err.toString?.())) }, []) const value = { name, nwcUrl, relayUrl, walletPubkey, secret, initialized, enabled, saveConfig, clearConfig, getInfo, sendPayment } @@ -187,14 +228,16 @@ export function useNWC () { return useContext(NWCContext) } -async function validateParams ({ relayUrl, walletPubkey, secret }) { +async function validateParams ({ relayUrl, walletPubkey, secret, logger }) { let infoRelay try { // validate connection by fetching info event infoRelay = await Relay.connect(relayUrl) - return await getInfoWithRelay(infoRelay, walletPubkey) + logger.ok(`connected to ${relayUrl}`) + await getInfoWithRelay(infoRelay, walletPubkey, logger) } finally { infoRelay?.close() + logger.info(`closed connection to ${relayUrl}`) } } @@ -220,7 +263,7 @@ async function getInfoWithRelay (relay, walletPubkey) { }, onclose (reason) { clearTimeout(timer) - reject(new Error(reason)) + reject(new Error(reason || 'connection closed: reason unknown')) }, oneose () { clearTimeout(timer) diff --git a/fragments/wallet.js b/fragments/wallet.js index f1a3c09e..85abd619 100644 --- a/fragments/wallet.js +++ b/fragments/wallet.js @@ -149,3 +149,15 @@ export const WALLETS = gql` } } ` + +export const WALLET_LOGS = gql` + query WalletLogs { + walletLogs { + id + createdAt + wallet + level + message + } + } +` diff --git a/pages/settings/index.js b/pages/settings/index.js index 67cf5c92..cda45afc 100644 --- a/pages/settings/index.js +++ b/pages/settings/index.js @@ -23,7 +23,7 @@ import { useShowModal } from '@/components/modal' import { authErrorMessage } from '@/components/login' import { NostrAuth } from '@/components/nostr-auth' import { useToast } from '@/components/toast' -import { useLogger } from '@/components/logger' +import { useServiceWorkerLogger } from '@/components/logger' import { useMe } from '@/components/me' import { INVOICE_RETENTION_DAYS } from '@/lib/constants' import { OverlayTrigger, Tooltip } from 'react-bootstrap' @@ -52,7 +52,7 @@ export default function Settings ({ ssrData }) { } } ) - const logger = useLogger() + const logger = useServiceWorkerLogger() const { data } = useQuery(SETTINGS) const { settings: { privates: settings } } = useMemo(() => data ?? ssrData, [data, ssrData]) diff --git a/pages/settings/wallets/lightning-address.js b/pages/settings/wallets/lightning-address.js index 68d0f7b1..c24c2086 100644 --- a/pages/settings/wallets/lightning-address.js +++ b/pages/settings/wallets/lightning-address.js @@ -3,12 +3,13 @@ import { Form, Input } from '@/components/form' import { CenterLayout } from '@/components/layout' import { useMe } from '@/components/me' import { WalletButtonBar, WalletCard } from '@/components/wallet-card' -import { useMutation } from '@apollo/client' +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' const variables = { type: 'LIGHTNING_ADDRESS' } export const getServerSideProps = getGetServerSideProps({ query: WALLET_BY_TYPE, variables, authRequired: true }) @@ -17,8 +18,21 @@ export default function LightningAddress ({ ssrData }) { const me = useMe() const toaster = useToast() const router = useRouter() - const [upsertWalletLNAddr] = useMutation(UPSERT_WALLET_LNADDR) - const [removeWallet] = useMutation(REMOVE_WALLET) + 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 || {} @@ -49,7 +63,6 @@ export default function LightningAddress ({ ssrData }) { router.push('/settings/wallets') } catch (err) { console.error(err) - toaster.danger('failed to attach: ' + err.message || err.toString?.()) } }} > @@ -69,11 +82,13 @@ export default function LightningAddress ({ ssrData }) { router.push('/settings/wallets') } catch (err) { console.error(err) - toaster.danger('failed to unattach:' + err.message || err.toString?.()) } }} /> +
+ +
) } diff --git a/pages/settings/wallets/lnbits.js b/pages/settings/wallets/lnbits.js index c1fd3dc4..e6fadc79 100644 --- a/pages/settings/wallets/lnbits.js +++ b/pages/settings/wallets/lnbits.js @@ -8,6 +8,7 @@ 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' export const getServerSideProps = getGetServerSideProps({ authRequired: true }) @@ -76,6 +77,9 @@ export default function LNbits () { }} /> +
+ +
) } diff --git a/pages/settings/wallets/lnd.js b/pages/settings/wallets/lnd.js index e1501fb3..a7aea8f1 100644 --- a/pages/settings/wallets/lnd.js +++ b/pages/settings/wallets/lnd.js @@ -3,7 +3,7 @@ import { Form, Input } from '@/components/form' import { CenterLayout } from '@/components/layout' import { useMe } from '@/components/me' import { WalletButtonBar, WalletCard } from '@/components/wallet-card' -import { useMutation } from '@apollo/client' +import { useApolloClient, useMutation } from '@apollo/client' import { useToast } from '@/components/toast' import { LNDAutowithdrawSchema } from '@/lib/validate' import { useRouter } from 'next/router' @@ -11,6 +11,7 @@ import { AutowithdrawSettings, autowithdrawInitial } from '@/components/autowith 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' const variables = { type: 'LND' } export const getServerSideProps = getGetServerSideProps({ query: WALLET_BY_TYPE, variables, authRequired: true }) @@ -19,8 +20,21 @@ export default function LND ({ ssrData }) { const me = useMe() const toaster = useToast() const router = useRouter() - const [upsertWalletLND] = useMutation(UPSERT_WALLET_LND) - const [removeWallet] = useMutation(REMOVE_WALLET) + 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 || {} @@ -55,7 +69,6 @@ export default function LND ({ ssrData }) { router.push('/settings/wallets') } catch (err) { console.error(err) - toaster.danger('failed to attach: ' + err.message || err.toString?.()) } }} > @@ -100,11 +113,13 @@ export default function LND ({ ssrData }) { router.push('/settings/wallets') } catch (err) { console.error(err) - toaster.danger('failed to unattach:' + err.message || err.toString?.()) } }} /> +
+ +
) } diff --git a/pages/settings/wallets/nwc.js b/pages/settings/wallets/nwc.js index f0ff9617..92375d0c 100644 --- a/pages/settings/wallets/nwc.js +++ b/pages/settings/wallets/nwc.js @@ -8,6 +8,7 @@ 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' export const getServerSideProps = getGetServerSideProps({ authRequired: true }) @@ -68,6 +69,9 @@ export default function NWC () { }} /> +
+ +
) } diff --git a/pages/wallet.js b/pages/wallet/index.js similarity index 97% rename from pages/wallet.js rename to pages/wallet/index.js index 6a292559..e43f766c 100644 --- a/pages/wallet.js +++ b/pages/wallet/index.js @@ -6,7 +6,7 @@ import { gql, useMutation, useQuery } from '@apollo/client' import Qr, { QrSkeleton } from '@/components/qr' import { CenterLayout } from '@/components/layout' import InputGroup from 'react-bootstrap/InputGroup' -import { WithdrawlSkeleton } from './withdrawals/[id]' +import { WithdrawlSkeleton } from '@/pages/withdrawals/[id]' import { useMe } from '@/components/me' import { useEffect, useState } from 'react' import { requestProvider } from 'webln' @@ -78,9 +78,18 @@ function YouHaveSats () { function WalletHistory () { return ( - - wallet history - +
+
+ + wallet history + +
+
+ + wallet logs + +
+
) } diff --git a/pages/wallet/logs.js b/pages/wallet/logs.js new file mode 100644 index 00000000..debad4c2 --- /dev/null +++ b/pages/wallet/logs.js @@ -0,0 +1,16 @@ +import { CenterLayout } from '@/components/layout' +import { getGetServerSideProps } from '@/api/ssrApollo' +import WalletLogs from '@/components/wallet-logs' + +export const getServerSideProps = getGetServerSideProps({ query: null }) + +export default function () { + return ( + <> + +

wallet logs

+ +
+ + ) +} diff --git a/prisma/migrations/20240403013755_wallet_logs/migration.sql b/prisma/migrations/20240403013755_wallet_logs/migration.sql new file mode 100644 index 00000000..5de11d54 --- /dev/null +++ b/prisma/migrations/20240403013755_wallet_logs/migration.sql @@ -0,0 +1,20 @@ +-- AlterEnum +ALTER TYPE "LogLevel" ADD VALUE 'SUCCESS'; + +-- CreateTable +CREATE TABLE "WalletLog" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "userId" INTEGER NOT NULL, + "wallet" TEXT NOT NULL, + "level" "LogLevel" NOT NULL, + "message" TEXT NOT NULL, + + CONSTRAINT "WalletLog_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "WalletLog_userId_created_at_idx" ON "WalletLog"("userId", "created_at"); + +-- AddForeignKey +ALTER TABLE "WalletLog" ADD CONSTRAINT "WalletLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 74655e78..7ada75c7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -122,6 +122,7 @@ model User { TerritoryReceives TerritoryTransfer[] @relation("TerritoryTransfer_newUser") AncestorReplies Reply[] @relation("AncestorReplyUser") Replies Reply[] + walletLogs WalletLog[] @@index([photoId]) @@index([createdAt], map: "users.created_at_index") @@ -157,6 +158,18 @@ model Wallet { @@index([userId]) } +model WalletLog { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + userId Int + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + wallet String + level LogLevel + message String + + @@index([userId, createdAt]) +} + model WalletLightningAddress { id Int @id @default(autoincrement()) walletId Int @unique @@ -874,4 +887,5 @@ enum LogLevel { INFO WARN ERROR + SUCCESS } diff --git a/styles/log.module.css b/styles/log.module.css new file mode 100644 index 00000000..d69d0fab --- /dev/null +++ b/styles/log.module.css @@ -0,0 +1,24 @@ +.logNav { + text-align: center; + color: var(--theme-grey) !important; /* .text-muted */ +} + +.logTable { + width: 100%; + max-height: 60svh; + overflow-y: auto; + padding-right: 1em; + font-size: x-small; + font-family: monospace; + color: var(--theme-grey) !important; /* .text-muted */ +} + +@media screen and (min-width: 768px) { + .logTable { + max-height: 70svh; + } + + .embedded { + max-height: 30svh; + } +} diff --git a/worker/autowithdraw.js b/worker/autowithdraw.js index c9d17b99..f0685a05 100644 --- a/worker/autowithdraw.js +++ b/worker/autowithdraw.js @@ -1,7 +1,7 @@ import { authenticatedLndGrpc, createInvoice } from 'ln-service' -import { msatsToSats, satsToMsats } from '@/lib/format' +import { msatsToSats, numWithUnits, satsToMsats } from '@/lib/format' import { datePivot } from '@/lib/time' -import { createWithdrawal, sendToLnAddr } from '@/api/resolvers/wallet' +import { createWithdrawal, sendToLnAddr, addWalletLog } from '@/api/resolvers/wallet' export async function autoWithdraw ({ data: { id }, models, lnd }) { const user = await models.user.findUnique({ where: { id } }) @@ -45,19 +45,37 @@ export async function autoWithdraw ({ data: { id }, models, lnd }) { for (const wallet of wallets) { try { + const message = `autowithdrawal of ${numWithUnits(amount, { abbreviate: false, unitSingular: 'sat', unitPlural: 'sats' })}` if (wallet.type === 'LND') { await autowithdrawLND( { amount, maxFee }, { models, me: user, lnd }) + await addWalletLog({ + wallet: 'walletLND', + level: 'SUCCESS', + message + }, { me: user, models }) } else if (wallet.type === 'LIGHTNING_ADDRESS') { await autowithdrawLNAddr( { amount, maxFee }, { models, me: user, lnd }) + await addWalletLog({ + wallet: 'walletLightningAddress', + level: 'SUCCESS', + message + }, { me: user, models }) } return } catch (error) { console.error(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({ + wallet: wallet.type === 'LND' ? 'walletLND' : 'walletLightningAddress', + level: 'ERROR', + message: 'autowithdrawal failed: ' + details + }) } }