supercharged wallet logs (#1516)

* Inject wallet logger interface

* Include method in NWC logs

* Fix wrong page total

* Poll for new logs every second

* Fix overlapping pagination

* Remove unused total

* Better logs for incoming payments

* Use _setLogs instead of wrapper

* Remove inconsistent receive log

* Remove console.log from wallet logger on server

* Fix missing 'wallet detached' log

* Fix confirm_withdrawl code

* Remove duplicate autowithdrawal log

* Add context to log

* Add more context

* Better table styling

* Move CSS for wallet logs into one file

* remove unused logNav class
* rename classes

* Align key with second column

* Fix TypeError if context empty

* Check content-type header before calling res.json()

* Fix duplicate 'failed to create invoice'

* Parse details from LND error

* Fix invalid DOM property 'colspan'

* P2P zap logs with context

* Remove unnecessary withdrawal error log

* the code assignment was broken anyway
* we already log withdrawal errors using .catch on payViaPaymentRequest

* Don't show outgoing fee to receiver to avoid confusion

* Fix typo in comment

* Log if invoice was canceled by payer

* Automatically populate context from bolt11

* Fix missing context

* Fix wrap errors not logged

* Only log cancel if client canceled

* Remove unused imports

* Log withdrawal/forward success/error in payment flow

* Fix boss not passed to checkInvoice

* Fix TypeError

* Fix database timeouts caused by logger

The logger shares the same connection pool with any currently running transaction.

This means that we enter a classic deadlock when we await logger calls: the logger call is waiting for a connection but the currently running transaction is waiting for the logger call to finish before it can release a connection.

* Fix cache returning undefined

* Fix typo in comment

* Add padding-right to key in log context

* Always use 'incoming payment failed:'

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
This commit is contained in:
ekzyis 2024-11-08 20:26:40 +01:00 committed by GitHub
parent f1b2197d31
commit 72e2d19433
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 463 additions and 226 deletions

View File

@ -1,7 +1,7 @@
import { createHodlInvoice, createInvoice, parsePaymentRequest } from 'ln-service'
import { datePivot } from '@/lib/time'
import { PAID_ACTION_TERMINAL_STATES, USER_ID } from '@/lib/constants'
import { createHmac } from '../resolvers/wallet'
import { createHmac, walletLogger } from '@/api/resolvers/wallet'
import { Prisma } from '@prisma/client'
import * as ITEM_CREATE from './itemCreate'
import * as ITEM_UPDATE from './itemUpdate'
@ -249,15 +249,19 @@ export async function createLightningInvoice (actionType, args, context) {
}
if (userId) {
let logger, bolt11
try {
const description = await paidActions[actionType].describe(args, context)
const { invoice: bolt11, wallet } = await createUserInvoice(userId, {
const { invoice, wallet } = await createUserInvoice(userId, {
// this is the amount the stacker will receive, the other 3/10ths is the sybil fee
msats: cost * BigInt(7) / BigInt(10),
description,
expiry: INVOICE_EXPIRE_SECS
}, { models })
logger = walletLogger({ wallet, models })
bolt11 = invoice
// the sender (me) decides if the wrapped invoice has a description
// whereas the recipient decides if their invoice has a description
const { invoice: wrappedInvoice, maxFee } = await wrapInvoice(
@ -270,6 +274,7 @@ export async function createLightningInvoice (actionType, args, context) {
maxFee
}
} catch (e) {
logger?.error('invalid invoice: ' + e.message, { bolt11 })
console.error('failed to create stacker invoice, falling back to SN invoice', e)
}
}

View File

@ -7,7 +7,7 @@ import crypto, { timingSafeEqual } from 'crypto'
import serialize from './serial'
import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
import { SELECT, itemQueryWithMeta } from './item'
import { msatsToSats, msatsToSatsDecimal } from '@/lib/format'
import { formatMsats, formatSats, msatsToSats, msatsToSatsDecimal } from '@/lib/format'
import {
ANON_BALANCE_LIMIT_MSATS, ANON_INV_PENDING_LIMIT, USER_ID, BALANCE_LIMIT_MSATS,
INVOICE_RETENTION_DAYS, INV_PENDING_LIMIT, USER_IDS_BALANCE_NO_LIMIT, LND_PATHFINDING_TIMEOUT_MS
@ -54,20 +54,25 @@ function injectResolvers (resolvers) {
settings && Object.keys(validData).filter(key => key in settings).forEach(key => { settings[key] = validData[key] })
}
// wallet in shape of db row
const wallet = {
field: walletDef.walletField,
type: walletDef.walletType,
userId: me?.id
}
const logger = walletLogger({ wallet, models })
return await upsertWallet({
wallet: {
field: walletDef.walletField,
type: walletDef.walletType
},
wallet,
testCreateInvoice:
walletDef.testCreateInvoice && validateLightning && canReceive({ def: walletDef, config: data })
? (data) => walletDef.testCreateInvoice(data, { me, models })
? (data) => walletDef.testCreateInvoice(data, { logger, me, models })
: null
}, {
settings,
data,
vaultEntries
}, { me, models })
}, { logger, me, models })
}
}
console.groupEnd()
@ -406,7 +411,7 @@ const resolvers = {
userId: me.id,
wallet: type ?? undefined,
createdAt: {
gte: from ? new Date(Number(from)) : undefined,
gt: from ? new Date(Number(from)) : undefined,
lte: to ? new Date(Number(to)) : undefined
}
},
@ -502,7 +507,15 @@ const resolvers = {
sendToLnAddr,
cancelInvoice: async (parent, { hash, hmac }, { models, lnd, boss }) => {
verifyHmac(hash, hmac)
await finalizeHodlInvoice({ data: { hash }, lnd, models, boss })
const dbInv = await finalizeHodlInvoice({ data: { hash }, lnd, models, boss })
if (dbInv.invoiceForward) {
const { wallet, bolt11 } = dbInv.invoiceForward
const logger = walletLogger({ wallet, models })
const decoded = await parsePaymentRequest({ request: bolt11 })
logger.info(`invoice for ${formatSats(msatsToSats(decoded.mtokens))} canceled by payer`, { bolt11 })
}
return await models.invoice.findFirst({ where: { hash } })
},
dropBolt11: async (parent, { id }, { me, models, lnd }) => {
@ -561,11 +574,9 @@ const resolvers = {
throw new GqlInputError('wallet not found')
}
await models.$transaction([
models.wallet.delete({ where: { userId: me.id, id: Number(id) } }),
models.walletLog.create({ data: { userId: me.id, wallet: wallet.type, level: 'INFO', message: 'receives disabled' } }),
models.walletLog.create({ data: { userId: me.id, wallet: wallet.type, level: 'SUCCESS', message: 'wallet detached for receives' } })
])
const logger = walletLogger({ wallet, models })
await models.wallet.delete({ where: { userId: me.id, id: Number(id) } })
logger.info('wallet detached')
return true
},
@ -663,16 +674,47 @@ const resolvers = {
export default injectResolvers(resolvers)
export const addWalletLog = async ({ wallet, level, message }, { models }) => {
try {
await models.walletLog.create({ data: { userId: wallet.userId, wallet: wallet.type, level, message } })
} catch (err) {
console.error('error creating wallet log:', err)
export const walletLogger = ({ wallet, models }) => {
// server implementation of wallet logger interface on client
const log = (level) => async (message, context = {}) => {
try {
if (context?.bolt11) {
// automatically populate context from bolt11 to avoid duplicating this code
const decoded = await parsePaymentRequest({ request: context.bolt11 })
context = {
...context,
amount: formatMsats(decoded.mtokens),
payment_hash: decoded.id,
created_at: decoded.created_at,
expires_at: decoded.expires_at,
description: decoded.description
}
}
await models.walletLog.create({
data: {
userId: wallet.userId,
wallet: wallet.type,
level,
message,
context
}
})
} catch (err) {
console.error('error creating wallet log:', err)
}
}
return {
ok: (message, context) => log('SUCCESS')(message, context),
info: (message, context) => log('INFO')(message, context),
error: (message, context) => log('ERROR')(message, context),
warn: (message, context) => log('WARN')(message, context)
}
}
async function upsertWallet (
{ wallet, testCreateInvoice }, { settings, data, vaultEntries }, { me, models }) {
{ wallet, testCreateInvoice }, { settings, data, vaultEntries }, { logger, me, models }) {
if (!me) {
throw new GqlAuthenticationError()
}
@ -682,11 +724,8 @@ async function upsertWallet (
try {
await testCreateInvoice(data)
} catch (err) {
console.error(err)
const message = 'failed to create test invoice: ' + (err.message || err.toString?.())
wallet = { ...wallet, userId: me.id }
await addWalletLog({ wallet, level: 'ERROR', message }, { models })
await addWalletLog({ wallet, level: 'INFO', message: 'receives disabled' }, { models })
logger.error(message)
throw new GqlInputError(message)
}
}
@ -804,7 +843,7 @@ async function upsertWallet (
return upsertedWallet
}
export async function createWithdrawal (parent, { invoice, maxFee }, { me, models, lnd, headers, walletId = null }) {
export async function createWithdrawal (parent, { invoice, maxFee }, { me, models, lnd, headers, wallet, logger }) {
assertApiKeyNotPermitted({ me })
await validateSchema(withdrawlSchema, { invoice, maxFee })
await assertGofacYourself({ models, headers })
@ -836,22 +875,22 @@ export async function createWithdrawal (parent, { invoice, maxFee }, { me, model
}
if (!decoded.mtokens || BigInt(decoded.mtokens) <= 0) {
throw new GqlInputError('your invoice must specify an amount')
throw new GqlInputError('invoice must specify an amount')
}
if (decoded.mtokens > Number.MAX_SAFE_INTEGER) {
throw new GqlInputError('your invoice amount is too large')
throw new GqlInputError('invoice amount is too large')
}
const msatsFee = Number(maxFee) * 1000
const user = await models.user.findUnique({ where: { id: me.id } })
const autoWithdraw = !!walletId
const autoWithdraw = !!wallet?.id
// create withdrawl transactionally (id, bolt11, amount, fee)
const [withdrawl] = await serialize(
models.$queryRaw`SELECT * FROM create_withdrawl(${decoded.id}, ${invoice},
${Number(decoded.mtokens)}, ${msatsFee}, ${user.name}, ${autoWithdraw}, ${walletId}::INTEGER)`,
${Number(decoded.mtokens)}, ${msatsFee}, ${user.name}, ${autoWithdraw}, ${wallet?.id}::INTEGER)`,
{ models }
)

View File

@ -170,6 +170,7 @@ const typeDefs = `
wallet: ID!
level: String!
message: String!
context: JSONObject
}
`

View File

@ -1,15 +1,52 @@
import { timeSince } from '@/lib/time'
import styles from './log-message.module.css'
import styles from '@/styles/log.module.css'
import { Fragment, useState } from 'react'
export default function LogMessage ({ showWallet, wallet, level, message, context, ts }) {
const [show, setShow] = useState(false)
let className
switch (level.toLowerCase()) {
case 'ok':
case 'success':
level = 'ok'
className = 'text-success'; break
case 'error':
className = 'text-danger'; break
case 'warn':
className = 'text-warning'; break
default:
className = 'text-info'
}
const hasContext = context && Object.keys(context).length > 0
const handleClick = () => {
if (hasContext) { setShow(show => !show) }
}
const style = hasContext ? { cursor: 'pointer' } : { cursor: 'inherit' }
const indicator = hasContext ? (show ? '-' : '+') : <></>
export default function LogMessage ({ showWallet, 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>
{showWallet ? <td className={styles.wallet}>[{wallet}]</td> : <td className='mx-1' />}
<td className={`${styles.level} ${levelClassName}`}>{level === 'success' ? 'ok' : level}</td>
<td>{message}</td>
</tr>
<>
<tr className={styles.tableRow} onClick={handleClick} style={style}>
<td className={styles.timestamp}>{timeSince(new Date(ts))}</td>
{showWallet ? <td className={styles.wallet}>[{wallet}]</td> : <td className='mx-1' />}
<td className={`${styles.level} ${className}`}>{level}</td>
<td>{message}</td>
<td>{indicator}</td>
</tr>
{show && hasContext && Object.entries(context).map(([key, value], i) => {
const last = i === Object.keys(context).length - 1
return (
<tr className={styles.line} key={i}>
<td />
<td className={last ? 'pb-2 pe-1' : 'pe-1'} colSpan='2'>{key}</td>
<td className={last ? 'text-break pb-2' : 'text-break'}>{value}</td>
</tr>
)
})}
</>
)
}

View File

@ -1,23 +0,0 @@
.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

@ -0,0 +1,10 @@
import { useEffect } from 'react'
function useInterval (cb, ms, deps) {
return useEffect(() => {
const interval = setInterval(cb, ms)
return () => clearInterval(interval)
}, deps)
}
export default useInterval

View File

@ -1,5 +1,5 @@
import LogMessage from './log-message'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useMemo, useState } from 'react'
import styles from '@/styles/log.module.css'
import { Button } from 'react-bootstrap'
import { useToast } from './toast'
@ -10,6 +10,9 @@ import { gql, useLazyQuery, useMutation } from '@apollo/client'
import { useMe } from './me'
import useIndexedDB, { getDbName } from './use-indexeddb'
import { SSR } from '@/lib/constants'
import useInterval from './use-interval'
import { decode as bolt11Decode } from 'bolt11'
import { formatMsats } from '@/lib/format'
export function WalletLogs ({ wallet, embedded }) {
const { logs, setLogs, hasMore, loadMore, loading } = useWalletLogs(wallet)
@ -27,8 +30,15 @@ export function WalletLogs ({ wallet, embedded }) {
>clear logs
</span>
</div>
<div className={`${styles.logTable} ${embedded ? styles.embedded : ''}`}>
<div className={`${styles.tableContainer} ${embedded ? styles.embedded : ''}`}>
<table>
<colgroup>
<col span='1' style={{ width: '1rem' }} />
<col span='1' style={{ width: '1rem' }} />
<col span='1' style={{ width: '1rem' }} />
<col span='1' style={{ width: '100%' }} />
<col span='1' style={{ width: '1rem' }} />
</colgroup>
<tbody>
{logs.map((log, i) => (
<LogMessage
@ -103,8 +113,8 @@ function useWalletLogDB () {
export function useWalletLogger (wallet, setLogs) {
const { add, clear, notSupported } = useWalletLogDB()
const appendLog = useCallback(async (wallet, level, message) => {
const log = { wallet: tag(wallet), level, message, ts: +new Date() }
const appendLog = useCallback(async (wallet, level, message, context) => {
const log = { wallet: tag(wallet), level, message, ts: +new Date(), context }
try {
if (notSupported) {
console.log('cannot persist wallet log: indexeddb not supported')
@ -149,20 +159,33 @@ export function useWalletLogger (wallet, setLogs) {
}
}, [clear, deleteServerWalletLogs, setLogs, notSupported])
const log = useCallback(level => message => {
const log = useCallback(level => (message, context = {}) => {
if (!wallet) {
// console.error('cannot log: no wallet set')
return
}
appendLog(wallet, level, message)
if (context?.bolt11) {
// automatically populate context from bolt11 to avoid duplicating this code
const decoded = bolt11Decode(context.bolt11)
context = {
...context,
amount: formatMsats(Number(decoded.millisatoshis)),
payment_hash: decoded.tagsObject.payment_hash,
description: decoded.tagsObject.description,
created_at: new Date(decoded.timestamp * 1000).toISOString(),
expires_at: new Date(decoded.timeExpireDate * 1000).toISOString()
}
}
appendLog(wallet, level, message, context)
console[level !== 'error' ? 'info' : 'error'](`[${tag(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(' '))
ok: (message, context) => log('ok')(message, context),
info: (message, context) => log('info')(message, context),
error: (message, context) => log('error')(message, context)
}), [log])
return { logger, deleteLogs }
@ -176,7 +199,6 @@ export function useWalletLogs (wallet, initialPage = 1, logsPerPage = 10) {
const [logs, _setLogs] = useState([])
const [page, setPage] = useState(initialPage)
const [hasMore, setHasMore] = useState(true)
const [total, setTotal] = useState(0)
const [cursor, setCursor] = useState(null)
const [loading, setLoading] = useState(true)
@ -191,7 +213,7 @@ export function useWalletLogs (wallet, initialPage = 1, logsPerPage = 10) {
if (newLogs.length === 0) setHasMore(false)
}, [logs, _setLogs, setHasMore])
const loadLogsPage = useCallback(async (page, pageSize, walletDef) => {
const loadLogsPage = useCallback(async (page, pageSize, walletDef, variables = {}) => {
try {
let result = { data: [], hasMore: false }
if (notSupported) {
@ -206,14 +228,38 @@ export function useWalletLogs (wallet, initialPage = 1, logsPerPage = 10) {
return result
}
}
const oldestTs = result?.data[result.data.length - 1]?.ts // start of local logs
const newestTs = result?.data[0]?.ts // end of local logs
let from
if (variables?.from !== undefined) {
from = variables.from
} else if (oldestTs && result.hasMore) {
// fetch all missing, intertwined server logs since start of local logs
from = String(oldestTs)
} else {
from = null
}
let to
if (variables?.to !== undefined) {
to = variables.to
} else if (newestTs && cursor) {
// fetch next old page of server logs
// ( if cursor is available, we will use decoded time of cursor )
to = String(newestTs)
} else {
to = null
}
const { data } = await getWalletLogs({
variables: {
type: walletDef?.walletType,
// if it client logs has more, page based on it's range
from: result?.data[result.data.length - 1]?.ts && result.hasMore ? String(result.data[result.data.length - 1].ts) : null,
// if we have a cursor (this isn't the first page), page based on it's range
to: result?.data[0]?.ts && cursor ? String(result.data[0].ts) : null,
cursor
from,
to,
cursor,
...variables
}
})
@ -222,13 +268,17 @@ export function useWalletLogs (wallet, initialPage = 1, logsPerPage = 10) {
wallet: tag(getWalletByType(walletType)),
...log
}))
const combinedLogs = Array.from(new Set([...result.data, ...newLogs].map(JSON.stringify))).map(JSON.parse).sort((a, b) => b.ts - a.ts)
const combinedLogs = uniqueSort([...result.data, ...newLogs])
setCursor(data.walletLogs.cursor)
return { ...result, data: combinedLogs, hasMore: result.hasMore || !!data.walletLogs.cursor }
return {
...result,
data: combinedLogs,
hasMore: result.hasMore || !!data.walletLogs.cursor
}
} catch (error) {
console.error('Error loading logs from IndexedDB:', error)
return { data: [], total: 0, hasMore: false }
return { data: [], hasMore: false }
}
}, [getPage, setCursor, cursor, notSupported])
@ -240,27 +290,33 @@ export function useWalletLogs (wallet, initialPage = 1, logsPerPage = 10) {
if (hasMore) {
setLoading(true)
const result = await loadLogsPage(page + 1, logsPerPage, wallet?.def)
setLogs(prevLogs => [...prevLogs, ...result.data])
_setLogs(prevLogs => [...prevLogs, ...result.data])
setHasMore(result.hasMore)
setTotal(result.total)
setPage(prevPage => prevPage + 1)
setLoading(false)
}
}, [loadLogsPage, page, logsPerPage, wallet?.def, hasMore])
const loadLogs = useCallback(async () => {
setLoading(true)
const result = await loadLogsPage(1, logsPerPage, wallet?.def)
setLogs(result.data)
setHasMore(result.hasMore)
setTotal(result.total)
setPage(1)
const loadNew = useCallback(async () => {
const newestTs = logs[0]?.ts
const variables = { from: newestTs?.toString(), to: null }
const result = await loadLogsPage(1, logsPerPage, wallet?.def, variables)
setLoading(false)
}, [wallet?.def, loadLogsPage])
_setLogs(prevLogs => uniqueSort([...result.data, ...prevLogs]))
if (!newestTs) {
// we only want to update the more button if we didn't fetch new logs since it is about old logs.
// we didn't fetch new logs if this is our first fetch (no newest timestamp available)
setHasMore(result.hasMore)
}
}, [logs, wallet?.def, loadLogsPage])
useEffect(() => {
loadLogs()
}, [wallet?.def])
useInterval(() => {
loadNew().catch(console.error)
}, 1_000, [loadNew])
return { logs, hasMore, total, loadMore, loadLogs, setLogs, loading }
return { logs, hasMore: !loading && hasMore, loadMore, setLogs, loading }
}
function uniqueSort (logs) {
return Array.from(new Set(logs.map(JSON.stringify))).map(JSON.parse).sort((a, b) => b.ts - a.ts)
}

View File

@ -191,6 +191,7 @@ export const WALLET_LOGS = gql`
wallet
level
message
context
}
}
}

View File

@ -1,6 +1,7 @@
import fetch from 'cross-fetch'
import crypto from 'crypto'
import { getAgent } from '@/lib/proxy'
import { assertContentTypeJson } from './url'
export const createInvoice = async ({ socket, rune, cert, label, description, msats, expiry }) => {
const agent = getAgent({ hostname: socket, cert })
@ -25,6 +26,11 @@ export const createInvoice = async ({ socket, rune, cert, label, description, ms
expiry
})
})
if (!res.ok) {
assertContentTypeJson(res)
}
const inv = await res.json()
if (inv.error) {
throw new Error(inv.error.message)

View File

@ -166,9 +166,9 @@ export function cachedFetcher (fetcher, {
logger.incrementCacheMisses()
logger.log(`Cache miss for key: ${key}`)
const entry = { createdAt: now, pendingPromise: fetchAndCache() }
cache.set(key, entry)
try {
entry.data = await entry.pendingPromise
cache.set(key, entry)
return entry.data
} catch (error) {
logger.errorLog(`Error fetching data for key: ${key}`, error)

View File

@ -72,6 +72,9 @@ export const msatsToSatsDecimal = msats => {
return fixedDecimal(Number(msats) / 1000.0, 3)
}
export const formatSats = (sats) => numWithUnits(sats, { unitSignular: 'sat', unitPlural: 'sats', abbreviate: false })
export const formatMsats = (msats) => numWithUnits(msats, { unitSignular: 'msat', unitPlural: 'msats', abbreviate: false })
export const hexToB64 = hexstring => {
return btoa(hexstring.match(/\w{2}/g).map(function (a) {
return String.fromCharCode(parseInt(a, 16))

View File

@ -213,6 +213,13 @@ export function parseNwcUrl (walletConnectUrl) {
return params
}
export function assertContentTypeJson (res) {
const contentType = res.headers.get('content-type')
if (!contentType || !contentType.includes('application/json')) {
throw new Error(`POST ${res.url}: ${res.status} ${res.statusText}`)
}
}
export function decodeProxyUrl (imgproxyUrl) {
const parts = imgproxyUrl.split('/')
// base64url is not a known encoding in browsers

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "WalletLog" ADD COLUMN "context" JSONB;

View File

@ -245,6 +245,7 @@ model WalletLog {
wallet WalletType
level LogLevel
message String
context Json? @db.JsonB
@@index([userId, createdAt])
}

View File

@ -1,9 +1,4 @@
.logNav {
text-align: center;
color: var(--theme-grey) !important; /* .text-muted */
}
.logTable {
.tableContainer {
width: 100%;
max-height: 60svh;
overflow-y: auto;
@ -14,7 +9,7 @@
}
@media screen and (min-width: 768px) {
.logTable {
.tableContainer {
max-height: 70svh;
}
@ -22,3 +17,26 @@
max-height: 30svh;
}
}
.tableRow {
font-family: monospace;
color: var(--theme-grey) !important; /* .text-muted */
}
.timestamp {
vertical-align: top;
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

@ -54,7 +54,7 @@ export async function createInvoice (
const res = out.data.lnInvoiceCreate
const errors = res.errors
if (errors && errors.length > 0) {
throw new Error('failed to create invoice ' + errors.map(e => e.code + ' ' + e.message).join(', '))
throw new Error(errors.map(e => e.code + ' ' + e.message).join(', '))
}
const invoice = res.invoice.paymentRequest
return invoice

View File

@ -6,9 +6,10 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState }
import { getStorageKey, getWalletByType, Status, walletPrioritySort, canSend, isConfigured, upsertWalletVariables, siftConfig, saveWalletLocally } from './common'
import useVault from '@/components/vault/use-vault'
import { useWalletLogger } from '@/components/wallet-logger'
import { bolt11Tags } from '@/lib/bolt11'
import { decode as bolt11Decode } from 'bolt11'
import walletDefs from 'wallets/client'
import { generateMutation } from './graphql'
import { formatSats } from '@/lib/format'
const WalletsContext = createContext({
wallets: []
@ -231,14 +232,14 @@ export function useWallet (name) {
const { logger } = useWalletLogger(wallet?.def)
const sendPayment = useCallback(async (bolt11) => {
const hash = bolt11Tags(bolt11).payment_hash
logger.info('sending payment:', `payment_hash=${hash}`)
const decoded = bolt11Decode(bolt11)
logger.info(`↗ sending payment: ${formatSats(decoded.satoshis)}`, { bolt11 })
try {
const preimage = await wallet.def.sendPayment(bolt11, wallet.config, { logger })
logger.ok('payment successful:', `payment_hash=${hash}`, `preimage=${preimage}`)
logger.ok(`↗ payment sent: ${formatSats(decoded.satoshis)}`, { bolt11, preimage })
} catch (err) {
const message = err.message || err.toString?.()
logger.error('payment failed:', `payment_hash=${hash}`, message)
logger.error(`payment failed: ${message}`, { bolt11 })
throw err
}
}, [wallet, logger])

View File

@ -1,3 +1,5 @@
import { assertContentTypeJson } from '@/lib/url'
export * from 'wallets/lnbits'
export async function testSendPayment ({ url, adminKey, invoiceKey }, { logger }) {
@ -32,6 +34,7 @@ async function getWallet ({ url, adminKey, invoiceKey }) {
const res = await fetch(url + path, { method: 'GET', headers })
if (!res.ok) {
assertContentTypeJson(res)
const errBody = await res.json()
throw new Error(errBody.detail)
}
@ -52,6 +55,7 @@ async function postPayment (bolt11, { url, adminKey }) {
const res = await fetch(url + path, { method: 'POST', headers, body })
if (!res.ok) {
assertContentTypeJson(res)
const errBody = await res.json()
throw new Error(errBody.detail)
}
@ -70,6 +74,7 @@ async function getPayment (paymentHash, { url, adminKey }) {
const res = await fetch(url + path, { method: 'GET', headers })
if (!res.ok) {
assertContentTypeJson(res)
const errBody = await res.json()
throw new Error(errBody.detail)
}

View File

@ -1,5 +1,6 @@
import { msatsToSats } from '@/lib/format'
import { getAgent } from '@/lib/proxy'
import { assertContentTypeJson } from '@/lib/url'
import fetch from 'cross-fetch'
export * from 'wallets/lnbits'
@ -44,6 +45,7 @@ export async function createInvoice (
body
})
if (!res.ok) {
assertContentTypeJson(res)
const errBody = await res.json()
throw new Error(errBody.detail)
}

View File

@ -26,7 +26,7 @@ async function disconnect (lnc, logger) {
}, 50)
logger.info('disconnected')
} catch (err) {
logger.error('failed to disconnect from lnc', err)
logger.error('failed to disconnect from lnc: ' + err)
}
}
}

View File

@ -63,17 +63,17 @@ export async function nwcCall ({ nwcUrl, method, params }, { logger, timeout } =
logger?.info(`published ${method} request`)
logger?.info('waiting for response ...')
logger?.info(`waiting for ${method} response ...`)
const [response] = await subscription
if (!response) {
throw new Error('no response')
throw new Error(`no ${method} response`)
}
logger?.ok('response received')
logger?.ok(`${method} response received`)
if (!verifyEvent(response)) throw new Error('invalid response: failed to verify')
if (!verifyEvent(response)) throw new Error(`invalid ${method} response: failed to verify`)
const decrypted = await nip04.decrypt(secret, walletPubkey, response.content)
const content = JSON.parse(decrypted)
@ -81,7 +81,7 @@ export async function nwcCall ({ nwcUrl, method, params }, { logger, timeout } =
if (content.error) throw new Error(content.error.message)
if (content.result) return content.result
throw new Error('invalid response: missing error or result')
throw new Error(`invalid ${method} response: missing error or result`)
} finally {
relay?.close()
logger?.info(`closed connection to ${relayUrl}`)

View File

@ -2,10 +2,10 @@ import { withTimeout } from '@/lib/time'
import { nwcCall, supportedMethods } from 'wallets/nwc'
export * from 'wallets/nwc'
export async function testCreateInvoice ({ nwcUrlRecv }) {
export async function testCreateInvoice ({ nwcUrlRecv }, { logger }) {
const timeout = 15_000
const supported = await supportedMethods(nwcUrlRecv, { timeout })
const supported = await supportedMethods(nwcUrlRecv, { logger, timeout })
const supports = (method) => supported.includes(method)
@ -20,12 +20,12 @@ export async function testCreateInvoice ({ nwcUrlRecv }) {
}
}
return await withTimeout(createInvoice({ msats: 1000, expiry: 1 }, { nwcUrlRecv }), timeout)
return await withTimeout(createInvoice({ msats: 1000, expiry: 1 }, { nwcUrlRecv }, { logger }), timeout)
}
export async function createInvoice (
{ msats, description, expiry },
{ nwcUrlRecv }) {
{ nwcUrlRecv }, { logger }) {
const result = await nwcCall({
nwcUrl: nwcUrlRecv,
method: 'make_invoice',
@ -34,6 +34,6 @@ export async function createInvoice (
description,
expiry
}
})
}, { logger })
return result.invoice
}

View File

@ -25,8 +25,7 @@ export async function sendPayment (bolt11, { url, primaryPassword }) {
body
})
if (!res.ok) {
const error = await res.text()
throw new Error(error)
throw new Error(`POST ${res.url}: ${res.status} ${res.statusText}`)
}
const payment = await res.json()

View File

@ -29,8 +29,7 @@ export async function createInvoice (
body
})
if (!res.ok) {
const error = await res.text()
throw new Error(error)
throw new Error(`POST ${res.url}: ${res.status} ${res.statusText}`)
}
const payment = await res.json()

View File

@ -11,13 +11,14 @@ import * as blink from 'wallets/blink/server'
import * as lnc from 'wallets/lnc'
import * as webln from 'wallets/webln'
import { addWalletLog } from '@/api/resolvers/wallet'
import { walletLogger } from '@/api/resolvers/wallet'
import walletDefs from 'wallets/server'
import { parsePaymentRequest } from 'ln-service'
import { toPositiveNumber } from '@/lib/validate'
import { PAID_ACTION_TERMINAL_STATES } from '@/lib/constants'
import { withTimeout } from '@/lib/time'
import { canReceive } from './common'
import { formatMsats, formatSats, msatsToSats } from '@/lib/format'
export default [lnd, cln, lnAddr, lnbits, nwc, phoenixd, blink, lnc, webln]
@ -41,90 +42,125 @@ export async function createInvoice (userId, { msats, description, descriptionHa
for (const wallet of wallets) {
const w = walletDefs.find(w => w.walletType === wallet.type)
const config = wallet.wallet
if (!canReceive({ def: w, config })) {
continue
}
const logger = walletLogger({ wallet, models })
try {
if (!canReceive({ def: w, config: wallet.wallet })) {
continue
logger.info(
`↙ incoming payment: ${formatSats(msatsToSats(msats))}`,
{
amount: formatMsats(msats)
})
let invoice
try {
invoice = await walletCreateInvoice(
{ msats, description, descriptionHash, expiry },
{ ...w, userId, createInvoice: w.createInvoice },
{ logger, models })
} catch (err) {
throw new Error('failed to create invoice: ' + err.message)
}
const { walletType, walletField, createInvoice } = w
const walletFull = await models.wallet.findFirst({
where: {
userId,
type: walletType
},
include: {
[walletField]: true
}
})
if (!walletFull || !walletFull[walletField]) {
throw new Error(`no ${walletType} wallet found`)
}
// check for pending withdrawals
const pendingWithdrawals = await models.withdrawl.count({
where: {
walletId: walletFull.id,
status: null
}
})
// and pending forwards
const pendingForwards = await models.invoiceForward.count({
where: {
walletId: walletFull.id,
invoice: {
actionState: {
notIn: PAID_ACTION_TERMINAL_STATES
}
}
}
})
console.log('pending invoices', pendingWithdrawals + pendingForwards)
if (pendingWithdrawals + pendingForwards >= MAX_PENDING_INVOICES_PER_WALLET) {
throw new Error('wallet has too many pending invoices')
}
console.log('use wallet', walletType)
const invoice = await withTimeout(
createInvoice({
msats,
description: wallet.user.hideInvoiceDesc ? undefined : description,
descriptionHash,
expiry
}, walletFull[walletField]), 10_000)
const bolt11 = await parsePaymentRequest({ request: invoice })
logger.info(`created invoice for ${formatSats(msatsToSats(bolt11.mtokens))}`, {
bolt11: invoice
})
if (BigInt(bolt11.mtokens) !== BigInt(msats)) {
if (BigInt(bolt11.mtokens) > BigInt(msats)) {
throw new Error(`invoice is for an amount greater than requested ${bolt11.mtokens} > ${msats}`)
throw new Error('invoice invalid: amount too big')
}
if (BigInt(bolt11.mtokens) === 0n) {
throw new Error('invoice is for 0 msats')
throw new Error('invoice invalid: amount is 0 msats')
}
if (BigInt(msats) - BigInt(bolt11.mtokens) >= 1000n) {
throw new Error(`invoice has a different satoshi amount ${bolt11.mtokens} !== ${msats}`)
throw new Error('invoice invalid: amount too small')
}
await addWalletLog({
wallet,
level: 'INFO',
message: `wallet does not support msats so we floored ${msats} msats to nearest sat ${BigInt(bolt11.mtokens)} msats`
}, { models })
logger.warn('wallet does not support msats')
}
return { invoice, wallet }
} catch (error) {
console.error(error)
await addWalletLog({
wallet,
level: 'ERROR',
message: `creating invoice for ${description ?? ''} failed: ` + error
}, { models })
return { invoice, wallet, logger }
} catch (err) {
logger.error(err.message)
}
}
throw new Error('no wallet available')
throw new Error('no wallet to receive available')
}
async function walletCreateInvoice (
{
msats,
description,
descriptionHash,
expiry = 360
},
{
userId,
walletType,
walletField,
createInvoice
},
{ logger, models }) {
const wallet = await models.wallet.findFirst({
where: {
userId,
type: walletType
},
include: {
[walletField]: true,
user: true
}
})
const config = wallet[walletField]
if (!wallet || !config) {
throw new Error('wallet not found')
}
// check for pending withdrawals
const pendingWithdrawals = await models.withdrawl.count({
where: {
walletId: wallet.id,
status: null
}
})
// and pending forwards
const pendingForwards = await models.invoiceForward.count({
where: {
walletId: wallet.id,
invoice: {
actionState: {
notIn: PAID_ACTION_TERMINAL_STATES
}
}
}
})
const pending = pendingWithdrawals + pendingForwards
if (pendingWithdrawals + pendingForwards >= MAX_PENDING_INVOICES_PER_WALLET) {
throw new Error(`too many pending invoices: has ${pending}, max ${MAX_PENDING_INVOICES_PER_WALLET}`)
}
return await withTimeout(
createInvoice(
{
msats,
description: wallet.user.hideInvoiceDesc ? undefined : description,
descriptionHash,
expiry
},
config,
{ logger }
), 10_000)
}

View File

@ -42,8 +42,14 @@ export async function autoWithdraw ({ data: { id }, models, lnd }) {
if (pendingOrFailed.exists) return
const { invoice, wallet } = await createInvoice(id, { msats, description: 'SN: autowithdrawal', expiry: 360 }, { models })
return await createWithdrawal(null,
{ invoice, maxFee: msatsToSats(maxFeeMsats) },
{ me: { id }, models, lnd, walletId: wallet.id })
const { invoice, wallet, logger } = await createInvoice(id, { msats, description: 'SN: autowithdrawal', expiry: 360 }, { models })
try {
return await createWithdrawal(null,
{ invoice, maxFee: msatsToSats(maxFeeMsats) },
{ me: { id }, models, lnd, wallet, logger })
} catch (err) {
logger.error(`incoming payment failed: ${err}`, { bolt11: invoice })
throw err
}
}

View File

@ -1,6 +1,8 @@
import { getPaymentFailureStatus, hodlInvoiceCltvDetails } from '@/api/lnd'
import { paidActions } from '@/api/paidAction'
import { walletLogger } from '@/api/resolvers/wallet'
import { LND_PATHFINDING_TIMEOUT_MS, PAID_ACTION_TERMINAL_STATES } from '@/lib/constants'
import { formatMsats, formatSats, msatsToSats } from '@/lib/format'
import { datePivot } from '@/lib/time'
import { toPositiveNumber } from '@/lib/validate'
import { Prisma } from '@prisma/client'
@ -268,7 +270,7 @@ export async function paidActionForwarded ({ data: { invoiceId, withdrawal, ...a
throw new Error('invoice is not held')
}
const { hash, msatsPaying } = dbInvoice.invoiceForward.withdrawl
const { bolt11, hash, msatsPaying } = dbInvoice.invoiceForward.withdrawl
const { payment, is_confirmed: isConfirmed } = withdrawal ?? await getPayment({ id: hash, lnd })
if (!isConfirmed) {
throw new Error('payment is not confirmed')
@ -277,6 +279,17 @@ export async function paidActionForwarded ({ data: { invoiceId, withdrawal, ...a
// settle the invoice, allowing us to transition to PAID
await settleHodlInvoice({ secret: payment.secret, lnd })
const logger = walletLogger({ wallet: dbInvoice.invoiceForward.wallet, models })
logger.ok(
`↙ payment received: ${formatSats(msatsToSats(payment.mtokens))}`,
{
bolt11,
preimage: payment.secret
// we could show the outgoing fee that we paid from the incoming amount to the receiver
// but we don't since it might look like the receiver paid the fee but that's not the case.
// fee: formatMsats(Number(payment.fee_mtokens))
})
return {
preimage: payment.secret,
invoiceForward: {
@ -330,12 +343,21 @@ export async function paidActionFailedForward ({ data: { invoiceId, withdrawal:
// which once it does succeed will ensure we will try to cancel the held invoice until it actually cancels
await boss.send('finalizeHodlInvoice', { hash: dbInvoice.hash }, FINALIZE_OPTIONS)
const { status, message } = getPaymentFailureStatus(withdrawal)
const { bolt11, msatsFeePaying } = dbInvoice.invoiceForward.withdrawl
const logger = walletLogger({ wallet: dbInvoice.invoiceForward.wallet, models })
logger.warn(
`incoming payment failed: ${message}`, {
bolt11,
max_fee: formatMsats(Number(msatsFeePaying))
})
return {
invoiceForward: {
update: {
withdrawl: {
update: {
status: getPaymentFailureStatus(withdrawal).status
status
}
}
}

View File

@ -7,15 +7,15 @@ import { notifyDeposit, notifyWithdrawal } from '@/lib/webPush'
import { INVOICE_RETENTION_DAYS, LND_PATHFINDING_TIMEOUT_MS } from '@/lib/constants'
import { datePivot, sleep } from '@/lib/time'
import retry from 'async-retry'
import { addWalletLog } from '@/api/resolvers/wallet'
import { msatsToSats, numWithUnits } from '@/lib/format'
import {
paidActionPaid, paidActionForwarded,
paidActionFailedForward, paidActionHeld, paidActionFailed,
paidActionForwarding,
paidActionCanceling
} from './paidAction'
import { getPaymentFailureStatus } from '@/api/lnd/index'
import { getPaymentFailureStatus } from '@/api/lnd/index.js'
import { walletLogger } from '@/api/resolvers/wallet.js'
import { formatMsats, formatSats, msatsToSats } from '@/lib/format.js'
export async function subscribeToWallet (args) {
await subscribeToDeposits(args)
@ -287,6 +287,8 @@ export async function checkWithdrawal ({ data: { hash, withdrawal, invoice }, bo
}
}
const logger = walletLogger({ models, wallet: dbWdrwl.wallet })
if (wdrwl?.is_confirmed) {
if (dbWdrwl.invoiceForward.length > 0) {
return await paidActionForwarded({ data: { invoiceId: dbWdrwl.invoiceForward[0].invoice.id, withdrawal: wdrwl, invoice }, models, lnd, boss })
@ -294,7 +296,7 @@ export async function checkWithdrawal ({ data: { hash, withdrawal, invoice }, bo
const fee = Number(wdrwl.payment.fee_mtokens)
const paid = Number(wdrwl.payment.mtokens) - fee
const [{ confirm_withdrawl: code }] = await serialize([
const [[{ confirm_withdrawl: code }]] = await serialize([
models.$queryRaw`SELECT confirm_withdrawl(${dbWdrwl.id}::INTEGER, ${paid}, ${fee})`,
models.withdrawl.update({
where: { id: dbWdrwl.id },
@ -305,35 +307,34 @@ export async function checkWithdrawal ({ data: { hash, withdrawal, invoice }, bo
], { models })
if (code === 0) {
notifyWithdrawal(dbWdrwl.userId, wdrwl)
if (dbWdrwl.wallet) {
// this was an autowithdrawal
const message = `autowithdrawal of ${
numWithUnits(msatsToSats(paid), { abbreviate: false })} with ${
numWithUnits(msatsToSats(fee), { abbreviate: false })} as fee`
await addWalletLog({ wallet: dbWdrwl.wallet, level: 'SUCCESS', message }, { models })
}
const { request: bolt11, secret: preimage } = wdrwl.payment
logger?.ok(
`↙ payment received: ${formatSats(msatsToSats(Number(wdrwl.payment.mtokens)))}`,
{
bolt11,
preimage,
fee: formatMsats(fee)
})
}
} else if (wdrwl?.is_failed || notSent) {
if (dbWdrwl.invoiceForward.length > 0) {
return await paidActionFailedForward({ data: { invoiceId: dbWdrwl.invoiceForward[0].invoice.id, withdrawal: wdrwl, invoice }, models, lnd, boss })
}
const { status, message } = getPaymentFailureStatus(wdrwl)
const [{ reverse_withdrawl: code }] = await serialize(
const { message, status } = getPaymentFailureStatus(wdrwl)
await serialize(
models.$queryRaw`
SELECT reverse_withdrawl(${dbWdrwl.id}::INTEGER, ${status}::"WithdrawlStatus")`,
{ models }
)
if (code === 0 && dbWdrwl.wallet) {
// add error into log for autowithdrawal
await addWalletLog({
wallet: dbWdrwl.wallet,
level: 'ERROR',
message: 'autowithdrawal failed: ' + message
}, { models })
}
logger?.error(
`incoming payment failed: ${message}`,
{
bolt11: wdrwl.payment.request,
max_fee: formatMsats(dbWdrwl.msatsFeePaying)
})
}
}
@ -384,7 +385,8 @@ export async function finalizeHodlInvoice ({ data: { hash }, models, lnd, boss,
include: {
invoiceForward: {
include: {
withdrawl: true
withdrawl: true,
wallet: true
}
}
}
@ -402,7 +404,9 @@ export async function finalizeHodlInvoice ({ data: { hash }, models, lnd, boss,
}
// sync LND invoice status with invoice status in database
await checkInvoice({ data: { hash }, models, lnd, ...args })
await checkInvoice({ data: { hash }, models, lnd, boss })
return dbInv
}
export async function checkPendingDeposits (args) {