watchdog v1

This commit is contained in:
hatim boufnichel 2024-03-23 00:02:26 +01:00
parent f20abdd5f4
commit 9d7a3af9d7
4 changed files with 119 additions and 18 deletions

View file

@ -26,7 +26,8 @@ const start = async () => {
const mainHandler = new Main(mainSettings, storageManager) const mainHandler = new Main(mainSettings, storageManager)
await mainHandler.lnd.Warmup() await mainHandler.lnd.Warmup()
if (!mainSettings.skipSanityCheck) { if (!mainSettings.skipSanityCheck) {
await mainHandler.VerifyEventsLog() const totalUsersBalance = await mainHandler.VerifyEventsLog()
await mainHandler.paymentManager.watchDog.SeedLndBalance(totalUsersBalance)
} }
const serverMethods = GetServerMethods(mainHandler) const serverMethods = GetServerMethods(mainHandler)
const nostrSettings = LoadNosrtSettingsFromEnv() const nostrSettings = LoadNosrtSettingsFromEnv()

View file

@ -4,14 +4,20 @@ import { LightningHandler } from "./index.js";
export type WatchdogSettings = { export type WatchdogSettings = {
maxDiffBps: number maxDiffBps: number
maxDiffSats: number maxDiffSats: number
maxUpdateDiffSats: number
} }
export const LoadWatchdogSettingsFromEnv = (test = false): WatchdogSettings => { export const LoadWatchdogSettingsFromEnv = (test = false): WatchdogSettings => {
return { return {
maxDiffBps: EnvMustBeInteger("WATCHDOG_MAX_DIFF_BPS"), maxDiffBps: EnvMustBeInteger("WATCHDOG_MAX_DIFF_BPS"),
maxDiffSats: EnvMustBeInteger("WATCHDOG_MAX_DIFF_SATS") maxDiffSats: EnvMustBeInteger("WATCHDOG_MAX_DIFF_SATS"),
maxUpdateDiffSats: EnvMustBeInteger("WATCHDOG_MAX_UPDATE_DIFF_SATS")
} }
} }
export class Watchdog { export class Watchdog {
initialLndBalance: number;
initialUsersBalance: number;
lastLndBalance: number;
lastUsersBalance: number;
lnd: LightningHandler; lnd: LightningHandler;
settings: WatchdogSettings; settings: WatchdogSettings;
log = getLogger({ appName: "watchdog" }) log = getLogger({ appName: "watchdog" })
@ -20,15 +26,105 @@ export class Watchdog {
this.settings = settings; this.settings = settings;
} }
SeedLndBalance = async (totalUsersBalance: number) => {
this.initialLndBalance = await this.getTotalLndBalance()
this.lastLndBalance = this.initialLndBalance
this.initialUsersBalance = totalUsersBalance
this.lastUsersBalance = this.initialUsersBalance
}
getTotalLndBalance = async () => {
const { channelsBalance, confirmedBalance } = await this.lnd.GetBalance()
return confirmedBalance + channelsBalance.reduce((acc, { localBalanceSats }) => acc + localBalanceSats, 0)
}
checkBalanceUpdate = (deltaLnd: number, deltaUsers: number, type: 'incremental' | 'absolute', threshold: number) => {
this.log("LND balance update:", deltaLnd, "sats", type === 'incremental' ? "since last balance check" : "since app startup")
this.log("Users balance update:", deltaUsers, "sats", type === 'incremental' ? "since last balance check" : "since app startup")
const result = this.checkDeltas(deltaLnd, deltaUsers)
switch (result.type) {
case 'mismatch':
if (deltaLnd < 0) {
this.log("WARNING! LND balance decreased while users balance increased creating a difference of", result.absoluteDiff, "sats")
if (result.absoluteDiff > threshold) {
this.log("Difference is too big for an update, locking outgoing operations")
return true
}
} else {
this.log("LND balance increased while users balance decreased creating a difference of", result.absoluteDiff, "sats, could be caused by data loss, or liquidity injection")
return false
}
break
case 'negative':
if (Math.abs(deltaLnd) > Math.abs(deltaUsers)) {
this.log("WARNING! LND balance decreased more than users balance with a difference of", result.absoluteDiff, "sats")
if (result.absoluteDiff > threshold) {
this.log("Difference is too big for an update, locking outgoing operations")
return true
}
} else {
this.log("LND balance decreased less than users balance with a difference of", result.absoluteDiff, "sats, could be caused by data loss, or liquidity injection")
return false
}
break
case 'positive':
if (deltaLnd < deltaUsers) {
this.log("WARNING! LND balance increased less than users balance with a difference of", result.absoluteDiff, "sats")
if (result.absoluteDiff > threshold) {
this.log("Difference is too big for an update, locking outgoing operations")
return true
}
} else {
this.log("LND balance increased more than users balance with a difference of", result.absoluteDiff, "sats, could be caused by data loss, or liquidity injection")
return false
}
}
return false
}
PaymentRequested = async (totalUsersBalance: number) => { PaymentRequested = async (totalUsersBalance: number) => {
this.log("Payment requested, checking balance") this.log("Payment requested, checking balance")
const { channelsBalance, confirmedBalance } = await this.lnd.GetBalance() const totalLndBalance = await this.getTotalLndBalance()
const totalLndBalance = confirmedBalance + channelsBalance.reduce((acc, { localBalanceSats }) => acc + localBalanceSats, 0) const IncDeltaLnd = totalLndBalance - this.lastLndBalance
const diffSats = Math.abs(totalLndBalance - totalUsersBalance) const IncDeltaUsers = totalUsersBalance - this.lastUsersBalance
const diffBps = (diffSats / Math.max(totalLndBalance, totalUsersBalance)) * 10_000 const denyIncremental = this.checkBalanceUpdate(IncDeltaLnd, IncDeltaUsers, 'incremental', this.settings.maxUpdateDiffSats)
if (diffSats > this.settings.maxDiffSats || diffBps > this.settings.maxDiffBps) { if (denyIncremental) {
this.log(`LND balance ${totalLndBalance} is too different from users balance ${totalUsersBalance}`) this.log("Balance mismatch detected in incremental update, locking outgoing operations")
this.lnd.LockOutgoingOperations() this.lnd.LockOutgoingOperations()
return
}
const AbsDeltaLnd = totalLndBalance - this.initialLndBalance
const AbsDeltaUsers = totalUsersBalance - this.initialUsersBalance
const denyAbsolute = this.checkBalanceUpdate(AbsDeltaLnd, AbsDeltaUsers, 'absolute', this.settings.maxDiffSats)
if (denyAbsolute) {
this.log("Balance mismatch detected in absolute update, locking outgoing operations")
this.lnd.LockOutgoingOperations()
return
}
this.lastLndBalance = totalLndBalance
this.lastUsersBalance = totalUsersBalance
}
checkDeltas = (deltaLnd: number, deltaUsers: number): DeltaCheckResult => {
if (deltaLnd < 0) {
if (deltaUsers < 0) {
const diff = Math.abs(deltaLnd - deltaUsers)
return { type: 'negative', absoluteDiff: diff, relativeDiff: diff / Math.max(deltaLnd, deltaUsers) }
} else {
const diff = Math.abs(deltaLnd) + deltaUsers
return { type: 'mismatch', absoluteDiff: diff }
}
} else {
if (deltaUsers < 0) {
const diff = deltaLnd + Math.abs(deltaUsers)
return { type: 'mismatch', absoluteDiff: diff }
} else {
const diff = Math.abs(deltaLnd - deltaUsers)
return { type: 'positive', absoluteDiff: diff, relativeDiff: diff / Math.max(deltaLnd, deltaUsers) }
}
} }
} }
} }
type DeltaCheckResult = { type: 'negative' | 'positive', absoluteDiff: number, relativeDiff: number } | { type: 'mismatch', absoluteDiff: number }

View file

@ -113,12 +113,12 @@ export default class {
if (!updateResult.affected) { if (!updateResult.affected) {
throw new Error("unable to flag chain transaction as paid") throw new Error("unable to flag chain transaction as paid")
} }
await this.storage.userStorage.IncrementUserBalance(userAddress.user.user_id, amount - serviceFee, userAddress.address, tx) const addressData = `${userAddress.address}:${tx_hash}`
this.storage.eventsLog.LogEvent({ type: 'address_paid', userId: userAddress.user.user_id, appId: userAddress.linkedApplication.app_id, appUserId: "", balance: userAddress.user.balance_sats, data: addressData, amount })
await this.storage.userStorage.IncrementUserBalance(userAddress.user.user_id, amount - serviceFee, addressData, tx)
if (serviceFee > 0) { if (serviceFee > 0) {
await this.storage.userStorage.IncrementUserBalance(userAddress.linkedApplication.owner.user_id, serviceFee, 'fees', tx) await this.storage.userStorage.IncrementUserBalance(userAddress.linkedApplication.owner.user_id, serviceFee, 'fees', tx)
} }
const addressData = `${userAddress.address}:${tx_hash}`
this.storage.eventsLog.LogEvent({ type: 'address_paid', userId: userAddress.user.user_id, appId: userAddress.linkedApplication.app_id, appUserId: "", balance: userAddress.user.balance_sats, data: addressData, amount })
const operationId = `${Types.UserOperationType.INCOMING_TX}-${serialId}` const operationId = `${Types.UserOperationType.INCOMING_TX}-${serialId}`
const op = { amount, paidAtUnix: Date.now() / 1000, inbound: true, type: Types.UserOperationType.INCOMING_TX, identifier: userAddress.address, operationId, network_fee: 0, service_fee: serviceFee, confirmed: true, tx_hash: c.tx.tx_hash, internal: c.tx.internal } const op = { amount, paidAtUnix: Date.now() / 1000, inbound: true, type: Types.UserOperationType.INCOMING_TX, identifier: userAddress.address, operationId, network_fee: 0, service_fee: serviceFee, confirmed: true, tx_hash: c.tx.tx_hash, internal: c.tx.internal }
this.sendOperationToNostr(userAddress.linkedApplication!, userAddress.user.user_id, op) this.sendOperationToNostr(userAddress.linkedApplication!, userAddress.user.user_id, op)
@ -148,12 +148,13 @@ export default class {
// This call will fail if the transaction is already registered // This call will fail if the transaction is already registered
const addedTx = await this.storage.paymentStorage.AddAddressReceivingTransaction(userAddress, txOutput.hash, txOutput.index, amount, fee, internal, blockHeight, tx) const addedTx = await this.storage.paymentStorage.AddAddressReceivingTransaction(userAddress, txOutput.hash, txOutput.index, amount, fee, internal, blockHeight, tx)
if (internal) { if (internal) {
await this.storage.userStorage.IncrementUserBalance(userAddress.user.user_id, addedTx.paid_amount - fee, userAddress.address, tx) const addressData = `${address}:${txOutput.hash}`
this.storage.eventsLog.LogEvent({ type: 'address_paid', userId: userAddress.user.user_id, appId: userAddress.linkedApplication.app_id, appUserId: "", balance: userAddress.user.balance_sats, data: addressData, amount })
await this.storage.userStorage.IncrementUserBalance(userAddress.user.user_id, addedTx.paid_amount - fee, addressData, tx)
if (fee > 0) { if (fee > 0) {
await this.storage.userStorage.IncrementUserBalance(userAddress.linkedApplication.owner.user_id, fee, 'fees', tx) await this.storage.userStorage.IncrementUserBalance(userAddress.linkedApplication.owner.user_id, fee, 'fees', tx)
} }
const addressData = `${address}:${txOutput.hash}`
this.storage.eventsLog.LogEvent({ type: 'address_paid', userId: userAddress.user.user_id, appId: userAddress.linkedApplication.app_id, appUserId: "", balance: userAddress.user.balance_sats, data: addressData, amount })
} }
const operationId = `${Types.UserOperationType.INCOMING_TX}-${addedTx.serial_id}` const operationId = `${Types.UserOperationType.INCOMING_TX}-${addedTx.serial_id}`
const op = { amount, paidAtUnix: Date.now() / 1000, inbound: true, type: Types.UserOperationType.INCOMING_TX, identifier: userAddress.address, operationId, network_fee: 0, service_fee: fee, confirmed: internal, tx_hash: txOutput.hash, internal: false } const op = { amount, paidAtUnix: Date.now() / 1000, inbound: true, type: Types.UserOperationType.INCOMING_TX, identifier: userAddress.address, operationId, network_fee: 0, service_fee: fee, confirmed: internal, tx_hash: txOutput.hash, internal: false }
@ -183,11 +184,11 @@ export default class {
} }
try { try {
await this.storage.paymentStorage.FlagInvoiceAsPaid(userInvoice, amount, fee, internal, tx) await this.storage.paymentStorage.FlagInvoiceAsPaid(userInvoice, amount, fee, internal, tx)
this.storage.eventsLog.LogEvent({ type: 'invoice_paid', userId: userInvoice.user.user_id, appId: userInvoice.linkedApplication.app_id, appUserId: "", balance: userInvoice.user.balance_sats, data: paymentRequest, amount })
await this.storage.userStorage.IncrementUserBalance(userInvoice.user.user_id, amount - fee, userInvoice.invoice, tx) await this.storage.userStorage.IncrementUserBalance(userInvoice.user.user_id, amount - fee, userInvoice.invoice, tx)
if (fee > 0) { if (fee > 0) {
await this.storage.userStorage.IncrementUserBalance(userInvoice.linkedApplication.owner.user_id, fee, 'fees', tx) await this.storage.userStorage.IncrementUserBalance(userInvoice.linkedApplication.owner.user_id, fee, 'fees', tx)
} }
this.storage.eventsLog.LogEvent({ type: 'invoice_paid', userId: userInvoice.user.user_id, appId: userInvoice.linkedApplication.app_id, appUserId: "", balance: userInvoice.user.balance_sats, data: paymentRequest, amount })
await this.triggerPaidCallback(log, userInvoice.callbackUrl) await this.triggerPaidCallback(log, userInvoice.callbackUrl)
const operationId = `${Types.UserOperationType.INCOMING_INVOICE}-${userInvoice.serial_id}` const operationId = `${Types.UserOperationType.INCOMING_INVOICE}-${userInvoice.serial_id}`
const op = { amount, paidAtUnix: Date.now() / 1000, inbound: true, type: Types.UserOperationType.INCOMING_INVOICE, identifier: userInvoice.invoice, operationId, network_fee: 0, service_fee: fee, confirmed: true, tx_hash: "", internal } const op = { amount, paidAtUnix: Date.now() / 1000, inbound: true, type: Types.UserOperationType.INCOMING_INVOICE, identifier: userInvoice.invoice, operationId, network_fee: 0, service_fee: fee, confirmed: true, tx_hash: "", internal }
@ -305,6 +306,9 @@ export default class {
throw new Error("sanity check on balance failed, expected: " + u.updatedBalance + " found: " + user.balance_sats) throw new Error("sanity check on balance failed, expected: " + u.updatedBalance + " found: " + user.balance_sats)
} }
})) }))
const total = await this.storage.paymentStorage.GetTotalUsersBalance()
return total || 0
} }
checkUserEntry(e: LoggedEvent, u: { ts: number, updatedBalance: number } | undefined) { checkUserEntry(e: LoggedEvent, u: { ts: number, updatedBalance: number } | undefined) {

View file

@ -144,7 +144,7 @@ export default class {
async PayInvoice(userId: string, req: Types.PayInvoiceRequest, linkedApplication: Application): Promise<Types.PayInvoiceResponse> { async PayInvoice(userId: string, req: Types.PayInvoiceRequest, linkedApplication: Application): Promise<Types.PayInvoiceResponse> {
this.log("paying invoice", req.invoice, "for user", userId, "with amount", req.amount) this.log("paying invoice", req.invoice, "for user", userId, "with amount", req.amount)
this.WatchdogCheck() await this.WatchdogCheck()
const decoded = await this.lnd.DecodeInvoice(req.invoice) const decoded = await this.lnd.DecodeInvoice(req.invoice)
if (decoded.numSatoshis !== 0 && req.amount !== 0) { if (decoded.numSatoshis !== 0 && req.amount !== 0) {
throw new Error("invoice has value, do not provide amount the the request") throw new Error("invoice has value, do not provide amount the the request")
@ -201,7 +201,7 @@ export default class {
async PayAddress(ctx: Types.UserContext, req: Types.PayAddressRequest): Promise<Types.PayAddressResponse> { async PayAddress(ctx: Types.UserContext, req: Types.PayAddressRequest): Promise<Types.PayAddressResponse> {
throw new Error("address payment currently disabled, use Lightning instead") throw new Error("address payment currently disabled, use Lightning instead")
this.WatchdogCheck() await this.WatchdogCheck()
const { blockHeight } = await this.lnd.GetInfo() const { blockHeight } = await this.lnd.GetInfo()
const app = await this.storage.applicationStorage.GetApplication(ctx.app_id) const app = await this.storage.applicationStorage.GetApplication(ctx.app_id)
const serviceFee = this.getServiceFee(Types.UserOperationType.OUTGOING_TX, req.amoutSats, false) const serviceFee = this.getServiceFee(Types.UserOperationType.OUTGOING_TX, req.amoutSats, false)