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 ( + + {timeSince(new Date(ts))} + [{wallet}] + {level} + {message} + + ) +} 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
} + + + {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 + }) } }