Wallet Logs ()

* 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

@ -305,6 +305,20 @@ export default {
cursor: history.length === LIMIT ? nextCursorEncoded(decodedCursor) : null, cursor: history.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
facts: history 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: { WalletDetails: {
@ -416,35 +430,49 @@ export default {
data.macaroon = ensureB64(data.macaroon) data.macaroon = ensureB64(data.macaroon)
data.cert = ensureB64(data.cert) data.cert = ensureB64(data.cert)
const wallet = 'walletLND'
return await upsertWallet( return await upsertWallet(
{ {
schema: LNDAutowithdrawSchema, schema: LNDAutowithdrawSchema,
walletName: 'walletLND', walletName: wallet,
walletType: 'LND', walletType: 'LND',
testConnect: async ({ cert, macaroon, socket }) => { testConnect: async ({ cert, macaroon, socket }) => {
const { lnd } = await authenticatedLndGrpc({ try {
cert, const { lnd } = await authenticatedLndGrpc({
macaroon, cert,
socket macaroon,
}) socket
return await createInvoice({ })
description: 'SN connection test', const inv = await createInvoice({
lnd, description: 'SN connection test',
tokens: 0, lnd,
expires_at: new Date() 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 }) { settings, data }, { me, models })
}, },
upsertWalletLNAddr: async (parent, { settings, ...data }, { me, models }) => { upsertWalletLNAddr: async (parent, { settings, ...data }, { me, models }) => {
const wallet = 'walletLightningAddress'
return await upsertWallet( return await upsertWallet(
{ {
schema: lnAddrAutowithdrawSchema, schema: lnAddrAutowithdrawSchema,
walletName: 'walletLightningAddress', walletName: wallet,
walletType: 'LIGHTNING_ADDRESS', walletType: 'LIGHTNING_ADDRESS',
testConnect: async ({ 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 }) { settings, data }, { me, models })
@ -454,7 +482,23 @@ export default {
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) 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 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 ( async function upsertWallet (
{ schema, walletName, walletType, testConnect }, { settings, data }, { me, models }) { { schema, walletName, walletType, testConnect }, { settings, data }, { me, models }) {
if (!me) { if (!me) {
@ -500,8 +552,9 @@ async function upsertWallet (
if (testConnect) { if (testConnect) {
try { try {
await testConnect(data) await testConnect(data)
} catch (error) { } catch (err) {
console.error(error) 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' } }) 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 { } else {
txs.push( txs.push(
models.wallet.create({ models.wallet.create({
@ -556,7 +611,9 @@ async function upsertWallet (
create: walletData create: walletData
} }
} }
})) }),
models.walletLog.create({ data: { userId: me.id, wallet: walletName, level: 'SUCCESS', message: 'wallet created' } })
)
} }
await models.$transaction(txs) await models.$transaction(txs)

@ -10,6 +10,7 @@ export default gql`
wallets: [Wallet!]! wallets: [Wallet!]!
wallet(id: ID!): Wallet wallet(id: ID!): Wallet
walletByType(type: String!): Wallet walletByType(type: String!): Wallet
walletLogs: [WalletLog]!
} }
extend type Mutation { extend type Mutation {
@ -99,4 +100,12 @@ export default gql`
facts: [Fact!]! facts: [Fact!]!
cursor: String cursor: String
} }
type WalletLog {
id: ID!
createdAt: Date!
wallet: ID!
level: String!
message: String!
}
` `

15
components/log-message.js Normal 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>
)
}

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

@ -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 { useMe } from './me'
import fancyNames from '@/lib/fancy-names.json' import fancyNames from '@/lib/fancy-names.json'
import { useQuery } from '@apollo/client'
import { WALLET_LOGS } from '@/fragments/wallet'
const generateFancyName = () => { const generateFancyName = () => {
// 100 adjectives * 100 nouns * 10000 = 100M possible names // 100 adjectives * 100 nouns * 10000 = 100M possible names
@ -38,7 +40,19 @@ export function detectOS () {
export const LoggerContext = createContext() 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 me = useMe()
const [name, setName] = useState() const [name, setName] = useState()
const [os, setOS] = useState() const [os, setOS] = useState()
@ -98,12 +112,154 @@ export function LoggerProvider ({ children }) {
}, [logger]) }, [logger])
return ( return (
<LoggerContext.Provider value={logger}> <ServiceWorkerLoggerContext.Provider value={logger}>
{children} {children}
</LoggerContext.Provider> </ServiceWorkerLoggerContext.Provider>
) )
} }
export function useLogger () { export function useServiceWorkerLogger () {
return useContext(LoggerContext) 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)
} }

@ -1,7 +1,7 @@
import { createContext, useContext, useEffect, useState, useCallback, useMemo } from 'react' import { createContext, useContext, useEffect, useState, useCallback, useMemo } from 'react'
import { Workbox } from 'workbox-window' import { Workbox } from 'workbox-window'
import { gql, useMutation } from '@apollo/client' import { gql, useMutation } from '@apollo/client'
import { detectOS, useLogger } from './logger' import { detectOS, useServiceWorkerLogger } from './logger'
const applicationServerKey = process.env.NEXT_PUBLIC_VAPID_PUBKEY 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, // I am not entirely sure if this is needed since at least in Brave,
// using `registration.pushManager.subscribe` also prompts the user. // using `registration.pushManager.subscribe` also prompts the user.

77
components/wallet-logs.js Normal 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>
</>
)
}

@ -1,4 +1,6 @@
import { createContext, useCallback, useContext, useEffect, useState } from 'react' 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 // 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 [adminKey, setAdminKey] = useState('')
const [enabled, setEnabled] = useState() const [enabled, setEnabled] = useState()
const [initialized, setInitialized] = useState(false) const [initialized, setInitialized] = useState(false)
const logger = useWalletLogger('lnbits')
const name = 'LNbits' const name = 'LNbits'
const storageKey = 'webln:provider:lnbits' const storageKey = 'webln:provider:lnbits'
@ -87,19 +90,30 @@ export function LNbitsProvider ({ children }) {
}, [url, adminKey]) }, [url, adminKey])
const sendPayment = useCallback(async (bolt11) => { const sendPayment = useCallback(async (bolt11) => {
const response = await postPayment(url, adminKey, bolt11) const inv = lnpr.decode(bolt11)
const checkResponse = await getPayment(url, adminKey, response.payment_hash) const hash = inv.tagsObject.payment_hash
if (!checkResponse.preimage) { logger.info('sending payment:', `payment_hash=${hash}`)
throw new Error('No preimage') 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 } }, [logger, url, adminKey])
}, [url, adminKey])
const loadConfig = useCallback(async () => { const loadConfig = useCallback(async () => {
const configStr = window.localStorage.getItem(storageKey) const configStr = window.localStorage.getItem(storageKey)
if (!configStr) { if (!configStr) {
setEnabled(undefined) setEnabled(undefined)
setInitialized(true) setInitialized(true)
logger.info('no existing config found')
return return
} }
@ -109,18 +123,27 @@ export function LNbitsProvider ({ children }) {
setUrl(url) setUrl(url)
setAdminKey(adminKey) setAdminKey(adminKey)
logger.info(
'loaded wallet config: ' +
'adminKey=****** ' +
`url=${url}`)
try { try {
// validate config by trying to fetch wallet // validate config by trying to fetch wallet
logger.info('trying to fetch wallet')
await getWallet(url, adminKey) await getWallet(url, adminKey)
logger.ok('wallet found')
setEnabled(true) setEnabled(true)
logger.ok('wallet enabled')
} catch (err) { } catch (err) {
console.error('invalid LNbits config:', err) logger.error('invalid config:', err)
setEnabled(false) setEnabled(false)
logger.info('wallet disabled')
throw err throw err
} finally { } finally {
setInitialized(true) setInitialized(true)
} }
}, []) }, [logger])
const saveConfig = useCallback(async (config) => { const saveConfig = useCallback(async (config) => {
// immediately store config so it's not lost even if config is invalid // 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/ // https://thenewstack.io/leveraging-web-workers-to-safely-store-access-tokens/
window.localStorage.setItem(storageKey, JSON.stringify(config)) window.localStorage.setItem(storageKey, JSON.stringify(config))
logger.info(
'saved wallet config: ' +
'adminKey=****** ' +
`url=${config.url}`)
try { try {
// validate config by trying to fetch wallet // validate config by trying to fetch wallet
logger.info('trying to fetch wallet')
await getWallet(config.url, config.adminKey) await getWallet(config.url, config.adminKey)
logger.ok('wallet found')
} catch (err) { } catch (err) {
console.error('invalid LNbits config:', err) logger.error('invalid config:', err)
setEnabled(false) setEnabled(false)
logger.info('wallet disabled')
throw err throw err
} }
setEnabled(true) setEnabled(true)
logger.ok('wallet enabled')
}, []) }, [])
const clearConfig = useCallback(() => { const clearConfig = useCallback(() => {

@ -3,6 +3,8 @@
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react' import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
import { Relay, finalizeEvent, nip04 } from 'nostr-tools' import { Relay, finalizeEvent, nip04 } from 'nostr-tools'
import { parseNwcUrl } from '@/lib/url' import { parseNwcUrl } from '@/lib/url'
import { useWalletLogger } from '../logger'
import lnpr from 'bolt11'
const NWCContext = createContext() const NWCContext = createContext()
@ -13,6 +15,7 @@ export function NWCProvider ({ children }) {
const [secret, setSecret] = useState() const [secret, setSecret] = useState()
const [enabled, setEnabled] = useState() const [enabled, setEnabled] = useState()
const [initialized, setInitialized] = useState(false) const [initialized, setInitialized] = useState(false)
const logger = useWalletLogger('nwc')
const relayRef = useRef() const relayRef = useRef()
@ -21,8 +24,14 @@ export function NWCProvider ({ children }) {
const updateRelay = async (relayUrl) => { const updateRelay = async (relayUrl) => {
try { try {
relayRef.current?.close() if (relayRef.current) {
if (relayUrl) relayRef.current = await Relay.connect(relayUrl) 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) { } catch (err) {
console.error(err) console.error(err)
} }
@ -33,6 +42,7 @@ export function NWCProvider ({ children }) {
if (!configStr) { if (!configStr) {
setEnabled(undefined) setEnabled(undefined)
setInitialized(true) setInitialized(true)
logger.info('no existing config found')
return return
} }
@ -46,18 +56,28 @@ export function NWCProvider ({ children }) {
setWalletPubkey(params.walletPubkey) setWalletPubkey(params.walletPubkey)
setSecret(params.secret) setSecret(params.secret)
logger.info(
'loaded wallet config: ' +
'secret=****** ' +
`pubkey=${params.walletPubkey.slice(0, 6)}..${params.walletPubkey.slice(-6)} ` +
`relay=${params.relayUrl}`)
try { try {
await validateParams(params) logger.info(`requesting info event from ${params.relayUrl}`)
setEnabled(true) await validateParams({ ...params, logger })
logger.ok('info event received')
await updateRelay(params.relayUrl) await updateRelay(params.relayUrl)
setEnabled(true)
logger.ok('wallet enabled')
} catch (err) { } catch (err) {
console.error('invalid NWC config:', err) logger.error('invalid config:', err)
setEnabled(false) setEnabled(false)
logger.info('wallet disabled')
throw err throw err
} finally { } finally {
setInitialized(true) setInitialized(true)
} }
}, []) }, [logger])
const saveConfig = useCallback(async (config) => { const saveConfig = useCallback(async (config) => {
// immediately store config so it's not lost even if config is invalid // 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. // this is definitely not ideal from a security perspective.
window.localStorage.setItem(storageKey, JSON.stringify(config)) 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 { try {
await validateParams(params) logger.info(`requesting info event from ${params.relayUrl}`)
setEnabled(true) await validateParams({ ...params, logger })
logger.ok('info event received')
await updateRelay(params.relayUrl) await updateRelay(params.relayUrl)
setEnabled(true)
logger.ok('wallet enabled')
} catch (err) { } catch (err) {
console.error('invalid NWC config:', err) logger.error('invalid config:', err)
setEnabled(false) setEnabled(false)
logger.info('wallet disabled')
throw err throw err
} }
}, []) }, [logger])
const clearConfig = useCallback(() => { const clearConfig = useCallback(() => {
window.localStorage.removeItem(storageKey) window.localStorage.removeItem(storageKey)
@ -97,82 +127,93 @@ export function NWCProvider ({ children }) {
setEnabled(undefined) setEnabled(undefined)
}, []) }, [])
const sendPayment = useCallback((bolt11) => { const sendPayment = useCallback(async (bolt11) => {
return new Promise(function (resolve, reject) { const inv = lnpr.decode(bolt11)
const relay = relayRef.current const hash = inv.tagsObject.payment_hash
if (!relay) { logger.info('sending payment:', `payment_hash=${hash}`)
return reject(new Error('not connected to relay')) try {
} const ret = await new Promise(function (resolve, reject) {
(async function () { const relay = relayRef.current
if (!relay) {
return reject(new Error('not connected to relay'))
}
(async function () {
// XXX set this to mock NWC relays // 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 since NWC is async (user needs to confirm payment in wallet)
// timeout is same as invoice expiry // timeout is same as invoice expiry
const timeout = MOCK_NWC_RELAY ? 3000 : 180_000 const timeout = MOCK_NWC_RELAY ? 3000 : 180_000
let timer let timer
const resetTimer = () => { 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) {
clearTimeout(timer) 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)
} }
}) if (MOCK_NWC_RELAY) return resetTimer()
})().catch(reject)
}) const payload = {
}, [walletPubkey, secret]) 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]) const getInfo = useCallback(() => getInfoWithRelay(relayRef?.current, walletPubkey), [relayRef?.current, walletPubkey])
useEffect(() => { 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 } const value = { name, nwcUrl, relayUrl, walletPubkey, secret, initialized, enabled, saveConfig, clearConfig, getInfo, sendPayment }
@ -187,14 +228,16 @@ export function useNWC () {
return useContext(NWCContext) return useContext(NWCContext)
} }
async function validateParams ({ relayUrl, walletPubkey, secret }) { async function validateParams ({ relayUrl, walletPubkey, secret, logger }) {
let infoRelay let infoRelay
try { try {
// validate connection by fetching info event // validate connection by fetching info event
infoRelay = await Relay.connect(relayUrl) infoRelay = await Relay.connect(relayUrl)
return await getInfoWithRelay(infoRelay, walletPubkey) logger.ok(`connected to ${relayUrl}`)
await getInfoWithRelay(infoRelay, walletPubkey, logger)
} finally { } finally {
infoRelay?.close() infoRelay?.close()
logger.info(`closed connection to ${relayUrl}`)
} }
} }
@ -220,7 +263,7 @@ async function getInfoWithRelay (relay, walletPubkey) {
}, },
onclose (reason) { onclose (reason) {
clearTimeout(timer) clearTimeout(timer)
reject(new Error(reason)) reject(new Error(reason || 'connection closed: reason unknown'))
}, },
oneose () { oneose () {
clearTimeout(timer) clearTimeout(timer)

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

@ -23,7 +23,7 @@ import { useShowModal } from '@/components/modal'
import { authErrorMessage } from '@/components/login' import { authErrorMessage } from '@/components/login'
import { NostrAuth } from '@/components/nostr-auth' import { NostrAuth } from '@/components/nostr-auth'
import { useToast } from '@/components/toast' import { useToast } from '@/components/toast'
import { useLogger } from '@/components/logger' import { useServiceWorkerLogger } from '@/components/logger'
import { useMe } from '@/components/me' import { useMe } from '@/components/me'
import { INVOICE_RETENTION_DAYS } from '@/lib/constants' import { INVOICE_RETENTION_DAYS } from '@/lib/constants'
import { OverlayTrigger, Tooltip } from 'react-bootstrap' 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 { data } = useQuery(SETTINGS)
const { settings: { privates: settings } } = useMemo(() => data ?? ssrData, [data, ssrData]) const { settings: { privates: settings } } = useMemo(() => data ?? ssrData, [data, ssrData])

@ -3,12 +3,13 @@ import { Form, Input } from '@/components/form'
import { CenterLayout } from '@/components/layout' import { CenterLayout } from '@/components/layout'
import { useMe } from '@/components/me' import { useMe } from '@/components/me'
import { WalletButtonBar, WalletCard } from '@/components/wallet-card' import { WalletButtonBar, WalletCard } from '@/components/wallet-card'
import { useMutation } from '@apollo/client' import { useApolloClient, useMutation } from '@apollo/client'
import { useToast } from '@/components/toast' import { useToast } from '@/components/toast'
import { lnAddrAutowithdrawSchema } from '@/lib/validate' import { lnAddrAutowithdrawSchema } from '@/lib/validate'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { AutowithdrawSettings, autowithdrawInitial } from '@/components/autowithdraw-shared' import { AutowithdrawSettings, autowithdrawInitial } from '@/components/autowithdraw-shared'
import { REMOVE_WALLET, UPSERT_WALLET_LNADDR, WALLET_BY_TYPE } from '@/fragments/wallet' import { REMOVE_WALLET, UPSERT_WALLET_LNADDR, WALLET_BY_TYPE } from '@/fragments/wallet'
import WalletLogs from '@/components/wallet-logs'
const variables = { type: 'LIGHTNING_ADDRESS' } const variables = { type: 'LIGHTNING_ADDRESS' }
export const getServerSideProps = getGetServerSideProps({ query: WALLET_BY_TYPE, variables, authRequired: true }) export const getServerSideProps = getGetServerSideProps({ query: WALLET_BY_TYPE, variables, authRequired: true })
@ -17,8 +18,21 @@ export default function LightningAddress ({ ssrData }) {
const me = useMe() const me = useMe()
const toaster = useToast() const toaster = useToast()
const router = useRouter() const router = useRouter()
const [upsertWalletLNAddr] = useMutation(UPSERT_WALLET_LNADDR) const client = useApolloClient()
const [removeWallet] = useMutation(REMOVE_WALLET) 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 || {} const { walletByType: wallet } = ssrData || {}
@ -49,7 +63,6 @@ export default function LightningAddress ({ ssrData }) {
router.push('/settings/wallets') router.push('/settings/wallets')
} catch (err) { } catch (err) {
console.error(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') router.push('/settings/wallets')
} catch (err) { } catch (err) {
console.error(err) console.error(err)
toaster.danger('failed to unattach:' + err.message || err.toString?.())
} }
}} }}
/> />
</Form> </Form>
<div className='mt-3 w-100'>
<WalletLogs wallet='lnAddr' embedded />
</div>
</CenterLayout> </CenterLayout>
) )
} }

@ -8,6 +8,7 @@ import { useRouter } from 'next/router'
import { useLNbits } from '@/components/webln/lnbits' import { useLNbits } from '@/components/webln/lnbits'
import { WalletSecurityBanner } from '@/components/banners' import { WalletSecurityBanner } from '@/components/banners'
import { useWebLNConfigurator } from '@/components/webln' import { useWebLNConfigurator } from '@/components/webln'
import WalletLogs from '@/components/wallet-logs'
export const getServerSideProps = getGetServerSideProps({ authRequired: true }) export const getServerSideProps = getGetServerSideProps({ authRequired: true })
@ -76,6 +77,9 @@ export default function LNbits () {
}} }}
/> />
</Form> </Form>
<div className='mt-3 w-100'>
<WalletLogs wallet='lnbits' embedded />
</div>
</CenterLayout> </CenterLayout>
) )
} }

@ -3,7 +3,7 @@ import { Form, Input } from '@/components/form'
import { CenterLayout } from '@/components/layout' import { CenterLayout } from '@/components/layout'
import { useMe } from '@/components/me' import { useMe } from '@/components/me'
import { WalletButtonBar, WalletCard } from '@/components/wallet-card' import { WalletButtonBar, WalletCard } from '@/components/wallet-card'
import { useMutation } from '@apollo/client' import { useApolloClient, useMutation } from '@apollo/client'
import { useToast } from '@/components/toast' import { useToast } from '@/components/toast'
import { LNDAutowithdrawSchema } from '@/lib/validate' import { LNDAutowithdrawSchema } from '@/lib/validate'
import { useRouter } from 'next/router' 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 { REMOVE_WALLET, UPSERT_WALLET_LND, WALLET_BY_TYPE } from '@/fragments/wallet'
import Info from '@/components/info' import Info from '@/components/info'
import Text from '@/components/text' import Text from '@/components/text'
import WalletLogs from '@/components/wallet-logs'
const variables = { type: 'LND' } const variables = { type: 'LND' }
export const getServerSideProps = getGetServerSideProps({ query: WALLET_BY_TYPE, variables, authRequired: true }) export const getServerSideProps = getGetServerSideProps({ query: WALLET_BY_TYPE, variables, authRequired: true })
@ -19,8 +20,21 @@ export default function LND ({ ssrData }) {
const me = useMe() const me = useMe()
const toaster = useToast() const toaster = useToast()
const router = useRouter() const router = useRouter()
const [upsertWalletLND] = useMutation(UPSERT_WALLET_LND) const client = useApolloClient()
const [removeWallet] = useMutation(REMOVE_WALLET) 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 || {} const { walletByType: wallet } = ssrData || {}
@ -55,7 +69,6 @@ export default function LND ({ ssrData }) {
router.push('/settings/wallets') router.push('/settings/wallets')
} catch (err) { } catch (err) {
console.error(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') router.push('/settings/wallets')
} catch (err) { } catch (err) {
console.error(err) console.error(err)
toaster.danger('failed to unattach:' + err.message || err.toString?.())
} }
}} }}
/> />
</Form> </Form>
<div className='mt-3 w-100'>
<WalletLogs wallet='lnd' embedded />
</div>
</CenterLayout> </CenterLayout>
) )
} }

@ -8,6 +8,7 @@ import { useRouter } from 'next/router'
import { useNWC } from '@/components/webln/nwc' import { useNWC } from '@/components/webln/nwc'
import { WalletSecurityBanner } from '@/components/banners' import { WalletSecurityBanner } from '@/components/banners'
import { useWebLNConfigurator } from '@/components/webln' import { useWebLNConfigurator } from '@/components/webln'
import WalletLogs from '@/components/wallet-logs'
export const getServerSideProps = getGetServerSideProps({ authRequired: true }) export const getServerSideProps = getGetServerSideProps({ authRequired: true })
@ -68,6 +69,9 @@ export default function NWC () {
}} }}
/> />
</Form> </Form>
<div className='mt-3 w-100'>
<WalletLogs wallet='nwc' embedded />
</div>
</CenterLayout> </CenterLayout>
) )
} }

@ -6,7 +6,7 @@ import { gql, useMutation, useQuery } from '@apollo/client'
import Qr, { QrSkeleton } from '@/components/qr' import Qr, { QrSkeleton } from '@/components/qr'
import { CenterLayout } from '@/components/layout' import { CenterLayout } from '@/components/layout'
import InputGroup from 'react-bootstrap/InputGroup' import InputGroup from 'react-bootstrap/InputGroup'
import { WithdrawlSkeleton } from './withdrawals/[id]' import { WithdrawlSkeleton } from '@/pages/withdrawals/[id]'
import { useMe } from '@/components/me' import { useMe } from '@/components/me'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { requestProvider } from 'webln' import { requestProvider } from 'webln'
@ -78,9 +78,18 @@ function YouHaveSats () {
function WalletHistory () { function WalletHistory () {
return ( return (
<Link href='/satistics?inc=invoice,withdrawal' className='text-muted fw-bold text-underline'> <div className='d-flex flex-column text-center'>
wallet history <div>
</Link> <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

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

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

@ -122,6 +122,7 @@ model User {
TerritoryReceives TerritoryTransfer[] @relation("TerritoryTransfer_newUser") TerritoryReceives TerritoryTransfer[] @relation("TerritoryTransfer_newUser")
AncestorReplies Reply[] @relation("AncestorReplyUser") AncestorReplies Reply[] @relation("AncestorReplyUser")
Replies Reply[] Replies Reply[]
walletLogs WalletLog[]
@@index([photoId]) @@index([photoId])
@@index([createdAt], map: "users.created_at_index") @@index([createdAt], map: "users.created_at_index")
@ -157,6 +158,18 @@ model Wallet {
@@index([userId]) @@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 { model WalletLightningAddress {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
walletId Int @unique walletId Int @unique
@ -874,4 +887,5 @@ enum LogLevel {
INFO INFO
WARN WARN
ERROR ERROR
SUCCESS
} }

24
styles/log.module.css Normal 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;
}
}

@ -1,7 +1,7 @@
import { authenticatedLndGrpc, createInvoice } from 'ln-service' 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 { 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 }) { export async function autoWithdraw ({ data: { id }, models, lnd }) {
const user = await models.user.findUnique({ where: { id } }) 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) { for (const wallet of wallets) {
try { try {
const message = `autowithdrawal of ${numWithUnits(amount, { abbreviate: false, unitSingular: 'sat', unitPlural: 'sats' })}`
if (wallet.type === 'LND') { if (wallet.type === 'LND') {
await autowithdrawLND( await autowithdrawLND(
{ amount, maxFee }, { amount, maxFee },
{ models, me: user, lnd }) { models, me: user, lnd })
await addWalletLog({
wallet: 'walletLND',
level: 'SUCCESS',
message
}, { me: user, models })
} else if (wallet.type === 'LIGHTNING_ADDRESS') { } else if (wallet.type === 'LIGHTNING_ADDRESS') {
await autowithdrawLNAddr( await autowithdrawLNAddr(
{ amount, maxFee }, { amount, maxFee },
{ models, me: user, lnd }) { models, me: user, lnd })
await addWalletLog({
wallet: 'walletLightningAddress',
level: 'SUCCESS',
message
}, { me: user, models })
} }
return return
} catch (error) { } catch (error) {
console.error(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
})
} }
} }