From 38c87e288246959fa5de4877c19081b0df1dc7de Mon Sep 17 00:00:00 2001 From: boufni95 Date: Thu, 28 Mar 2024 00:03:51 +0100 Subject: [PATCH 1/3] move watchdog, write failed payments, fix sanity check --- src/services/main/index.ts | 82 -------- src/services/main/init.ts | 7 +- src/services/main/paymentManager.ts | 102 ++++++---- src/services/main/sanityChecker.ts | 263 +++++++++++++++++++++++++ src/services/main/settings.ts | 2 +- src/services/{lnd => main}/watchdog.ts | 22 ++- src/services/storage/paymentStorage.ts | 73 +++++-- 7 files changed, 406 insertions(+), 145 deletions(-) create mode 100644 src/services/main/sanityChecker.ts rename src/services/{lnd => main}/watchdog.ts (87%) diff --git a/src/services/main/index.ts b/src/services/main/index.ts index 9c7488df..3c8a3af6 100644 --- a/src/services/main/index.ts +++ b/src/services/main/index.ts @@ -1,4 +1,3 @@ -import crypto from 'crypto' import fetch from "node-fetch" import Storage, { LoadStorageSettingsFromEnv } from '../storage/index.js' import * as Types from '../../../proto/autogenerated/ts/types.js' @@ -219,85 +218,4 @@ export default class { log({ unsigned: event }) this.nostrSend(invoice.linkedApplication.app_id, { type: 'event', event }) } - - async VerifyEventsLog() { - const events = await this.storage.eventsLog.GetAllLogs() - const invoices = await this.lnd.GetAllPaidInvoices(1000) - const payments = await this.lnd.GetAllPayments(1000) - const incrementSources: Record = {} - const decrementSources: Record = {} - - const users: Record = {} - for (let i = 0; i < events.length; i++) { - const e = events[i] - if (e.type === 'balance_decrement') { - users[e.userId] = this.checkUserEntry(e, users[e.userId]) - if (LN_INVOICE_REGEX.test(e.data)) { - if (decrementSources[e.data]) { - throw new Error("payment decremented more that once " + e.data) - } - decrementSources[e.data] = true - const paymentEntry = await this.storage.paymentStorage.GetPaymentOwner(e.data) - if (!paymentEntry) { - throw new Error("payment entry not found for " + e.data) - } - if (paymentEntry.paid_at_unix === 0) { - throw new Error("payment was never paid " + e.data) - } - if (!paymentEntry.internal) { - const entry = payments.payments.find(i => i.paymentRequest === e.data) - if (!entry) { - throw new Error("payment not found in lnd " + e.data) - } - } - } - } else if (e.type === 'balance_increment') { - users[e.userId] = this.checkUserEntry(e, users[e.userId]) - if (LN_INVOICE_REGEX.test(e.data)) { - if (incrementSources[e.data]) { - throw new Error("invoice incremented more that once " + e.data) - } - incrementSources[e.data] = true - const invoiceEntry = await this.storage.paymentStorage.GetInvoiceOwner(e.data) - if (!invoiceEntry) { - throw new Error("invoice entry not found for " + e.data) - } - if (invoiceEntry.paid_at_unix === 0) { - throw new Error("invoice was never paid " + e.data) - } - if (!invoiceEntry.internal) { - const entry = invoices.invoices.find(i => i.paymentRequest === e.data) - if (!entry) { - throw new Error("invoice not found in lnd " + e.data) - } - } - - } - } else { - await this.storage.paymentStorage.VerifyDbEvent(e) - } - } - await Promise.all(Object.entries(users).map(async ([userId, u]) => { - const user = await this.storage.userStorage.GetUser(userId) - if (user.balance_sats !== u.updatedBalance) { - throw new Error("sanity check on balance failed, expected: " + u.updatedBalance + " found: " + user.balance_sats) - } - })) - } - - checkUserEntry(e: LoggedEvent, u: { ts: number, updatedBalance: number } | undefined) { - const newEntry = { ts: e.timestampMs, updatedBalance: e.balance + e.amount * (e.type === 'balance_decrement' ? -1 : 1) } - if (!u) { - return newEntry - } - if (e.timestampMs < u.ts) { - throw new Error("entry out of order " + e.timestampMs + " " + u.ts) - } - if (e.balance !== u.updatedBalance) { - throw new Error("inconsistent balance update got: " + e.balance + " expected " + u.updatedBalance) - } - return newEntry - } } - -const LN_INVOICE_REGEX = /^(lightning:)?(lnbc|lntb)[0-9a-zA-Z]+$/; \ No newline at end of file diff --git a/src/services/main/init.ts b/src/services/main/init.ts index 419a3f9f..71bcbe4f 100644 --- a/src/services/main/init.ts +++ b/src/services/main/init.ts @@ -2,6 +2,7 @@ import { PubLogger, getLogger } from "../helpers/logger.js" import Storage from "../storage/index.js" import { TypeOrmMigrationRunner } from "../storage/migrations/runner.js" import Main from "./index.js" +import SanityChecker from "./sanityChecker.js" import { MainSettings } from "./settings.js" export type AppData = { privateKey: string; @@ -18,10 +19,10 @@ export const initMainHandler = async (log: PubLogger, mainSettings: MainSettings const mainHandler = new Main(mainSettings, storageManager) await mainHandler.lnd.Warmup() if (!mainSettings.skipSanityCheck) { - await mainHandler.VerifyEventsLog() + const sanityChecker = new SanityChecker(storageManager, mainHandler.lnd) + await sanityChecker.VerifyEventsLog() } - const totalUsersBalance = await mainHandler.storage.paymentStorage.GetTotalUsersBalance() - await mainHandler.paymentManager.watchDog.SeedLndBalance(totalUsersBalance || 0) + await mainHandler.paymentManager.watchDog.Start() const appsData = await mainHandler.storage.applicationStorage.GetApplications() const existingWalletApp = await appsData.find(app => app.name === 'wallet' || app.name === 'wallet-test') if (!existingWalletApp) { diff --git a/src/services/main/paymentManager.ts b/src/services/main/paymentManager.ts index c8335368..6e29c4a9 100644 --- a/src/services/main/paymentManager.ts +++ b/src/services/main/paymentManager.ts @@ -14,7 +14,7 @@ import { SendCoinsResponse } from '../../../proto/lnd/lightning.js' import { Event, verifiedSymbol, verifySignature } from '../nostr/tools/event.js' import { AddressReceivingTransaction } from '../storage/entity/AddressReceivingTransaction.js' import { UserTransactionPayment } from '../storage/entity/UserTransactionPayment.js' -import { Watchdog } from '../lnd/watchdog.js' +import { Watchdog } from './watchdog.js' interface UserOperationInfo { serial_id: number paid_amount: number @@ -39,7 +39,6 @@ const defaultLnurlPayMetadata = `[["text/plain", "lnurl pay to Lightning.pub"]]` const confInOne = 1000 * 1000 const confInTwo = 100 * 1000 * 1000 export default class { - storage: Storage settings: MainSettings lnd: LightningHandler @@ -51,7 +50,7 @@ export default class { this.storage = storage this.settings = settings this.lnd = lnd - this.watchDog = new Watchdog(settings.watchDogSettings, lnd) + this.watchDog = new Watchdog(settings.watchDogSettings, lnd, storage) this.addressPaidCb = addressPaidCb this.invoicePaidCb = invoicePaidCb } @@ -143,14 +142,9 @@ export default class { } } - async WatchdogCheck() { - const total = await this.storage.paymentStorage.GetTotalUsersBalance() - await this.watchDog.PaymentRequested(total || 0) - } - async PayInvoice(userId: string, req: Types.PayInvoiceRequest, linkedApplication: Application): Promise { this.log("paying invoice", req.invoice, "for user", userId, "with amount", req.amount) - await this.WatchdogCheck() + await this.watchDog.PaymentRequested() const maybeBanned = await this.storage.userStorage.GetUser(userId) if (maybeBanned.locked) { throw new Error("user is banned, cannot send payment") @@ -167,51 +161,69 @@ export default class { const serviceFee = this.getServiceFee(Types.UserOperationType.OUTGOING_INVOICE, payAmount, isAppUserPayment) const totalAmountToDecrement = payAmount + serviceFee const internalInvoice = await this.storage.paymentStorage.GetInvoiceOwner(req.invoice) - let payment: PaidInvoice | null = null - if (!internalInvoice) { - if (this.settings.disableExternalPayments) { - throw new Error("something went wrong sending payment, please try again later") - } - this.log("paying external invoice", req.invoice) - const routingFeeLimit = this.lnd.GetFeeLimitAmount(payAmount) - await this.storage.userStorage.DecrementUserBalance(userId, totalAmountToDecrement + routingFeeLimit, req.invoice) - try { - payment = await this.lnd.PayInvoice(req.invoice, req.amount, routingFeeLimit) - if (routingFeeLimit - payment.feeSat > 0) { - await this.storage.userStorage.IncrementUserBalance(userId, routingFeeLimit - payment.feeSat, "routing_fee_refund") - } - } catch (err) { - await this.storage.userStorage.IncrementUserBalance(userId, totalAmountToDecrement + routingFeeLimit, "payment_refund") - throw err - } + let paymentInfo = { preimage: "", amtPaid: 0, networkFee: 0, serialId: 0 } + if (internalInvoice) { + paymentInfo = await this.PayInternalInvoice(userId, internalInvoice, { payAmount, serviceFee }, linkedApplication) } else { - this.log("paying internal invoice", req.invoice) - if (internalInvoice.paid_at_unix > 0) { - throw new Error("this invoice was already paid") - } - await this.storage.userStorage.DecrementUserBalance(userId, totalAmountToDecrement, req.invoice) - this.invoicePaidCb(req.invoice, payAmount, true) + paymentInfo = await this.PayExternalInvoice(userId, req.invoice, { payAmount, serviceFee, amountForLnd: req.amount }, linkedApplication) } if (isAppUserPayment && serviceFee > 0) { await this.storage.userStorage.IncrementUserBalance(linkedApplication.owner.user_id, serviceFee, "fees") } - const routingFees = payment ? payment.feeSat : 0 - const newPayment = await this.storage.paymentStorage.AddUserInvoicePayment(userId, req.invoice, payAmount, routingFees, serviceFee, !!internalInvoice, linkedApplication) const user = await this.storage.userStorage.GetUser(userId) this.storage.eventsLog.LogEvent({ type: 'invoice_payment', userId, appId: linkedApplication.app_id, appUserId: "", balance: user.balance_sats, data: req.invoice, amount: payAmount }) return { - preimage: payment ? payment.paymentPreimage : "", - amount_paid: payment ? Number(payment.valueSat) : payAmount, - operation_id: `${Types.UserOperationType.OUTGOING_INVOICE}-${newPayment.serial_id}`, - network_fee: routingFees, + preimage: paymentInfo.preimage, + amount_paid: paymentInfo.amtPaid, + operation_id: `${Types.UserOperationType.OUTGOING_INVOICE}-${paymentInfo.serialId}`, + network_fee: paymentInfo.networkFee, service_fee: serviceFee } } + async PayExternalInvoice(userId: string, invoice: string, amounts: { payAmount: number, serviceFee: number, amountForLnd: number }, linkedApplication: Application) { + if (this.settings.disableExternalPayments) { + throw new Error("something went wrong sending payment, please try again later") + } + const { amountForLnd, payAmount, serviceFee } = amounts + const totalAmountToDecrement = payAmount + serviceFee + this.log("paying external invoice", invoice) + const routingFeeLimit = this.lnd.GetFeeLimitAmount(payAmount) + await this.storage.userStorage.DecrementUserBalance(userId, totalAmountToDecrement + routingFeeLimit, invoice) + const pendingPayment = await this.storage.paymentStorage.AddPendingExternalPayment(userId, invoice, payAmount, linkedApplication) + try { + const payment = await this.lnd.PayInvoice(invoice, amountForLnd, routingFeeLimit) + if (routingFeeLimit - payment.feeSat > 0) { + await this.storage.userStorage.IncrementUserBalance(userId, routingFeeLimit - payment.feeSat, "routing_fee_refund:" + invoice) + } + await this.storage.paymentStorage.UpdateExternalPayment(pendingPayment.serial_id, payment.feeSat, serviceFee, true) + + return { preimage: payment.paymentPreimage, amtPaid: payment.valueSat, networkFee: payment.feeSat, serialId: pendingPayment.serial_id } + + } catch (err) { + await this.storage.userStorage.IncrementUserBalance(userId, totalAmountToDecrement + routingFeeLimit, "payment_refund:" + invoice) + await this.storage.paymentStorage.UpdateExternalPayment(pendingPayment.serial_id, 0, 0, false) + throw err + } + } + + async PayInternalInvoice(userId: string, internalInvoice: UserReceivingInvoice, amounts: { payAmount: number, serviceFee: number }, linkedApplication: Application) { + this.log("paying internal invoice", internalInvoice.invoice) + if (internalInvoice.paid_at_unix > 0) { + throw new Error("this invoice was already paid") + } + const { payAmount, serviceFee } = amounts + const totalAmountToDecrement = payAmount + serviceFee + await this.storage.userStorage.DecrementUserBalance(userId, totalAmountToDecrement, internalInvoice.invoice) + this.invoicePaidCb(internalInvoice.invoice, payAmount, true) + const newPayment = await this.storage.paymentStorage.AddInternalPayment(userId, internalInvoice.invoice, payAmount, serviceFee, linkedApplication) + return { preimage: "", amtPaid: payAmount, networkFee: 0, serialId: newPayment.serial_id } + } + async PayAddress(ctx: Types.UserContext, req: Types.PayAddressRequest): Promise { throw new Error("address payment currently disabled, use Lightning instead") - await this.WatchdogCheck() + await this.watchDog.PaymentRequested() this.log("paying address", req.address, "for user", ctx.user_id, "with amount", req.amoutSats) const maybeBanned = await this.storage.userStorage.GetUser(ctx.user_id) if (maybeBanned.locked) { @@ -230,18 +242,21 @@ export default class { const vBytes = Math.ceil(Number(estimate.feeSat / estimate.satPerVbyte)) chainFees = vBytes * req.satsPerVByte const total = req.amoutSats + chainFees + // WARNING, before re-enabling this, make sure to add the tx_hash to the DecrementUserBalance "reason"!! this.storage.userStorage.DecrementUserBalance(ctx.user_id, total + serviceFee, req.address) try { const payment = await this.lnd.PayAddress(req.address, req.amoutSats, req.satsPerVByte) txId = payment.txid } catch (err) { + // WARNING, before re-enabling this, make sure to add the tx_hash to the IncrementUserBalance "reason"!! await this.storage.userStorage.IncrementUserBalance(ctx.user_id, total + serviceFee, req.address) throw err } } else { this.log("paying internal address") - await this.storage.userStorage.DecrementUserBalance(ctx.user_id, req.amoutSats + serviceFee, req.address) txId = crypto.randomBytes(32).toString("hex") + const addressData = `${req.address}:${txId}` + await this.storage.userStorage.DecrementUserBalance(ctx.user_id, req.amoutSats + serviceFee, addressData) this.addressPaidCb({ hash: txId, index: 0 }, req.address, req.amoutSats, true) } @@ -520,9 +535,10 @@ export default class { const isAppUserPayment = fromUser.user_id !== linkedApplication.owner.user_id let fee = this.getServiceFee(Types.UserOperationType.OUTGOING_USER_TO_USER, amount, isAppUserPayment) const toIncrement = amount - fee - await this.storage.userStorage.DecrementUserBalance(fromUser.user_id, amount, toUserId, tx) - await this.storage.userStorage.IncrementUserBalance(toUser.user_id, toIncrement, fromUserId, tx) - await this.storage.paymentStorage.AddUserToUserPayment(fromUserId, toUserId, amount, fee, linkedApplication, tx) + const paymentEntry = await this.storage.paymentStorage.CreateUserToUserPayment(fromUserId, toUserId, amount, fee, linkedApplication, tx) + await this.storage.userStorage.DecrementUserBalance(fromUser.user_id, amount, `${toUserId}:${paymentEntry.serial_id}`, tx) + await this.storage.userStorage.IncrementUserBalance(toUser.user_id, toIncrement, `${fromUserId}:${paymentEntry.serial_id}`, tx) + await this.storage.paymentStorage.SaveUserToUserPayment(paymentEntry, tx) if (isAppUserPayment && fee > 0) { await this.storage.userStorage.IncrementUserBalance(linkedApplication.owner.user_id, fee, 'fees', tx) } diff --git a/src/services/main/sanityChecker.ts b/src/services/main/sanityChecker.ts new file mode 100644 index 00000000..72f3087d --- /dev/null +++ b/src/services/main/sanityChecker.ts @@ -0,0 +1,263 @@ +import Storage from '../storage/index.js' +import { LightningHandler } from "../lnd/index.js" +import { LoggedEvent } from '../storage/eventsLog.js' +import { Invoice, Payment } from '../../../proto/lnd/lightning'; +const LN_INVOICE_REGEX = /^(lightning:)?(lnbc|lntb)[0-9a-zA-Z]+$/; +const BITCOIN_ADDRESS_REGEX = /^(bitcoin:)?([13][a-km-zA-HJ-NP-Z1-9]{25,34}|bc1[a-zA-HJ-NP-Z0-9]{39,59})$/; +type UniqueDecrementReasons = 'ban' +type UniqueIncrementReasons = 'fees' | 'routing_fee_refund' | 'payment_refund' +type CommonReasons = 'invoice' | 'address' | 'u2u' +type Reason = UniqueDecrementReasons | UniqueIncrementReasons | CommonReasons +export default class SanityChecker { + storage: Storage + lnd: LightningHandler + + events: LoggedEvent[] = [] + invoices: Invoice[] = [] + payments: Payment[] = [] + incrementSources: Record = {} + decrementSources: Record = {} + decrementEvents: Record = {} + users: Record = {} + constructor(storage: Storage, lnd: LightningHandler) { + this.storage = storage + this.lnd = lnd + } + + parseDataField(data: string): { type: Reason, data: string, txHash?: string, serialId?: number } { + const parts = data.split(":") + if (parts.length === 1) { + const [fullData] = parts + if (fullData === 'fees' || fullData === 'ban') { + return { type: fullData, data: fullData } + } else if (LN_INVOICE_REGEX.test(fullData)) { + return { type: 'invoice', data: fullData } + } else if (BITCOIN_ADDRESS_REGEX.test(fullData)) { + return { type: 'address', data: fullData } + } else { + return { type: 'u2u', data: fullData } + } + } else if (parts.length === 2) { + const [prefix, data] = parts + if (prefix === 'routing_fee_refund' || prefix === 'payment_refund') { + return { type: prefix, data } + } else if (BITCOIN_ADDRESS_REGEX.test(prefix)) { + return { type: 'address', data: prefix, txHash: data } + } else { + return { type: 'u2u', data: prefix, serialId: +data } + } + } + throw new Error("unknown data format") + } + + async verifyDecrementEvent(e: LoggedEvent) { + if (this.decrementSources[e.data]) { + throw new Error("entry decremented more that once " + e.data) + } + this.decrementSources[e.data] = true + this.users[e.userId] = this.checkUserEntry(e, this.users[e.userId]) + const parsed = this.parseDataField(e.data) + switch (parsed.type) { + case 'ban': + return + case 'address': + return this.validateUserTransactionPayment({ address: parsed.data, txHash: parsed.txHash, userId: e.userId }) + case 'invoice': + return this.validateUserInvoicePayment({ invoice: parsed.data, userId: e.userId, amt: e.amount }) + case 'u2u': + return this.validateUser2UserPayment({ fromUser: e.userId, toUser: parsed.data, serialId: parsed.serialId }) + default: + throw new Error("unknown decrement type " + parsed.type) + } + } + + async validateUserTransactionPayment({ address, txHash, userId }: { userId: string, address: string, txHash?: string }) { + if (!txHash) { + throw new Error("no tx hash provided to payment for address " + address) + } + const entry = await this.storage.paymentStorage.GetUserTransactionPaymentOwner(address, txHash) + if (!entry) { + throw new Error("no payment found for tx hash " + txHash) + } + if (entry.user.user_id !== userId) { + throw new Error("payment user id mismatch for tx hash " + txHash) + } + if (entry.paid_at_unix <= 0) { + throw new Error("payment not paid for tx hash " + txHash) + } + } + + async validateUserInvoicePayment({ invoice, userId, amt }: { userId: string, invoice: string, amt: number }) { + const entry = await this.storage.paymentStorage.GetPaymentOwner(invoice) + if (!entry) { + throw new Error("no payment found for invoice " + invoice) + } + if (entry.user.user_id !== userId) { + throw new Error("payment user id mismatch for invoice " + invoice) + } + if (entry.paid_at_unix === 0) { + throw new Error("payment never settled for invoice " + invoice) // TODO: check if this is correct + } + if (entry.paid_at_unix === -1) { + const refund = amt - (entry.paid_amount + entry.routing_fees + entry.service_fees) + this.decrementEvents[invoice] = { userId, refund, falure: true } + } else { + this.decrementEvents[invoice] = { userId, refund: amt, falure: false } + } + if (!entry.internal) { + const lndEntry = this.payments.find(i => i.paymentRequest === invoice) + if (!lndEntry) { + throw new Error("payment not found in lnd for invoice " + invoice) + } + } + } + + async validateUser2UserPayment({ fromUser, toUser, serialId }: { fromUser: string, toUser: string, serialId?: number }) { + if (!serialId) { + throw new Error("no serial id provided to u2u payment") + } + const entry = await this.storage.paymentStorage.GetUser2UserPayment(serialId) + if (!entry) { + throw new Error("no payment u2u found for serial id " + serialId) + } + if (entry.from_user.user_id !== fromUser || entry.to_user.user_id !== toUser) { + throw new Error("u2u payment user id mismatch for serial id " + serialId) + } + if (entry.paid_at_unix <= 0) { + throw new Error("payment not paid for serial id " + serialId) + } + } + + async verifyIncrementEvent(e: LoggedEvent) { + if (this.incrementSources[e.data]) { + throw new Error("entry incremented more that once " + e.data) + } + this.incrementSources[e.data] = true + this.users[e.userId] = this.checkUserEntry(e, this.users[e.userId]) + const parsed = this.parseDataField(e.data) + switch (parsed.type) { + case 'fees': + return + case 'address': + return this.validateAddressReceivingTransaction({ address: parsed.data, txHash: parsed.txHash, userId: e.userId }) + case 'invoice': + return this.validateReceivingInvoice({ invoice: parsed.data, userId: e.userId }) + case 'u2u': + return this.validateUser2UserPayment({ fromUser: parsed.data, toUser: e.userId, serialId: parsed.serialId }) + case 'routing_fee_refund': + return this.validateRoutingFeeRefund({ amt: e.amount, invoice: parsed.data, userId: e.userId }) + case 'payment_refund': + return this.validatePaymentRefund({ amt: e.amount, invoice: parsed.data, userId: e.userId }) + default: + throw new Error("unknown increment type " + parsed.type) + } + } + + async validateAddressReceivingTransaction({ userId, address, txHash }: { userId: string, address: string, txHash?: string }) { + if (!txHash) { + throw new Error("no tx hash provided to address " + address) + } + const entry = await this.storage.paymentStorage.GetAddressReceivingTransactionOwner(address, txHash) + if (!entry) { + throw new Error("no tx found for tx hash " + txHash) + } + if (entry.user_address.user.user_id !== userId) { + throw new Error("tx user id mismatch for tx hash " + txHash) + } + if (entry.paid_at_unix <= 0) { + throw new Error("tx not paid for tx hash " + txHash) + } + } + + async validateReceivingInvoice({ userId, invoice }: { userId: string, invoice: string }) { + const entry = await this.storage.paymentStorage.GetInvoiceOwner(invoice) + if (!entry) { + throw new Error("no invoice found for invoice " + invoice) + } + if (entry.user.user_id !== userId) { + throw new Error("invoice user id mismatch for invoice " + invoice) + } + if (entry.paid_at_unix <= 0) { + throw new Error("invoice not paid for invoice " + invoice) + } + if (!entry.internal) { + const entry = this.invoices.find(i => i.paymentRequest === invoice) + if (!entry) { + throw new Error("invoice not found in lnd " + invoice) + } + } + } + + async validateRoutingFeeRefund({ amt, invoice, userId }: { userId: string, invoice: string, amt: number }) { + const entry = this.decrementEvents[invoice] + if (!entry) { + throw new Error("no decrement event found for invoice routing fee refound " + invoice) + } + if (entry.userId !== userId) { + throw new Error("user id mismatch for routing fee refund " + invoice) + } + if (entry.falure) { + throw new Error("payment failled, should not refund routing fees " + invoice) + } + if (entry.refund !== amt) { + throw new Error("refund amount mismatch for routing fee refund " + invoice) + } + } + + async validatePaymentRefund({ amt, invoice, userId }: { userId: string, invoice: string, amt: number }) { + const entry = this.decrementEvents[invoice] + if (!entry) { + throw new Error("no decrement event found for invoice payment refund " + invoice) + } + if (entry.userId !== userId) { + throw new Error("user id mismatch for payment refund " + invoice) + } + if (!entry.falure) { + throw new Error("payment did not fail, should not refund payment " + invoice) + } + if (entry.refund !== amt) { + throw new Error("refund amount mismatch for payment refund " + invoice) + } + } + + async VerifyEventsLog() { + this.events = await this.storage.eventsLog.GetAllLogs() + this.invoices = (await this.lnd.GetAllPaidInvoices(1000)).invoices + this.payments = (await this.lnd.GetAllPayments(1000)).payments + this.incrementSources = {} + this.decrementSources = {} + this.users = {} + this.users = {} + this.decrementEvents = {} + for (let i = 0; i < this.events.length; i++) { + const e = this.events[i] + if (e.type === 'balance_decrement') { + await this.verifyDecrementEvent(e) + } else if (e.type === 'balance_increment') { + await this.verifyIncrementEvent(e) + } else { + await this.storage.paymentStorage.VerifyDbEvent(e) + } + } + await Promise.all(Object.entries(this.users).map(async ([userId, u]) => { + const user = await this.storage.userStorage.GetUser(userId) + if (user.balance_sats !== u.updatedBalance) { + throw new Error("sanity check on balance failed, expected: " + u.updatedBalance + " found: " + user.balance_sats) + } + })) + } + + checkUserEntry(e: LoggedEvent, u: { ts: number, updatedBalance: number } | undefined) { + const newEntry = { ts: e.timestampMs, updatedBalance: e.balance + e.amount * (e.type === 'balance_decrement' ? -1 : 1) } + if (!u) { + return newEntry + } + if (e.timestampMs < u.ts) { + throw new Error("entry out of order " + e.timestampMs + " " + u.ts) + } + if (e.balance !== u.updatedBalance) { + throw new Error("inconsistent balance update got: " + e.balance + " expected " + u.updatedBalance) + } + return newEntry + } +} \ No newline at end of file diff --git a/src/services/main/settings.ts b/src/services/main/settings.ts index 0fddaf87..e2bcda27 100644 --- a/src/services/main/settings.ts +++ b/src/services/main/settings.ts @@ -1,6 +1,6 @@ import { LoadStorageSettingsFromEnv, StorageSettings } from '../storage/index.js' import { LndSettings } from '../lnd/settings.js' -import { LoadWatchdogSettingsFromEnv, WatchdogSettings } from '../lnd/watchdog.js' +import { LoadWatchdogSettingsFromEnv, WatchdogSettings } from './watchdog.js' import { LoadLndSettingsFromEnv } from '../lnd/index.js' import { EnvMustBeInteger, EnvMustBeNonEmptyString } from '../helpers/envParser.js' export type MainSettings = { diff --git a/src/services/lnd/watchdog.ts b/src/services/main/watchdog.ts similarity index 87% rename from src/services/lnd/watchdog.ts rename to src/services/main/watchdog.ts index da52eaa4..f5108f6c 100644 --- a/src/services/lnd/watchdog.ts +++ b/src/services/main/watchdog.ts @@ -1,6 +1,7 @@ import { EnvCanBeInteger } from "../helpers/envParser.js"; import { getLogger } from "../helpers/logger.js"; -import { LightningHandler } from "./index.js"; +import { LightningHandler } from "../lnd/index.js"; +import Storage from '../storage/index.js' export type WatchdogSettings = { maxDiffSats: number } @@ -14,17 +15,28 @@ export class Watchdog { initialUsersBalance: number; lnd: LightningHandler; settings: WatchdogSettings; + storage: Storage; + latestCheckStart = 0 log = getLogger({ appName: "watchdog" }) enabled = false - constructor(settings: WatchdogSettings, lnd: LightningHandler) { + constructor(settings: WatchdogSettings, lnd: LightningHandler, storage: Storage) { this.lnd = lnd; this.settings = settings; + this.storage = storage; } - SeedLndBalance = async (totalUsersBalance: number) => { + Start = async () => { + const totalUsersBalance = await this.storage.paymentStorage.GetTotalUsersBalance() this.initialLndBalance = await this.getTotalLndBalance() this.initialUsersBalance = totalUsersBalance this.enabled = true + + setInterval(() => { + if (this.latestCheckStart + (1000 * 60) < Date.now()) { + this.log("No balance check was made in the last minute, checking now") + this.PaymentRequested() + } + }, 1000 * 60) } getTotalLndBalance = async () => { @@ -77,12 +89,14 @@ export class Watchdog { return false } - PaymentRequested = async (totalUsersBalance: number) => { + PaymentRequested = async () => { this.log("Payment requested, checking balance") if (!this.enabled) { this.log("WARNING! Watchdog not enabled, skipping balance check") return } + this.latestCheckStart = Date.now() + const totalUsersBalance = await this.storage.paymentStorage.GetTotalUsersBalance() const totalLndBalance = await this.getTotalLndBalance() const deltaLnd = totalLndBalance - this.initialLndBalance const deltaUsers = totalUsersBalance - this.initialUsersBalance diff --git a/src/services/storage/paymentStorage.ts b/src/services/storage/paymentStorage.ts index 03281232..7a0c249e 100644 --- a/src/services/storage/paymentStorage.ts +++ b/src/services/storage/paymentStorage.ts @@ -114,6 +114,23 @@ export default class { }) } + async GetAddressReceivingTransactionOwner(address: string, txHash: string, entityManager = this.DB): Promise { + return entityManager.getRepository(AddressReceivingTransaction).findOne({ + where: { + user_address: { address }, + tx_hash: txHash + } + }) + } + async GetUserTransactionPaymentOwner(address: string, txHash: string, entityManager = this.DB): Promise { + return entityManager.getRepository(UserTransactionPayment).findOne({ + where: { + address, + tx_hash: txHash + } + }) + } + async GetInvoiceOwner(paymentRequest: string, entityManager = this.DB): Promise { return entityManager.getRepository(UserReceivingInvoice).findOne({ where: { @@ -128,19 +145,48 @@ export default class { } }) } + async GetUser2UserPayment(serialId: number, entityManager = this.DB): Promise { + return entityManager.getRepository(UserToUserPayment).findOne({ + where: { + serial_id: serialId + } + }) + } - async AddUserInvoicePayment(userId: string, invoice: string, amount: number, routingFees: number, serviceFees: number, internal: boolean, linkedApplication: Application): Promise { + async AddPendingExternalPayment(userId: string, invoice: string, amount: number, linkedApplication: Application): Promise { const newPayment = this.DB.getRepository(UserInvoicePayment).create({ user: await this.userStorage.GetUser(userId), paid_amount: amount, invoice, - routing_fees: routingFees, - service_fees: serviceFees, - paid_at_unix: Math.floor(Date.now() / 1000), - internal, + routing_fees: 0, + service_fees: 0, + paid_at_unix: 0, + internal: false, linkedApplication }) - return this.txQueue.PushToQueue({ exec: async db => db.getRepository(UserInvoicePayment).save(newPayment), dbTx: false, description: `add invoice payment for ${userId} linked to ${linkedApplication.app_id}: ${invoice}, amt: ${amount} ` }) + return this.txQueue.PushToQueue({ exec: async db => db.getRepository(UserInvoicePayment).save(newPayment), dbTx: false, description: `add pending invoice payment for ${userId} linked to ${linkedApplication.app_id}: ${invoice}, amt: ${amount} ` }) + } + + async UpdateExternalPayment(invoicePaymentSerialId: number, routingFees: number, serviceFees: number, success: boolean) { + return this.DB.getRepository(UserInvoicePayment).update(invoicePaymentSerialId, { + routing_fees: routingFees, + service_fees: serviceFees, + paid_at_unix: success ? Math.floor(Date.now() / 1000) : -1 + }) + } + + async AddInternalPayment(userId: string, invoice: string, amount: number, serviceFees: number, linkedApplication: Application): Promise { + const newPayment = this.DB.getRepository(UserInvoicePayment).create({ + user: await this.userStorage.GetUser(userId), + paid_amount: amount, + invoice, + routing_fees: 0, + service_fees: serviceFees, + paid_at_unix: Math.floor(Date.now() / 1000), + internal: true, + linkedApplication + }) + return this.txQueue.PushToQueue({ exec: async db => db.getRepository(UserInvoicePayment).save(newPayment), dbTx: false, description: `add internal invoice payment for ${userId} linked to ${linkedApplication.app_id}: ${invoice}, amt: ${amount} ` }) } GetUserInvoicePayments(userId: string, fromIndex: number, take = 50, entityManager = this.DB): Promise { @@ -237,16 +283,18 @@ export default class { return found } - async AddUserToUserPayment(fromUserId: string, toUserId: string, amount: number, fee: number, linkedApplication: Application, dbTx: DataSource | EntityManager) { - const newKey = dbTx.getRepository(UserToUserPayment).create({ - from_user: await this.userStorage.GetUser(fromUserId), - to_user: await this.userStorage.GetUser(toUserId), + async CreateUserToUserPayment(fromUserId: string, toUserId: string, amount: number, fee: number, linkedApplication: Application, dbTx: DataSource | EntityManager) { + return dbTx.getRepository(UserToUserPayment).create({ + from_user: await this.userStorage.GetUser(fromUserId, dbTx), + to_user: await this.userStorage.GetUser(toUserId, dbTx), paid_at_unix: Math.floor(Date.now() / 1000), paid_amount: amount, service_fees: fee, linkedApplication }) - return dbTx.getRepository(UserToUserPayment).save(newKey) + } + async SaveUserToUserPayment(payment: UserToUserPayment, dbTx: DataSource | EntityManager) { + return dbTx.getRepository(UserToUserPayment).save(payment) } GetUserToUserReceivedPayments(userId: string, fromIndex: number, take = 50, entityManager = this.DB) { @@ -362,6 +410,7 @@ export default class { } async GetTotalUsersBalance(entityManager = this.DB) { - return entityManager.getRepository(User).sum("balance_sats") + const total = await entityManager.getRepository(User).sum("balance_sats") + return total || 0 } } \ No newline at end of file From e97e78e90b3fe92326341f49ee1626aea7a9f316 Mon Sep 17 00:00:00 2001 From: boufni95 Date: Thu, 28 Mar 2024 00:05:14 +0100 Subject: [PATCH 2/3] sanity check v2 --- src/services/storage/eventsLog.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/storage/eventsLog.ts b/src/services/storage/eventsLog.ts index a5cad37f..ec226219 100644 --- a/src/services/storage/eventsLog.ts +++ b/src/services/storage/eventsLog.ts @@ -1,7 +1,7 @@ import fs from 'fs' import { parse, stringify } from 'csv' import { getLogger } from '../helpers/logger.js' -const eventLogPath = "logs/eventLog.csv" +const eventLogPath = "logs/eventLogV2.csv" type LoggedEventType = 'new_invoice' | 'new_address' | 'address_paid' | 'invoice_paid' | 'invoice_payment' | 'address_payment' | 'u2u_receiver' | 'u2u_sender' | 'balance_increment' | 'balance_decrement' export type LoggedEvent = { timestampMs: number From 7c1c79b426b5b6f3e514f6f8e1be24d9a8ce99b3 Mon Sep 17 00:00:00 2001 From: hatim boufnichel Date: Sat, 30 Mar 2024 01:13:34 +0100 Subject: [PATCH 3/3] fix and add tests --- package.json | 4 +- src/services/helpers/logger.ts | 8 +- src/services/lnd/index.ts | 2 +- src/services/lnd/lnd.spec.ts | 32 -------- src/services/lnd/settings.ts | 4 + src/services/main/main.spec.ts | 47 ----------- src/services/main/paymentManager.ts | 4 +- src/services/main/sanityChecker.ts | 21 +++-- src/services/main/settings.ts | 12 ++- src/services/main/watchdog.ts | 10 ++- src/services/storage/eventsLog.ts | 8 +- src/services/storage/index.ts | 5 +- src/services/storage/transactionsQueue.ts | 26 +++--- src/services/storage/userStorage.ts | 39 +++++++-- src/test.ts | 5 -- src/tests/externalPayment.spec.ts | 29 +++++++ src/tests/internalPayment.spec.ts | 39 +++++++++ src/tests/spamExternalPayments.spec.ts | 46 +++++++++++ src/tests/spamMixedPayments.spec.ts | 54 +++++++++++++ src/tests/testBase.ts | 97 +++++++++++++++++++++++ src/{ => tests}/testRunner.ts | 29 +++---- 21 files changed, 379 insertions(+), 142 deletions(-) delete mode 100644 src/services/lnd/lnd.spec.ts delete mode 100644 src/services/main/main.spec.ts delete mode 100644 src/test.ts create mode 100644 src/tests/externalPayment.spec.ts create mode 100644 src/tests/internalPayment.spec.ts create mode 100644 src/tests/spamExternalPayments.spec.ts create mode 100644 src/tests/spamMixedPayments.spec.ts create mode 100644 src/tests/testBase.ts rename src/{ => tests}/testRunner.ts (62%) diff --git a/package.json b/package.json index 76b0e803..789638bd 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "", "main": "index.js", "scripts": { - "test": " tsc && node build/src/testRunner.js", + "test": " tsc && node build/src/tests/testRunner.js", "start": "tsc && node build/src/index.js", "start:ci": "git reset --hard && git pull && npm run start", "build_autogenerated": "cd proto && rimraf autogenerated && protoc -I ./service --pub_out=. service/*", @@ -77,4 +77,4 @@ "ts-node": "10.7.0", "typescript": "4.5.2" } -} +} \ No newline at end of file diff --git a/src/services/helpers/logger.ts b/src/services/helpers/logger.ts index 7e45c11d..9b676a11 100644 --- a/src/services/helpers/logger.ts +++ b/src/services/helpers/logger.ts @@ -7,7 +7,6 @@ try { } catch { } const z = (n: number) => n < 10 ? `0${n}` : `${n}` const openWriter = (fileName: string): Writer => { - const logStream = fs.createWriteStream(`logs/${fileName}`, { flags: 'a' }); return (message) => { logStream.write(message + "\n") @@ -37,6 +36,9 @@ export const getLogger = (params: LoggerParams): PubLogger => { const timestamp = `${now.getFullYear()}-${z(now.getMonth() + 1)}-${z(now.getDate())} ${z(now.getHours())}:${z(now.getMinutes())}:${z(now.getSeconds())}` const toLog = [timestamp] if (params.appName) { + if (disabledApps.includes(params.appName)) { + return + } toLog.push(params.appName) } if (params.userId) { @@ -48,3 +50,7 @@ export const getLogger = (params: LoggerParams): PubLogger => { writers.forEach(w => w(final)) } } +const disabledApps: string[] = [] +export const disableLoggers = (appNamesToDisable: string[]) => { + disabledApps.push(...appNamesToDisable) +} \ No newline at end of file diff --git a/src/services/lnd/index.ts b/src/services/lnd/index.ts index 87a0b3f0..635a235d 100644 --- a/src/services/lnd/index.ts +++ b/src/services/lnd/index.ts @@ -12,7 +12,7 @@ export const LoadLndSettingsFromEnv = (): LndSettings => { const feeRateLimit = EnvMustBeInteger("OUTBOUND_MAX_FEE_BPS") / 10000 const feeFixedLimit = EnvMustBeInteger("OUTBOUND_MAX_FEE_EXTRA_SATS") const mockLnd = EnvCanBeBoolean("MOCK_LND") - return { lndAddr, lndCertPath, lndMacaroonPath, feeRateLimit, feeFixedLimit, mockLnd } + return { lndAddr, lndCertPath, lndMacaroonPath, feeRateLimit, feeFixedLimit, mockLnd, otherLndAddr: "", otherLndCertPath: "", otherLndMacaroonPath: "" } } export interface LightningHandler { Stop(): void diff --git a/src/services/lnd/lnd.spec.ts b/src/services/lnd/lnd.spec.ts deleted file mode 100644 index 7ec63697..00000000 --- a/src/services/lnd/lnd.spec.ts +++ /dev/null @@ -1,32 +0,0 @@ -import 'dotenv/config' // TODO - test env -import { expect } from 'chai' -import * as Types from '../../../proto/autogenerated/ts/types.js'; -import NewLightningHandler, { LightningHandler, LoadLndSettingsFromEnv } from '../lnd/index.js' -let lnd: LightningHandler -export const ignore = true -export const setup = async () => { - lnd = NewLightningHandler(LoadLndSettingsFromEnv(), console.log, console.log, console.log, console.log) - await lnd.Warmup() -} -export const teardown = () => { - lnd.Stop() -} - -export default async (d: (message: string, failure?: boolean) => void) => { - const info = await lnd.GetInfo() - expect(info.alias).to.equal("alice") - d("get alias ok") - - const addr = await lnd.NewAddress(Types.AddressType.WITNESS_PUBKEY_HASH) - console.log(addr) - d("new address ok") - - const invoice = await lnd.NewInvoice(1000, "", 60 * 60) - console.log(invoice) - d("new invoice ok") - - const res = await lnd.EstimateChainFees("bcrt1qajzzx453x9fx5gtlyax8zrsennckrw3syd2llt", 1000, 100) - console.log(res) - d("estimate fee ok") - //const res = await this.lnd.OpenChannel("025ed7fc85fc05a07fc5acc13a6e3836cd11c5587c1d400afcd22630a9e230eb7a", "", 20000, 0) -} \ No newline at end of file diff --git a/src/services/lnd/settings.ts b/src/services/lnd/settings.ts index 43f0dba3..7965abdc 100644 --- a/src/services/lnd/settings.ts +++ b/src/services/lnd/settings.ts @@ -7,6 +7,10 @@ export type LndSettings = { feeRateLimit: number feeFixedLimit: number mockLnd: boolean + + otherLndAddr: string + otherLndCertPath: string + otherLndMacaroonPath: string } type TxOutput = { hash: string diff --git a/src/services/main/main.spec.ts b/src/services/main/main.spec.ts deleted file mode 100644 index e7b06616..00000000 --- a/src/services/main/main.spec.ts +++ /dev/null @@ -1,47 +0,0 @@ -import 'dotenv/config' // TODO - test env -import chai from 'chai' -import { AppData, initMainHandler } from './init.js' -import Main from './index.js' -import { User } from '../storage/entity/User.js' -import { LoadMainSettingsFromEnv, LoadTestSettingsFromEnv, MainSettings } from './settings.js' -import chaiString from 'chai-string' -import { defaultInvoiceExpiry } from '../storage/paymentStorage.js' -chai.use(chaiString) -const expect = chai.expect -export const ignore = false -let main: Main -let app: AppData -let user1: { userId: string, appUserIdentifier: string, appId: string } -let user2: { userId: string, appUserIdentifier: string, appId: string } -export const setup = async () => { - const settings = LoadTestSettingsFromEnv() - const initialized = await initMainHandler(console.log, settings) - if (!initialized) { - throw new Error("failed to initialize main handler") - } - main = initialized.mainHandler - app = initialized.apps[0] - const u1 = await main.applicationManager.AddAppUser(app.appId, { identifier: "user1", balance: 0, fail_if_exists: true }) - const u2 = await main.applicationManager.AddAppUser(app.appId, { identifier: "user2", balance: 2000, fail_if_exists: true }) - user1 = { userId: u1.info.userId, appUserIdentifier: u1.identifier, appId: app.appId } - user2 = { userId: u2.info.userId, appUserIdentifier: u2.identifier, appId: app.appId } -} -export const teardown = () => { - console.log("teardown") -} - -export default async (d: (message: string, failure?: boolean) => void) => { - const application = await main.storage.applicationStorage.GetApplication(app.appId) - const invoice = await main.paymentManager.NewInvoice(user1.userId, { amountSats: 1000, memo: "test" }, { linkedApplication: application, expiry: defaultInvoiceExpiry }) - expect(invoice.invoice).to.startWith("lnbcrtmockin") - d("got the invoice") - - const pay = await main.paymentManager.PayInvoice(user2.userId, { invoice: invoice.invoice, amount: 0 }, application) - expect(pay.amount_paid).to.be.equal(1000) - const u1 = await main.storage.userStorage.GetUser(user1.userId) - const u2 = await main.storage.userStorage.GetUser(user2.userId) - const owner = await main.storage.userStorage.GetUser(application.owner.user_id) - expect(u1.balance_sats).to.be.equal(1000) - expect(u2.balance_sats).to.be.equal(994) - expect(owner.balance_sats).to.be.equal(6) -} \ No newline at end of file diff --git a/src/services/main/paymentManager.ts b/src/services/main/paymentManager.ts index 6e29c4a9..9bcf2de1 100644 --- a/src/services/main/paymentManager.ts +++ b/src/services/main/paymentManager.ts @@ -159,7 +159,6 @@ export default class { const payAmount = req.amount !== 0 ? req.amount : Number(decoded.numSatoshis) const isAppUserPayment = userId !== linkedApplication.owner.user_id const serviceFee = this.getServiceFee(Types.UserOperationType.OUTGOING_INVOICE, payAmount, isAppUserPayment) - const totalAmountToDecrement = payAmount + serviceFee const internalInvoice = await this.storage.paymentStorage.GetInvoiceOwner(req.invoice) let paymentInfo = { preimage: "", amtPaid: 0, networkFee: 0, serialId: 0 } if (internalInvoice) { @@ -190,10 +189,13 @@ export default class { this.log("paying external invoice", invoice) const routingFeeLimit = this.lnd.GetFeeLimitAmount(payAmount) await this.storage.userStorage.DecrementUserBalance(userId, totalAmountToDecrement + routingFeeLimit, invoice) + console.log("decremented") const pendingPayment = await this.storage.paymentStorage.AddPendingExternalPayment(userId, invoice, payAmount, linkedApplication) try { const payment = await this.lnd.PayInvoice(invoice, amountForLnd, routingFeeLimit) + if (routingFeeLimit - payment.feeSat > 0) { + this.log("refund routing fee", routingFeeLimit, payment.feeSat, "sats") await this.storage.userStorage.IncrementUserBalance(userId, routingFeeLimit - payment.feeSat, "routing_fee_refund:" + invoice) } await this.storage.paymentStorage.UpdateExternalPayment(pendingPayment.serial_id, payment.feeSat, serviceFee, true) diff --git a/src/services/main/sanityChecker.ts b/src/services/main/sanityChecker.ts index 72f3087d..8b94c80c 100644 --- a/src/services/main/sanityChecker.ts +++ b/src/services/main/sanityChecker.ts @@ -8,6 +8,7 @@ type UniqueDecrementReasons = 'ban' type UniqueIncrementReasons = 'fees' | 'routing_fee_refund' | 'payment_refund' type CommonReasons = 'invoice' | 'address' | 'u2u' type Reason = UniqueDecrementReasons | UniqueIncrementReasons | CommonReasons +const incrementTwiceAllowed = ['fees', 'ban'] export default class SanityChecker { storage: Storage lnd: LightningHandler @@ -17,7 +18,7 @@ export default class SanityChecker { payments: Payment[] = [] incrementSources: Record = {} decrementSources: Record = {} - decrementEvents: Record = {} + decrementEvents: Record = {} users: Record = {} constructor(storage: Storage, lnd: LightningHandler) { this.storage = storage @@ -54,7 +55,7 @@ export default class SanityChecker { if (this.decrementSources[e.data]) { throw new Error("entry decremented more that once " + e.data) } - this.decrementSources[e.data] = true + this.decrementSources[e.data] = !incrementTwiceAllowed.includes(e.data) this.users[e.userId] = this.checkUserEntry(e, this.users[e.userId]) const parsed = this.parseDataField(e.data) switch (parsed.type) { @@ -99,10 +100,10 @@ export default class SanityChecker { throw new Error("payment never settled for invoice " + invoice) // TODO: check if this is correct } if (entry.paid_at_unix === -1) { - const refund = amt - (entry.paid_amount + entry.routing_fees + entry.service_fees) - this.decrementEvents[invoice] = { userId, refund, falure: true } + this.decrementEvents[invoice] = { userId, refund: amt, failure: true } } else { - this.decrementEvents[invoice] = { userId, refund: amt, falure: false } + const refund = amt - (entry.paid_amount + entry.routing_fees + entry.service_fees) + this.decrementEvents[invoice] = { userId, refund, failure: false } } if (!entry.internal) { const lndEntry = this.payments.find(i => i.paymentRequest === invoice) @@ -132,7 +133,7 @@ export default class SanityChecker { if (this.incrementSources[e.data]) { throw new Error("entry incremented more that once " + e.data) } - this.incrementSources[e.data] = true + this.incrementSources[e.data] = !incrementTwiceAllowed.includes(e.data) this.users[e.userId] = this.checkUserEntry(e, this.users[e.userId]) const parsed = this.parseDataField(e.data) switch (parsed.type) { @@ -196,10 +197,11 @@ export default class SanityChecker { if (entry.userId !== userId) { throw new Error("user id mismatch for routing fee refund " + invoice) } - if (entry.falure) { + if (entry.failure) { throw new Error("payment failled, should not refund routing fees " + invoice) } if (entry.refund !== amt) { + console.log(entry.refund, amt) throw new Error("refund amount mismatch for routing fee refund " + invoice) } } @@ -212,7 +214,7 @@ export default class SanityChecker { if (entry.userId !== userId) { throw new Error("user id mismatch for payment refund " + invoice) } - if (!entry.falure) { + if (!entry.failure) { throw new Error("payment did not fail, should not refund payment " + invoice) } if (entry.refund !== amt) { @@ -249,7 +251,9 @@ export default class SanityChecker { checkUserEntry(e: LoggedEvent, u: { ts: number, updatedBalance: number } | undefined) { const newEntry = { ts: e.timestampMs, updatedBalance: e.balance + e.amount * (e.type === 'balance_decrement' ? -1 : 1) } + console.log(e) if (!u) { + console.log(e.userId, "balance starts at", e.balance, "sats and moves by", e.amount * (e.type === 'balance_decrement' ? -1 : 1), "sats, resulting in", newEntry.updatedBalance, "sats") return newEntry } if (e.timestampMs < u.ts) { @@ -258,6 +262,7 @@ export default class SanityChecker { if (e.balance !== u.updatedBalance) { throw new Error("inconsistent balance update got: " + e.balance + " expected " + u.updatedBalance) } + console.log(e.userId, "balance updates from", e.balance, "sats and moves by", e.amount * (e.type === 'balance_decrement' ? -1 : 1), "sats, resulting in", newEntry.updatedBalance, "sats") return newEntry } } \ No newline at end of file diff --git a/src/services/main/settings.ts b/src/services/main/settings.ts index e2bcda27..bb96bb60 100644 --- a/src/services/main/settings.ts +++ b/src/services/main/settings.ts @@ -40,16 +40,22 @@ export const LoadMainSettingsFromEnv = (): MainSettings => { servicePort: EnvMustBeInteger("PORT"), recordPerformance: process.env.RECORD_PERFORMANCE === 'true' || false, skipSanityCheck: process.env.SKIP_SANITY_CHECK === 'true' || false, - disableExternalPayments: process.env.DISABLE_EXTERNAL_PAYMENTS === 'true' || false, - + disableExternalPayments: process.env.DISABLE_EXTERNAL_PAYMENTS === 'true' || false } } export const LoadTestSettingsFromEnv = (): MainSettings => { + const eventLogPath = `logs/eventLogV2Test${Date.now()}.csv` const settings = LoadMainSettingsFromEnv() return { ...settings, - storageSettings: { dbSettings: { ...settings.storageSettings.dbSettings, databaseFile: ":memory:", metricsDatabaseFile: ":memory:" } }, + storageSettings: { dbSettings: { ...settings.storageSettings.dbSettings, databaseFile: ":memory:", metricsDatabaseFile: ":memory:" }, eventLogPath }, + lndSettings: { + ...settings.lndSettings, + otherLndAddr: EnvMustBeNonEmptyString("LND_OTHER_ADDR"), + otherLndCertPath: EnvMustBeNonEmptyString("LND_OTHER_CERT_PATH"), + otherLndMacaroonPath: EnvMustBeNonEmptyString("LND_OTHER_MACAROON_PATH") + }, skipSanityCheck: true } } \ No newline at end of file diff --git a/src/services/main/watchdog.ts b/src/services/main/watchdog.ts index f5108f6c..2d6786c3 100644 --- a/src/services/main/watchdog.ts +++ b/src/services/main/watchdog.ts @@ -11,6 +11,7 @@ export const LoadWatchdogSettingsFromEnv = (test = false): WatchdogSettings => { } } export class Watchdog { + initialLndBalance: number; initialUsersBalance: number; lnd: LightningHandler; @@ -19,19 +20,26 @@ export class Watchdog { latestCheckStart = 0 log = getLogger({ appName: "watchdog" }) enabled = false + interval: NodeJS.Timer; constructor(settings: WatchdogSettings, lnd: LightningHandler, storage: Storage) { this.lnd = lnd; this.settings = settings; this.storage = storage; } + Stop() { + if (this.interval) { + clearInterval(this.interval) + } + } + Start = async () => { const totalUsersBalance = await this.storage.paymentStorage.GetTotalUsersBalance() this.initialLndBalance = await this.getTotalLndBalance() this.initialUsersBalance = totalUsersBalance this.enabled = true - setInterval(() => { + this.interval = setInterval(() => { if (this.latestCheckStart + (1000 * 60) < Date.now()) { this.log("No balance check was made in the last minute, checking now") this.PaymentRequested() diff --git a/src/services/storage/eventsLog.ts b/src/services/storage/eventsLog.ts index ec226219..6983bcd3 100644 --- a/src/services/storage/eventsLog.ts +++ b/src/services/storage/eventsLog.ts @@ -1,7 +1,7 @@ import fs from 'fs' import { parse, stringify } from 'csv' import { getLogger } from '../helpers/logger.js' -const eventLogPath = "logs/eventLogV2.csv" +//const eventLogPath = "logs/eventLogV2.csv" type LoggedEventType = 'new_invoice' | 'new_address' | 'address_paid' | 'invoice_paid' | 'invoice_payment' | 'address_payment' | 'u2u_receiver' | 'u2u_sender' | 'balance_increment' | 'balance_decrement' export type LoggedEvent = { timestampMs: number @@ -22,9 +22,11 @@ type TimeEntry = { const columns = ["timestampMs", "userId", "appUserId", "appId", "balance", "type", "data", "amount"] type StringerWrite = (chunk: any, cb: (error: Error | null | undefined) => void) => boolean export default class EventsLogManager { + eventLogPath: string log = getLogger({ appName: "EventsLogManager" }) stringerWrite: StringerWrite - constructor() { + constructor(eventLogPath: string) { + this.eventLogPath = eventLogPath const exists = fs.existsSync(eventLogPath) if (!exists) { const stringer = stringify({ header: true, columns }) @@ -51,7 +53,7 @@ export default class EventsLogManager { } Read = async (path?: string): Promise => { - const filePath = path ? path : eventLogPath + const filePath = path ? path : this.eventLogPath const exists = fs.existsSync(filePath) if (!exists) { return [] diff --git a/src/services/storage/index.ts b/src/services/storage/index.ts index cff8c41a..8038102b 100644 --- a/src/services/storage/index.ts +++ b/src/services/storage/index.ts @@ -9,9 +9,10 @@ import TransactionsQueue, { TX } from "./transactionsQueue.js"; import EventsLogManager from "./eventsLog.js"; export type StorageSettings = { dbSettings: DbSettings + eventLogPath: string } export const LoadStorageSettingsFromEnv = (): StorageSettings => { - return { dbSettings: LoadDbSettingsFromEnv() } + return { dbSettings: LoadDbSettingsFromEnv(), eventLogPath: "logs/eventLogV2.csv" } } export default class { DB: DataSource | EntityManager @@ -25,7 +26,7 @@ export default class { eventsLog: EventsLogManager constructor(settings: StorageSettings) { this.settings = settings - this.eventsLog = new EventsLogManager() + this.eventsLog = new EventsLogManager(settings.eventLogPath) } async Connect(migrations: Function[], metricsMigrations: Function[]) { const { source, executedMigrations } = await NewDB(this.settings.dbSettings, migrations) diff --git a/src/services/storage/transactionsQueue.ts b/src/services/storage/transactionsQueue.ts index 44a74fb4..8a1894b2 100644 --- a/src/services/storage/transactionsQueue.ts +++ b/src/services/storage/transactionsQueue.ts @@ -20,6 +20,7 @@ export default class { PushToQueue(op: TxOperation) { if (!this.pendingTx) { + this.log("queue empty, starting transaction", this.transactionsQueue.length) return this.execQueueItem(op) } this.log("queue not empty, possibly stuck") @@ -29,6 +30,7 @@ export default class { } async execNextInQueue() { + this.log("executing next in queue") this.pendingTx = false const next = this.transactionsQueue.pop() if (!next) { @@ -49,6 +51,7 @@ export default class { throw new Error("cannot start DB transaction") } this.pendingTx = true + this.log("starting", op.dbTx ? "db transaction" : "operation", op.description || "") if (op.dbTx) { return this.doTransaction(op.exec) } @@ -67,16 +70,17 @@ export default class { } - doTransaction(exec: TX) { - return this.DB.transaction(async tx => { - try { - const res = await exec(tx) - this.execNextInQueue() - return res - } catch (err) { - this.execNextInQueue() - throw err - } - }) + async doTransaction(exec: TX) { + try { + const res = await this.DB.transaction(async tx => { + return exec(tx) + }) + this.execNextInQueue() + return res + } catch (err: any) { + this.execNextInQueue() + this.log("transaction failed", err.message) + throw err + } } } \ No newline at end of file diff --git a/src/services/storage/userStorage.ts b/src/services/storage/userStorage.ts index afdb7deb..8c1d5b2d 100644 --- a/src/services/storage/userStorage.ts +++ b/src/services/storage/userStorage.ts @@ -76,9 +76,21 @@ export default class { } return user } - async IncrementUserBalance(userId: string, increment: number, reason: string, entityManager = this.DB) { - const user = await this.GetUser(userId, entityManager) - const res = await entityManager.getRepository(User).increment({ + async IncrementUserBalance(userId: string, increment: number, reason: string, entityManager?: DataSource | EntityManager) { + if (entityManager) { + return this.IncrementUserBalanceInTx(userId, increment, reason, entityManager) + } + await this.txQueue.PushToQueue({ + dbTx: true, + description: `incrementing user ${userId} balance by ${increment}`, + exec: async tx => { + await this.IncrementUserBalanceInTx(userId, increment, reason, tx) + } + }) + } + async IncrementUserBalanceInTx(userId: string, increment: number, reason: string, dbTx: DataSource | EntityManager) { + const user = await this.GetUser(userId, dbTx) + const res = await dbTx.getRepository(User).increment({ user_id: userId, }, "balance_sats", increment) if (!res.affected) { @@ -88,13 +100,26 @@ export default class { getLogger({ userId: userId, appName: "balanceUpdates" })("incremented balance from", user.balance_sats, "sats, by", increment, "sats") this.eventsLog.LogEvent({ type: 'balance_increment', userId, appId: "", appUserId: "", balance: user.balance_sats, data: reason, amount: increment }) } - async DecrementUserBalance(userId: string, decrement: number, reason: string, entityManager = this.DB) { - const user = await this.GetUser(userId, entityManager) + async DecrementUserBalance(userId: string, decrement: number, reason: string, entityManager?: DataSource | EntityManager) { + if (entityManager) { + return this.DecrementUserBalanceInTx(userId, decrement, reason, entityManager) + } + await this.txQueue.PushToQueue({ + dbTx: true, + description: `decrementing user ${userId} balance by ${decrement}`, + exec: async tx => { + await this.DecrementUserBalanceInTx(userId, decrement, reason, tx) + } + }) + } + + async DecrementUserBalanceInTx(userId: string, decrement: number, reason: string, dbTx: DataSource | EntityManager) { + const user = await this.GetUser(userId, dbTx) if (!user || user.balance_sats < decrement) { - getLogger({ userId: userId, appName: "balanceUpdates" })("user to decrement not found") + getLogger({ userId: userId, appName: "balanceUpdates" })("not enough balance to decrement") throw new Error("not enough balance to decrement") } - const res = await entityManager.getRepository(User).decrement({ + const res = await dbTx.getRepository(User).decrement({ user_id: userId, }, "balance_sats", decrement) if (!res.affected) { diff --git a/src/test.ts b/src/test.ts deleted file mode 100644 index 16a3d5f5..00000000 --- a/src/test.ts +++ /dev/null @@ -1,5 +0,0 @@ -const failure = true -export default async (describe: (message: string, failure?: boolean) => void) => { - describe("all good") - describe("oh no", failure) -} \ No newline at end of file diff --git a/src/tests/externalPayment.spec.ts b/src/tests/externalPayment.spec.ts new file mode 100644 index 00000000..28253d0d --- /dev/null +++ b/src/tests/externalPayment.spec.ts @@ -0,0 +1,29 @@ +import { defaultInvoiceExpiry } from '../services/storage/paymentStorage.js' +import { Describe, expect, expectThrowsAsync, runSanityCheck, safelySetUserBalance, SetupTest, TestBase } from './testBase.js' +export const ignore = false + +export default async (T: TestBase) => { + await safelySetUserBalance(T, T.user1, 2000) + await testSuccessfulExternalPayment(T) + await runSanityCheck(T) +} + + +const testSuccessfulExternalPayment = async (T: TestBase) => { + const application = await T.main.storage.applicationStorage.GetApplication(T.app.appId) + const invoice = await T.externalAccessToOtherLnd.NewInvoice(500, "test", defaultInvoiceExpiry) + expect(invoice.payRequest).to.startWith("lnbcrt5u") + T.d("generated 500 sats invoice for external node") + + const pay = await T.main.paymentManager.PayInvoice(T.user1.userId, { invoice: invoice.payRequest, amount: 0 }, application) + expect(pay.amount_paid).to.be.equal(500) + T.d("paid 500 sats invoice from user1") + const u1 = await T.main.storage.userStorage.GetUser(T.user1.userId) + const owner = await T.main.storage.userStorage.GetUser(application.owner.user_id) + expect(u1.balance_sats).to.be.equal(1496) + T.d("user1 balance is now 1496 (2000 - (500 + 3 fee + 1 routing))") + expect(owner.balance_sats).to.be.equal(3) + T.d("app balance is 3 sats") + +} + diff --git a/src/tests/internalPayment.spec.ts b/src/tests/internalPayment.spec.ts new file mode 100644 index 00000000..562a502d --- /dev/null +++ b/src/tests/internalPayment.spec.ts @@ -0,0 +1,39 @@ +import { defaultInvoiceExpiry } from '../services/storage/paymentStorage.js' +import { Describe, expect, expectThrowsAsync, runSanityCheck, safelySetUserBalance, SetupTest, TestBase } from './testBase.js' +export const ignore = false + +export default async (T: TestBase) => { + await safelySetUserBalance(T, T.user1, 2000) + await testSuccessfulInternalPayment(T) + await testFailedInternalPayment(T) + await runSanityCheck(T) +} + +const testSuccessfulInternalPayment = async (T: TestBase) => { + const application = await T.main.storage.applicationStorage.GetApplication(T.app.appId) + const invoice = await T.main.paymentManager.NewInvoice(T.user2.userId, { amountSats: 1000, memo: "test" }, { linkedApplication: application, expiry: defaultInvoiceExpiry }) + expect(invoice.invoice).to.startWith("lnbcrt10u") + T.d("generated 1000 sats invoice for user2") + const pay = await T.main.paymentManager.PayInvoice(T.user1.userId, { invoice: invoice.invoice, amount: 0 }, application) + expect(pay.amount_paid).to.be.equal(1000) + T.d("paid 1000 sats invoice from user1") + const u1 = await T.main.storage.userStorage.GetUser(T.user1.userId) + const u2 = await T.main.storage.userStorage.GetUser(T.user2.userId) + const owner = await T.main.storage.userStorage.GetUser(application.owner.user_id) + expect(u2.balance_sats).to.be.equal(1000) + T.d("user2 balance is 1000") + expect(u1.balance_sats).to.be.equal(994) + T.d("user1 balance is 994 cuz he paid 6 sats fee") + expect(owner.balance_sats).to.be.equal(6) + T.d("app balance is 6 sats") +} + +const testFailedInternalPayment = async (T: TestBase) => { + const application = await T.main.storage.applicationStorage.GetApplication(T.app.appId) + const invoice = await T.main.paymentManager.NewInvoice(T.user2.userId, { amountSats: 1000, memo: "test" }, { linkedApplication: application, expiry: defaultInvoiceExpiry }) + expect(invoice.invoice).to.startWith("lnbcrt10u") + T.d("generated 1000 sats invoice for user2") + await expectThrowsAsync(T.main.paymentManager.PayInvoice(T.user1.userId, { invoice: invoice.invoice, amount: 0 }, application), "not enough balance to decrement") + T.d("payment failed as expected, with the expected error message") +} + diff --git a/src/tests/spamExternalPayments.spec.ts b/src/tests/spamExternalPayments.spec.ts new file mode 100644 index 00000000..4c44f21a --- /dev/null +++ b/src/tests/spamExternalPayments.spec.ts @@ -0,0 +1,46 @@ +import { disableLoggers } from '../services/helpers/logger.js' +import { defaultInvoiceExpiry } from '../services/storage/paymentStorage.js' +import { Describe, expect, expectThrowsAsync, runSanityCheck, safelySetUserBalance, SetupTest, TestBase } from './testBase.js' +export const ignore = false + +export default async (T: TestBase) => { + disableLoggers(["EventsLogManager", "htlcTracker", "watchdog"]) + await safelySetUserBalance(T, T.user1, 2000) + await testSpamExternalPayment(T) + await runSanityCheck(T) +} + + +const testSpamExternalPayment = async (T: TestBase) => { + const application = await T.main.storage.applicationStorage.GetApplication(T.app.appId) + const invoices = await Promise.all(new Array(10).fill(0).map(() => T.externalAccessToOtherLnd.NewInvoice(500, "test", defaultInvoiceExpiry))) + T.d("generated 10 500 sats invoices for external node") + const res = await Promise.all(invoices.map(async (invoice, i) => { + try { + T.d("trying to pay invoice " + i) + const result = await T.main.paymentManager.PayInvoice(T.user1.userId, { invoice: invoice.payRequest, amount: 0 }, application) + T.d("payment succeeded " + i) + return { success: true, result } + } catch (e: any) { + T.d("payment failed " + i) + console.log(e, i) + return { success: false, err: e } + } + })) + + const successfulPayments = res.filter(r => r.success) + const failedPayments = res.filter(r => !r.success) + failedPayments.forEach(f => expect(f.err).to.be.equal("not enough balance to decrement")) + successfulPayments.forEach(s => expect(s.result).to.contain({ amount_paid: 500, network_fee: 1, service_fee: 3 })) + expect(successfulPayments.length).to.be.equal(3) + expect(failedPayments.length).to.be.equal(7) + T.d("3 payments succeeded, 7 failed as expected") + const u = await T.main.storage.userStorage.GetUser(T.user1.userId) + const owner = await T.main.storage.userStorage.GetUser(application.owner.user_id) + expect(u.balance_sats).to.be.equal(488) + T.d("user1 balance is now 488 (2000 - (500 + 3 fee + 1 routing) * 3)") + expect(owner.balance_sats).to.be.equal(9) + T.d("app balance is 9 sats") + +} + diff --git a/src/tests/spamMixedPayments.spec.ts b/src/tests/spamMixedPayments.spec.ts new file mode 100644 index 00000000..2bcf4ab9 --- /dev/null +++ b/src/tests/spamMixedPayments.spec.ts @@ -0,0 +1,54 @@ +import { disableLoggers } from '../services/helpers/logger.js' +import { defaultInvoiceExpiry } from '../services/storage/paymentStorage.js' +import { Describe, expect, expectThrowsAsync, runSanityCheck, safelySetUserBalance, SetupTest, TestBase } from './testBase.js' +import * as Types from '../../proto/autogenerated/ts/types.js' +export const ignore = false + +export default async (T: TestBase) => { + disableLoggers(["EventsLogManager", "htlcTracker", "watchdog"]) + await safelySetUserBalance(T, T.user1, 2000) + await testSpamExternalPayment(T) + await runSanityCheck(T) +} + + +const testSpamExternalPayment = async (T: TestBase) => { + const application = await T.main.storage.applicationStorage.GetApplication(T.app.appId) + const invoicesForExternal = await Promise.all(new Array(5).fill(0).map(() => T.externalAccessToOtherLnd.NewInvoice(500, "test", defaultInvoiceExpiry))) + const invoicesForUser2 = await Promise.all(new Array(5).fill(0).map(() => T.main.paymentManager.NewInvoice(T.user2.userId, { amountSats: 500, memo: "test" }, { linkedApplication: application, expiry: defaultInvoiceExpiry }))) + const invoices = invoicesForExternal.map(i => i.payRequest).concat(invoicesForUser2.map(i => i.invoice)) + T.d("generated 10 500 sats mixed invoices between external node and user 2") + const res = await Promise.all(invoices.map(async (invoice, i) => { + try { + T.d("trying to pay invoice " + i) + const result = await T.main.paymentManager.PayInvoice(T.user1.userId, { invoice: invoice, amount: 0 }, application) + T.d("payment succeeded " + i) + return { success: true, result } + } catch (e: any) { + T.d("payment failed " + i) + console.log(e, i) + return { success: false, err: e } + } + })) + + const successfulPayments = res.filter(r => r.success) as { success: true, result: Types.PayInvoiceResponse }[] + const failedPayments = res.filter(r => !r.success) + failedPayments.forEach(f => expect(f.err).to.be.equal("not enough balance to decrement")) + expect(successfulPayments.length).to.be.equal(3) + expect(failedPayments.length).to.be.equal(7) + T.d("3 payments succeeded, 7 failed as expected") + const networkPayments = successfulPayments.filter(s => s.result.network_fee > 0) + const internalPayments = successfulPayments.filter(s => s.result.network_fee === 0) + expect(networkPayments.length).to.be.equal(1) + expect(internalPayments.length).to.be.equal(2) + networkPayments.forEach(s => expect(s.result).to.contain({ amount_paid: 500, service_fee: 3, network_fee: 1 })) + internalPayments.forEach(s => expect(s.result).to.contain({ amount_paid: 500, service_fee: 3 })) + const u = await T.main.storage.userStorage.GetUser(T.user1.userId) + const owner = await T.main.storage.userStorage.GetUser(application.owner.user_id) + expect(u.balance_sats).to.be.equal(490) + T.d("user1 balance is now 490 (2000 - (500 + 3 fee + 1 routing + (500 + 3fee) * 2))") + expect(owner.balance_sats).to.be.equal(9) + T.d("app balance is 9 sats") + +} + diff --git a/src/tests/testBase.ts b/src/tests/testBase.ts new file mode 100644 index 00000000..b10d620a --- /dev/null +++ b/src/tests/testBase.ts @@ -0,0 +1,97 @@ +import 'dotenv/config' // TODO - test env +import chai from 'chai' +import { AppData, initMainHandler } from '../services/main/init.js' +import Main from '../services/main/index.js' +import Storage from '../services/storage/index.js' +import { User } from '../services/storage/entity/User.js' +import { LoadMainSettingsFromEnv, LoadTestSettingsFromEnv, MainSettings } from '../services/main/settings.js' +import chaiString from 'chai-string' +import { defaultInvoiceExpiry } from '../services/storage/paymentStorage.js' +import SanityChecker from '../services/main/sanityChecker.js' +import LND from '../services/lnd/lnd.js' +import { LightningHandler } from '../services/lnd/index.js' +chai.use(chaiString) +export const expect = chai.expect +export type Describe = (message: string, failure?: boolean) => void +export type TestUserData = { + userId: string; + appUserIdentifier: string; + appId: string; + +} +export type TestBase = { + expect: Chai.ExpectStatic; + main: Main + app: AppData + user1: TestUserData + user2: TestUserData + externalAccessToMainLnd: LND + externalAccessToOtherLnd: LND + d: Describe +} + +export const SetupTest = async (d: Describe): Promise => { + const settings = LoadTestSettingsFromEnv() + const initialized = await initMainHandler(console.log, settings) + if (!initialized) { + throw new Error("failed to initialize main handler") + } + const main = initialized.mainHandler + const app = initialized.apps[0] + const u1 = await main.applicationManager.AddAppUser(app.appId, { identifier: "user1", balance: 0, fail_if_exists: true }) + const u2 = await main.applicationManager.AddAppUser(app.appId, { identifier: "user2", balance: 0, fail_if_exists: true }) + const user1 = { userId: u1.info.userId, appUserIdentifier: u1.identifier, appId: app.appId } + const user2 = { userId: u2.info.userId, appUserIdentifier: u2.identifier, appId: app.appId } + + + const externalAccessToMainLnd = new LND(settings.lndSettings, console.log, console.log, () => { }, () => { }) + const otherLndSetting = { ...settings.lndSettings, lndCertPath: settings.lndSettings.otherLndCertPath, lndMacaroonPath: settings.lndSettings.otherLndMacaroonPath, lndAddr: settings.lndSettings.otherLndAddr } + const externalAccessToOtherLnd = new LND(otherLndSetting, console.log, console.log, () => { }, () => { }) + await externalAccessToMainLnd.Warmup() + await externalAccessToOtherLnd.Warmup() + + + return { + expect, main, app, + user1, user2, + externalAccessToMainLnd, externalAccessToOtherLnd, + d + } +} + +export const teardown = async (T: TestBase) => { + T.main.paymentManager.watchDog.Stop() + T.main.lnd.Stop() + T.externalAccessToMainLnd.Stop() + T.externalAccessToOtherLnd.Stop() + console.log("teardown") +} + +export const safelySetUserBalance = async (T: TestBase, user: TestUserData, amount: number) => { + const app = await T.main.storage.applicationStorage.GetApplication(user.appId) + const invoice = await T.main.paymentManager.NewInvoice(user.userId, { amountSats: amount, memo: "test" }, { linkedApplication: app, expiry: defaultInvoiceExpiry }) + await T.externalAccessToOtherLnd.PayInvoice(invoice.invoice, 0, 100) + const u = await T.main.storage.userStorage.GetUser(user.userId) + expect(u.balance_sats).to.be.equal(amount) + T.d(`user ${user.appUserIdentifier} balance is now ${amount}`) +} + +export const runSanityCheck = async (T: TestBase) => { + const sanityChecker = new SanityChecker(T.main.storage, T.main.lnd) + await sanityChecker.VerifyEventsLog() +} + +export const expectThrowsAsync = async (promise: Promise, errorMessage?: string) => { + let error: Error | null = null + try { + await promise + } + catch (err: any) { + error = err as Error + } + expect(error).to.be.an('Error') + console.log(error!.message) + if (errorMessage) { + expect(error!.message).to.equal(errorMessage) + } +} \ No newline at end of file diff --git a/src/testRunner.ts b/src/tests/testRunner.ts similarity index 62% rename from src/testRunner.ts rename to src/tests/testRunner.ts index 8a9b2d87..0ad46882 100644 --- a/src/testRunner.ts +++ b/src/tests/testRunner.ts @@ -1,11 +1,10 @@ import { globby } from 'globby' -type Describe = (message: string, failure?: boolean) => void +import { Describe, SetupTest, teardown, TestBase } from './testBase.js' + type TestModule = { ignore?: boolean - setup?: () => Promise - default: (describe: Describe) => Promise - teardown?: () => Promise + default: (T: TestBase) => Promise } let failures = 0 const start = async () => { @@ -13,7 +12,7 @@ const start = async () => { const files = await globby("**/*.spec.js") for (const file of files) { console.log(file) - const module = await import(`./${file.slice("build/src/".length)}`) as TestModule + const module = await import(`./${file.slice("build/src/tests/".length)}`) as TestModule await runTestFile(file, module) } if (failures) { @@ -30,21 +29,15 @@ const runTestFile = async (fileName: string, mod: TestModule) => { d("-----ignoring file-----") return } + const T = await SetupTest(d) try { - if (mod.setup) { - d("setup started") - await mod.setup() - } - d("tests starting") - await mod.default(d) - d("tests finished") - if (mod.teardown) { - await mod.teardown() - d("teardown finished") - } + d("test starting") + await mod.default(T) + d("test finished") + await teardown(T) } catch (e: any) { - d("FAILURE", true) d(e, true) + await teardown(T) } } @@ -52,7 +45,7 @@ const getDescribe = (fileName: string): Describe => { return (message, failure) => { if (failure) { failures++ - console.error(redConsole, fileName, ":", message, resetConsole) + console.error(redConsole, fileName, ": FAILURE ", message, resetConsole) } else { console.log(greenConsole, fileName, ":", message, resetConsole) }