Fix autowithdrawal logs (#1073)

* Also log autowithdrawal routing errors

* Only log autowithdrawal success in worker

* Use WalletType for WalletLog.wallet

* Fix autowithdrawal success message

* Infer walletName from walletType in upsertWallet
This commit is contained in:
ekzyis 2024-04-16 20:59:46 +02:00 committed by GitHub
parent c19c9124ec
commit e30dfbae57
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 101 additions and 59 deletions

View File

@ -437,12 +437,11 @@ 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' const walletType = 'LND'
return await upsertWallet( return await upsertWallet(
{ {
schema: LNDAutowithdrawSchema, schema: LNDAutowithdrawSchema,
walletName: wallet, walletType,
walletType: 'LND',
testConnect: async ({ cert, macaroon, socket }) => { testConnect: async ({ cert, macaroon, socket }) => {
try { try {
const { lnd } = await authenticatedLndGrpc({ const { lnd } = await authenticatedLndGrpc({
@ -457,12 +456,12 @@ export default {
expires_at: new Date() expires_at: new Date()
}) })
// we wrap both calls in one try/catch since connection attempts happen on RPC calls // 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 }) await addWalletLog({ wallet: walletType, level: 'SUCCESS', message: 'connected to LND' }, { me, models })
return inv return inv
} catch (err) { } catch (err) {
// LND errors are in this shape: [code, type, { err: { code, details, metadata } }] // LND errors are in this shape: [code, type, { err: { code, details, metadata } }]
const details = err[2]?.err?.details || err.message || err.toString?.() const details = err[2]?.err?.details || err.message || err.toString?.()
await addWalletLog({ wallet, level: 'ERROR', message: `could not connect to LND: ${details}` }, { me, models }) await addWalletLog({ wallet: walletType, level: 'ERROR', message: `could not connect to LND: ${details}` }, { me, models })
throw err throw err
} }
} }
@ -472,12 +471,11 @@ export default {
upsertWalletCLN: async (parent, { settings, ...data }, { me, models }) => { upsertWalletCLN: async (parent, { settings, ...data }, { me, models }) => {
data.cert = ensureB64(data.cert) data.cert = ensureB64(data.cert)
const wallet = 'walletCLN' const walletType = 'CLN'
return await upsertWallet( return await upsertWallet(
{ {
schema: CLNAutowithdrawSchema, schema: CLNAutowithdrawSchema,
walletName: wallet, walletType,
walletType: 'CLN',
testConnect: async ({ socket, rune, cert }) => { testConnect: async ({ socket, rune, cert }) => {
try { try {
const inv = await createInvoiceCLN({ const inv = await createInvoiceCLN({
@ -488,11 +486,11 @@ export default {
msats: 'any', msats: 'any',
expiry: 0 expiry: 0
}) })
await addWalletLog({ wallet, level: 'SUCCESS', message: 'connected to CLN' }, { me, models }) await addWalletLog({ wallet: walletType, level: 'SUCCESS', message: 'connected to CLN' }, { me, models })
return inv return inv
} catch (err) { } catch (err) {
const details = err.details || err.message || err.toString?.() const details = err.details || err.message || err.toString?.()
await addWalletLog({ wallet, level: 'ERROR', message: `could not connect to CLN: ${details}` }, { me, models }) await addWalletLog({ wallet: walletType, level: 'ERROR', message: `could not connect to CLN: ${details}` }, { me, models })
throw err throw err
} }
} }
@ -500,15 +498,14 @@ export default {
{ 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' const walletType = 'LIGHTNING_ADDRESS'
return await upsertWallet( return await upsertWallet(
{ {
schema: lnAddrAutowithdrawSchema, schema: lnAddrAutowithdrawSchema,
walletName: wallet, walletType,
walletType: 'LIGHTNING_ADDRESS',
testConnect: async ({ address }) => { testConnect: async ({ address }) => {
const options = await lnAddrOptions(address) const options = await lnAddrOptions(address)
await addWalletLog({ wallet, level: 'SUCCESS', message: 'fetched payment details' }, { me, models }) await addWalletLog({ wallet: walletType, level: 'SUCCESS', message: 'fetched payment details' }, { me, models })
return options return options
} }
}, },
@ -524,19 +521,9 @@ export default {
throw new GraphQLError('wallet not found', { extensions: { code: 'BAD_INPUT' } }) 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 === 'CLN') {
walletName = 'walletCLN'
} else if (wallet.type === 'LIGHTNING_ADDRESS') {
walletName = 'walletLightningAddress'
}
await models.$transaction([ await models.$transaction([
models.wallet.delete({ where: { userId: me.id, id: Number(id) } }), models.wallet.delete({ where: { userId: me.id, id: Number(id) } }),
models.walletLog.create({ data: { userId: me.id, wallet: walletName, level: 'SUCCESS', message: 'wallet deleted' } }) models.walletLog.create({ data: { userId: me.id, wallet: wallet.type, level: 'SUCCESS', message: 'wallet deleted' } })
]) ])
return true return true
@ -580,7 +567,7 @@ export const addWalletLog = async ({ wallet, level, message }, { me, models }) =
} }
async function upsertWallet ( async function upsertWallet (
{ schema, walletName, walletType, testConnect }, { settings, data }, { me, models }) { { schema, walletType, testConnect }, { settings, data }, { me, models }) {
if (!me) { if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
} }
@ -593,7 +580,7 @@ async function upsertWallet (
await testConnect(data) await testConnect(data)
} catch (err) { } catch (err) {
console.error(err) console.error(err)
await addWalletLog({ wallet: walletName, level: 'ERROR', message: 'failed to attach wallet' }, { me, models }) await addWalletLog({ wallet: walletType, 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' } })
} }
} }
@ -623,6 +610,9 @@ async function upsertWallet (
})) }))
} }
const walletName = walletType === 'LND'
? 'walletLND'
: walletType === 'CLN' ? 'walletCLN' : 'walletLightningAddress'
if (id) { if (id) {
txs.push( txs.push(
models.wallet.update({ models.wallet.update({
@ -637,7 +627,7 @@ async function upsertWallet (
} }
} }
}), }),
models.walletLog.create({ data: { userId: me.id, wallet: walletName, level: 'SUCCESS', message: 'wallet updated' } }) models.walletLog.create({ data: { userId: me.id, wallet: walletType, level: 'SUCCESS', message: 'wallet updated' } })
) )
} else { } else {
txs.push( txs.push(
@ -651,7 +641,7 @@ async function upsertWallet (
} }
} }
}), }),
models.walletLog.create({ data: { userId: me.id, wallet: walletName, level: 'SUCCESS', message: 'wallet created' } }) models.walletLog.create({ data: { userId: me.id, wallet: walletType, level: 'SUCCESS', message: 'wallet created' } })
) )
} }
@ -659,7 +649,7 @@ async function upsertWallet (
return true return true
} }
export async function createWithdrawal (parent, { invoice, maxFee }, { me, models, lnd, headers, autoWithdraw = false }) { export async function createWithdrawal (parent, { invoice, maxFee }, { me, models, lnd, headers, walletId = null }) {
assertApiKeyNotPermitted({ me }) assertApiKeyNotPermitted({ me })
await ssValidate(withdrawlSchema, { invoice, maxFee }) await ssValidate(withdrawlSchema, { invoice, maxFee })
await assertGofacYourself({ models, headers }) await assertGofacYourself({ models, headers })
@ -698,10 +688,11 @@ export async function createWithdrawal (parent, { invoice, maxFee }, { me, model
const user = await models.user.findUnique({ where: { id: me.id } }) const user = await models.user.findUnique({ where: { id: me.id } })
const autoWithdraw = !!walletId
// create withdrawl transactionally (id, bolt11, amount, fee) // create withdrawl transactionally (id, bolt11, amount, fee)
const [withdrawl] = await serialize( const [withdrawl] = await serialize(
models.$queryRaw`SELECT * FROM create_withdrawl(${decoded.id}, ${invoice}, models.$queryRaw`SELECT * FROM create_withdrawl(${decoded.id}, ${invoice},
${Number(decoded.mtokens)}, ${msatsFee}, ${user.name}, ${autoWithdraw})`, ${Number(decoded.mtokens)}, ${msatsFee}, ${user.name}, ${autoWithdraw}, ${walletId}::INTEGER)`,
{ models } { models }
) )

View File

@ -158,9 +158,17 @@ const initIndexedDB = async (storeName) => {
} }
const renameWallet = (wallet) => { const renameWallet = (wallet) => {
if (wallet === 'walletLightningAddress') return 'lnAddr' switch (wallet) {
if (wallet === 'walletLND') return 'lnd' case 'walletLightningAddress':
if (wallet === 'walletCLN') return 'cln' case 'LIGHTNING_ADDRESS':
return 'lnAddr'
case 'walletLND':
case 'LND':
return 'lnd'
case 'walletCLN':
case 'CLN':
return 'cln'
}
return wallet return wallet
} }

View File

@ -0,0 +1,38 @@
-- AlterTable
ALTER TABLE "Withdrawl" ADD COLUMN "walletId" INTEGER;
-- AddForeignKey
ALTER TABLE "Withdrawl" ADD CONSTRAINT "Withdrawl_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "Wallet"("id") ON DELETE SET NULL ON UPDATE CASCADE;
CREATE OR REPLACE FUNCTION create_withdrawl(lnd_id TEXT, invoice TEXT, msats_amount BIGINT, msats_max_fee BIGINT, username TEXT, auto_withdraw BOOLEAN, wallet_id INTEGER)
RETURNS "Withdrawl"
LANGUAGE plpgsql
AS $$
DECLARE
user_id INTEGER;
user_msats BIGINT;
withdrawl "Withdrawl";
BEGIN
PERFORM ASSERT_SERIALIZED();
SELECT msats, id INTO user_msats, user_id FROM users WHERE name = username;
IF (msats_amount + msats_max_fee) > user_msats THEN
RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS';
END IF;
IF EXISTS (SELECT 1 FROM "Withdrawl" WHERE hash = lnd_id AND status IS NULL) THEN
RAISE EXCEPTION 'SN_PENDING_WITHDRAWL_EXISTS';
END IF;
IF EXISTS (SELECT 1 FROM "Withdrawl" WHERE hash = lnd_id AND status = 'CONFIRMED') THEN
RAISE EXCEPTION 'SN_CONFIRMED_WITHDRAWL_EXISTS';
END IF;
INSERT INTO "Withdrawl" (hash, bolt11, "msatsPaying", "msatsFeePaying", "userId", "autoWithdraw", "walletId", created_at, updated_at)
VALUES (lnd_id, invoice, msats_amount, msats_max_fee, user_id, auto_withdraw, wallet_id, now_utc(), now_utc()) RETURNING * INTO withdrawl;
UPDATE users SET msats = msats - msats_amount - msats_max_fee WHERE id = user_id;
RETURN withdrawl;
END;
$$;

View File

@ -156,6 +156,7 @@ model Wallet {
walletLightningAddress WalletLightningAddress? walletLightningAddress WalletLightningAddress?
walletLND WalletLND? walletLND WalletLND?
walletCLN WalletCLN? walletCLN WalletCLN?
withdrawals Withdrawl[]
@@index([userId]) @@index([userId])
} }
@ -694,7 +695,9 @@ model Withdrawl {
msatsFeePaid BigInt? msatsFeePaid BigInt?
status WithdrawlStatus? status WithdrawlStatus?
autoWithdraw Boolean @default(false) autoWithdraw Boolean @default(false)
walletId Int?
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
wallet Wallet? @relation(fields: [walletId], references: [id], onDelete: SetNull)
@@index([createdAt], map: "Withdrawl.created_at_index") @@index([createdAt], map: "Withdrawl.created_at_index")
@@index([userId], map: "Withdrawl.userId_index") @@index([userId], map: "Withdrawl.userId_index")

View File

@ -1,5 +1,5 @@
import { authenticatedLndGrpc, createInvoice } from 'ln-service' import { authenticatedLndGrpc, createInvoice } from 'ln-service'
import { msatsToSats, numWithUnits, satsToMsats } from '@/lib/format' import { msatsToSats, satsToMsats } from '@/lib/format'
import { datePivot } from '@/lib/time' import { datePivot } from '@/lib/time'
import { createWithdrawal, sendToLnAddr, addWalletLog } from '@/api/resolvers/wallet' import { createWithdrawal, sendToLnAddr, addWalletLog } from '@/api/resolvers/wallet'
import { createInvoice as createInvoiceCLN } from '@/lib/cln' import { createInvoice as createInvoiceCLN } from '@/lib/cln'
@ -46,34 +46,18 @@ 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 === 'CLN') { } else if (wallet.type === 'CLN') {
await autowithdrawCLN( await autowithdrawCLN(
{ amount, maxFee }, { amount, maxFee },
{ models, me: user, lnd }) { models, me: user, lnd })
await addWalletLog({
wallet: 'walletCLN',
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
@ -82,9 +66,7 @@ export async function autoWithdraw ({ data: { id }, models, lnd }) {
// LND errors are in this shape: [code, type, { err: { code, details, metadata } }] // LND errors are in this shape: [code, type, { err: { code, details, metadata } }]
const details = error[2]?.err?.details || error.message || error.toString?.() const details = error[2]?.err?.details || error.message || error.toString?.()
await addWalletLog({ await addWalletLog({
wallet: wallet.type === 'LND' wallet: wallet.type,
? 'walletLND'
: wallet.type === 'CLN' ? 'walletCLN' : 'walletLightningAddress',
level: 'ERROR', level: 'ERROR',
message: 'autowithdrawal failed: ' + details message: 'autowithdrawal failed: ' + details
}, { me: user, models }) }, { me: user, models })
@ -116,7 +98,7 @@ async function autowithdrawLNAddr (
} }
const { walletLightningAddress: { address } } = wallet const { walletLightningAddress: { address } } = wallet
return await sendToLnAddr(null, { addr: address, amount, maxFee }, { me, models, lnd, autoWithdraw: true }) return await sendToLnAddr(null, { addr: address, amount, maxFee }, { me, models, lnd, walletId: wallet.id })
} }
async function autowithdrawLND ({ amount, maxFee }, { me, models, lnd }) { async function autowithdrawLND ({ amount, maxFee }, { me, models, lnd }) {
@ -152,7 +134,7 @@ async function autowithdrawLND ({ amount, maxFee }, { me, models, lnd }) {
expires_at: datePivot(new Date(), { seconds: 360 }) expires_at: datePivot(new Date(), { seconds: 360 })
}) })
return await createWithdrawal(null, { invoice: invoice.request, maxFee }, { me, models, lnd, autoWithdraw: true }) return await createWithdrawal(null, { invoice: invoice.request, maxFee }, { me, models, lnd, walletId: wallet.id })
} }
async function autowithdrawCLN ({ amount, maxFee }, { me, models, lnd }) { async function autowithdrawCLN ({ amount, maxFee }, { me, models, lnd }) {
@ -185,5 +167,5 @@ async function autowithdrawCLN ({ amount, maxFee }, { me, models, lnd }) {
expiry: 360 expiry: 360
}) })
return await createWithdrawal(null, { invoice: inv.bolt11, maxFee }, { me, models, lnd, autoWithdraw: true }) return await createWithdrawal(null, { invoice: inv.bolt11, maxFee }, { me, models, lnd, walletId: wallet.id })
} }

View File

@ -7,6 +7,8 @@ import { notifyDeposit, notifyWithdrawal } from '@/lib/webPush'
import { INVOICE_RETENTION_DAYS } from '@/lib/constants' import { INVOICE_RETENTION_DAYS } from '@/lib/constants'
import { datePivot, sleep } from '@/lib/time.js' import { datePivot, sleep } from '@/lib/time.js'
import retry from 'async-retry' import retry from 'async-retry'
import { addWalletLog } from '@/api/resolvers/wallet'
import { msatsToSats, numWithUnits } from '@/lib/format'
export async function subscribeToWallet (args) { export async function subscribeToWallet (args) {
await subscribeToDeposits(args) await subscribeToDeposits(args)
@ -205,7 +207,7 @@ async function subscribeToWithdrawals (args) {
} }
async function checkWithdrawal ({ data: { hash }, boss, models, lnd }) { async function checkWithdrawal ({ data: { hash }, boss, models, lnd }) {
const dbWdrwl = await models.withdrawl.findFirst({ where: { hash, status: null } }) const dbWdrwl = await models.withdrawl.findFirst({ where: { hash, status: null }, include: { wallet: true } })
if (!dbWdrwl) { if (!dbWdrwl) {
// [WARNING] LND paid an invoice that wasn't created via the SN GraphQL API. // [WARNING] LND paid an invoice that wasn't created via the SN GraphQL API.
// >>> an adversary might be draining our funds right now <<< // >>> an adversary might be draining our funds right now <<<
@ -237,16 +239,25 @@ async function checkWithdrawal ({ data: { hash }, boss, models, lnd }) {
if (code === 0) { if (code === 0) {
notifyWithdrawal(dbWdrwl.userId, wdrwl) 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.type, level: 'SUCCESS', message }, { models, me: { id: dbWdrwl.userId } })
}
} else if (wdrwl?.is_failed || notFound) { } else if (wdrwl?.is_failed || notFound) {
let status = 'UNKNOWN_FAILURE' let status = 'UNKNOWN_FAILURE'; let message = 'unknown failure'
if (wdrwl?.failed.is_insufficient_balance) { if (wdrwl?.failed.is_insufficient_balance) {
status = 'INSUFFICIENT_BALANCE' status = 'INSUFFICIENT_BALANCE'
message = "you didn't have enough sats"
} else if (wdrwl?.failed.is_invalid_payment) { } else if (wdrwl?.failed.is_invalid_payment) {
status = 'INVALID_PAYMENT' status = 'INVALID_PAYMENT'
message = 'invalid payment'
} else if (wdrwl?.failed.is_pathfinding_timeout) { } else if (wdrwl?.failed.is_pathfinding_timeout) {
status = 'PATHFINDING_TIMEOUT' status = 'PATHFINDING_TIMEOUT'
message = 'no route found'
} else if (wdrwl?.failed.is_route_not_found) { } else if (wdrwl?.failed.is_route_not_found) {
status = 'ROUTE_NOT_FOUND' status = 'ROUTE_NOT_FOUND'
message = 'no route found'
} }
await serialize( await serialize(
@ -254,6 +265,15 @@ async function checkWithdrawal ({ data: { hash }, boss, models, lnd }) {
SELECT reverse_withdrawl(${dbWdrwl.id}::INTEGER, ${status}::"WithdrawlStatus")`, SELECT reverse_withdrawl(${dbWdrwl.id}::INTEGER, ${status}::"WithdrawlStatus")`,
{ models } { models }
) )
if (dbWdrwl.wallet) {
// add error into log for autowithdrawal
addWalletLog({
wallet: dbWdrwl.wallet.type,
level: 'ERROR',
message: 'autowithdrawal failed: ' + message
}, { models, me: { id: dbWdrwl.userId } })
}
} }
} }