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

* <table> 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
This commit is contained in:
ekzyis 2024-04-04 00:27:21 +02:00 committed by GitHub
parent a7105b90f2
commit 15fb7f446b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 698 additions and 135 deletions

View File

@ -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)

View File

@ -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!
}
`

15
components/log-message.js Normal file
View File

@ -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 (
<tr className={styles.line}>
<td className={styles.timestamp}>{timeSince(new Date(ts))}</td>
<td className={styles.wallet}>[{wallet}]</td>
<td className={`${styles.level} ${levelClassName}`}>{level}</td>
<td>{message}</td>
</tr>
)
}

View File

@ -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;
}

View File

@ -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 (
<ServiceWorkerLoggerProvider>
<WalletLoggerProvider>
{children}
</WalletLoggerProvider>
</ServiceWorkerLoggerProvider>
)
}
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 (
<LoggerContext.Provider value={logger}>
<ServiceWorkerLoggerContext.Provider value={logger}>
{children}
</LoggerContext.Provider>
</ServiceWorkerLoggerContext.Provider>
)
}
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 (
<WalletLogsContext.Provider value={logs}>
<WalletLoggerContext.Provider value={appendLog}>
{children}
</WalletLoggerContext.Provider>
</WalletLogsContext.Provider>
)
}
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)
}

View File

@ -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.

77
components/wallet-logs.js Normal file
View File

@ -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 (
<Checkbox {...props} />
)
}
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 (
<>
<Form initial={{ follow: true }}>
<FollowCheckbox
label='follow logs' name='follow' value={follow}
handleChange={setFollow}
/>
</Form>
<div ref={tableRef} className={`${styles.logTable} ${embedded ? styles.embedded : ''}`}>
<div className='w-100 text-center'>------ start of logs ------</div>
{logs.length === 0 && <div className='w-100 text-center'>empty</div>}
<table>
<tbody>
{logs.map((log, i) => <LogMessage key={i} {...log} />)}
</tbody>
</table>
</div>
</>
)
}

View File

@ -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(() => {

View File

@ -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)

View File

@ -149,3 +149,15 @@ export const WALLETS = gql`
}
}
`
export const WALLET_LOGS = gql`
query WalletLogs {
walletLogs {
id
createdAt
wallet
level
message
}
}
`

View File

@ -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])

View File

@ -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?.())
}
}}
/>
</Form>
<div className='mt-3 w-100'>
<WalletLogs wallet='lnAddr' embedded />
</div>
</CenterLayout>
)
}

View File

@ -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 () {
}}
/>
</Form>
<div className='mt-3 w-100'>
<WalletLogs wallet='lnbits' embedded />
</div>
</CenterLayout>
)
}

View File

@ -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?.())
}
}}
/>
</Form>
<div className='mt-3 w-100'>
<WalletLogs wallet='lnd' embedded />
</div>
</CenterLayout>
)
}

View File

@ -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 () {
}}
/>
</Form>
<div className='mt-3 w-100'>
<WalletLogs wallet='nwc' embedded />
</div>
</CenterLayout>
)
}

View File

@ -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 (
<Link href='/satistics?inc=invoice,withdrawal' className='text-muted fw-bold text-underline'>
wallet history
</Link>
<div className='d-flex flex-column text-center'>
<div>
<Link href='/satistics?inc=invoice,withdrawal' className='text-muted fw-bold text-underline'>
wallet history
</Link>
</div>
<div>
<Link href='/wallet/logs' className='text-muted fw-bold text-underline'>
wallet logs
</Link>
</div>
</div>
)
}

16
pages/wallet/logs.js Normal file
View File

@ -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 (
<>
<CenterLayout>
<h2 className='text-center'>wallet logs</h2>
<WalletLogs />
</CenterLayout>
</>
)
}

View File

@ -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;

View File

@ -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
}

24
styles/log.module.css Normal file
View File

@ -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;
}
}

View File

@ -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
})
}
}