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:
		
							parent
							
								
									c19c9124ec
								
							
						
					
					
						commit
						e30dfbae57
					
				| @ -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 } | ||||
|   ) | ||||
| 
 | ||||
|  | ||||
| @ -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 | ||||
| } | ||||
| 
 | ||||
|  | ||||
| @ -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; | ||||
| $$; | ||||
| @ -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") | ||||
|  | ||||
| @ -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 }) | ||||
| } | ||||
|  | ||||
| @ -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 } }) | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user