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.cert = ensureB64(data.cert)
const wallet = 'walletLND'
const walletType = 'LND'
return await upsertWallet(
{
schema: LNDAutowithdrawSchema,
walletName: wallet,
walletType: 'LND',
walletType,
testConnect: async ({ cert, macaroon, socket }) => {
try {
const { lnd } = await authenticatedLndGrpc({
@ -457,12 +456,12 @@ export default {
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 })
await addWalletLog({ wallet: walletType, 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 })
await addWalletLog({ wallet: walletType, level: 'ERROR', message: `could not connect to LND: ${details}` }, { me, models })
throw err
}
}
@ -472,12 +471,11 @@ export default {
upsertWalletCLN: async (parent, { settings, ...data }, { me, models }) => {
data.cert = ensureB64(data.cert)
const wallet = 'walletCLN'
const walletType = 'CLN'
return await upsertWallet(
{
schema: CLNAutowithdrawSchema,
walletName: wallet,
walletType: 'CLN',
walletType,
testConnect: async ({ socket, rune, cert }) => {
try {
const inv = await createInvoiceCLN({
@ -488,11 +486,11 @@ export default {
msats: 'any',
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
} catch (err) {
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
}
}
@ -500,15 +498,14 @@ export default {
{ settings, data }, { me, models })
},
upsertWalletLNAddr: async (parent, { settings, ...data }, { me, models }) => {
const wallet = 'walletLightningAddress'
const walletType = 'LIGHTNING_ADDRESS'
return await upsertWallet(
{
schema: lnAddrAutowithdrawSchema,
walletName: wallet,
walletType: 'LIGHTNING_ADDRESS',
walletType,
testConnect: async ({ 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
}
},
@ -524,19 +521,9 @@ export default {
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([
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
@ -580,7 +567,7 @@ export const addWalletLog = async ({ wallet, level, message }, { me, models }) =
}
async function upsertWallet (
{ schema, walletName, walletType, testConnect }, { settings, data }, { me, models }) {
{ schema, walletType, testConnect }, { settings, data }, { me, models }) {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
}
@ -593,7 +580,7 @@ async function upsertWallet (
await testConnect(data)
} catch (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' } })
}
}
@ -623,6 +610,9 @@ async function upsertWallet (
}))
}
const walletName = walletType === 'LND'
? 'walletLND'
: walletType === 'CLN' ? 'walletCLN' : 'walletLightningAddress'
if (id) {
txs.push(
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 {
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
}
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 })
await ssValidate(withdrawlSchema, { invoice, maxFee })
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 autoWithdraw = !!walletId
// 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})`,
${Number(decoded.mtokens)}, ${msatsFee}, ${user.name}, ${autoWithdraw}, ${walletId}::INTEGER)`,
{ models }
)

View File

@ -158,9 +158,17 @@ const initIndexedDB = async (storeName) => {
}
const renameWallet = (wallet) => {
if (wallet === 'walletLightningAddress') return 'lnAddr'
if (wallet === 'walletLND') return 'lnd'
if (wallet === 'walletCLN') return 'cln'
switch (wallet) {
case 'walletLightningAddress':
case 'LIGHTNING_ADDRESS':
return 'lnAddr'
case 'walletLND':
case 'LND':
return 'lnd'
case 'walletCLN':
case 'CLN':
return 'cln'
}
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?
walletLND WalletLND?
walletCLN WalletCLN?
withdrawals Withdrawl[]
@@index([userId])
}
@ -694,7 +695,9 @@ model Withdrawl {
msatsFeePaid BigInt?
status WithdrawlStatus?
autoWithdraw Boolean @default(false)
walletId Int?
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([userId], map: "Withdrawl.userId_index")

View File

@ -1,5 +1,5 @@
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 { createWithdrawal, sendToLnAddr, addWalletLog } from '@/api/resolvers/wallet'
import { createInvoice as createInvoiceCLN } from '@/lib/cln'
@ -46,34 +46,18 @@ 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 === 'CLN') {
await autowithdrawCLN(
{ amount, maxFee },
{ models, me: user, lnd })
await addWalletLog({
wallet: 'walletCLN',
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
@ -82,9 +66,7 @@ export async function autoWithdraw ({ data: { id }, models, lnd }) {
// 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'
: wallet.type === 'CLN' ? 'walletCLN' : 'walletLightningAddress',
wallet: wallet.type,
level: 'ERROR',
message: 'autowithdrawal failed: ' + details
}, { me: user, models })
@ -116,7 +98,7 @@ async function autowithdrawLNAddr (
}
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 }) {
@ -152,7 +134,7 @@ async function autowithdrawLND ({ amount, maxFee }, { me, models, lnd }) {
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 }) {
@ -185,5 +167,5 @@ async function autowithdrawCLN ({ amount, maxFee }, { me, models, lnd }) {
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 { datePivot, sleep } from '@/lib/time.js'
import retry from 'async-retry'
import { addWalletLog } from '@/api/resolvers/wallet'
import { msatsToSats, numWithUnits } from '@/lib/format'
export async function subscribeToWallet (args) {
await subscribeToDeposits(args)
@ -205,7 +207,7 @@ async function subscribeToWithdrawals (args) {
}
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) {
// [WARNING] LND paid an invoice that wasn't created via the SN GraphQL API.
// >>> an adversary might be draining our funds right now <<<
@ -237,16 +239,25 @@ async function checkWithdrawal ({ data: { hash }, boss, models, lnd }) {
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.type, level: 'SUCCESS', message }, { models, me: { id: dbWdrwl.userId } })
}
} else if (wdrwl?.is_failed || notFound) {
let status = 'UNKNOWN_FAILURE'
let status = 'UNKNOWN_FAILURE'; let message = 'unknown failure'
if (wdrwl?.failed.is_insufficient_balance) {
status = 'INSUFFICIENT_BALANCE'
message = "you didn't have enough sats"
} else if (wdrwl?.failed.is_invalid_payment) {
status = 'INVALID_PAYMENT'
message = 'invalid payment'
} else if (wdrwl?.failed.is_pathfinding_timeout) {
status = 'PATHFINDING_TIMEOUT'
message = 'no route found'
} else if (wdrwl?.failed.is_route_not_found) {
status = 'ROUTE_NOT_FOUND'
message = 'no route found'
}
await serialize(
@ -254,6 +265,15 @@ async function checkWithdrawal ({ data: { hash }, boss, models, lnd }) {
SELECT reverse_withdrawl(${dbWdrwl.id}::INTEGER, ${status}::"WithdrawlStatus")`,
{ 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 } })
}
}
}