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