From af9d8ed00baa1cf184884fec10a4afd304616700 Mon Sep 17 00:00:00 2001 From: Bernd Date: Mon, 2 Feb 2026 09:44:51 +0100 Subject: [PATCH 1/5] Remove Rootstock integration and add generic EVM balance monitoring - Remove Rootstock blockchain integration (client, service, module, config, enum) - Add database-configurable EVM balance monitoring system - Support monitoring of native coins and ERC20 tokens for any EVM chain - Add initial configuration for Ethereum, Polygon, and Citrea - Keep existing Citrea balance tracking in monitoring_balance for now --- .../1769698161000-removeRootstockBalance.js | 14 + .../1769700099000-addEvmBalanceMonitoring.js | 74 ++++ src/config/config.ts | 5 - .../blockchain/blockchain.module.ts | 2 - .../blockchain/citrea/citrea-client.ts | 53 ++- .../blockchain/rootstock/rootstock-client.ts | 40 --- .../blockchain/rootstock/rootstock.module.ts | 12 - .../blockchain/rootstock/rootstock.service.ts | 26 -- .../blockchain/shared/evm/evm-client.ts | 165 +++++---- src/shared/enums/blockchain.enum.ts | 1 - .../alchemy/alchemy-network-mapper.ts | 104 +++--- .../monitoring/dto/monitoring.dto.ts | 23 ++ .../entities/monitoring-balance.entity.ts | 196 +++++----- .../entities/monitoring-evm-balance.entity.ts | 55 +++ .../monitoring/monitoring.module.ts | 63 ++-- .../monitoring-evm-balance.repository.ts | 28 ++ .../services/monitoring-evm.service.ts | 92 +++++ .../monitoring/services/monitoring.service.ts | 340 +++++++++--------- 18 files changed, 744 insertions(+), 549 deletions(-) create mode 100644 migration/1769698161000-removeRootstockBalance.js create mode 100644 migration/1769700099000-addEvmBalanceMonitoring.js delete mode 100644 src/integration/blockchain/rootstock/rootstock-client.ts delete mode 100644 src/integration/blockchain/rootstock/rootstock.module.ts delete mode 100644 src/integration/blockchain/rootstock/rootstock.service.ts create mode 100644 src/subdomains/monitoring/dto/monitoring.dto.ts create mode 100644 src/subdomains/monitoring/entities/monitoring-evm-balance.entity.ts create mode 100644 src/subdomains/monitoring/repositories/monitoring-evm-balance.repository.ts create mode 100644 src/subdomains/monitoring/services/monitoring-evm.service.ts diff --git a/migration/1769698161000-removeRootstockBalance.js b/migration/1769698161000-removeRootstockBalance.js new file mode 100644 index 00000000..60db355c --- /dev/null +++ b/migration/1769698161000-removeRootstockBalance.js @@ -0,0 +1,14 @@ +const { MigrationInterface, QueryRunner } = require("typeorm"); + +module.exports = class removeRootstockBalance1769698161000 { + name = 'removeRootstockBalance1769698161000' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "monitoring_balance" DROP CONSTRAINT "DF_e6e6c92e451a1c8873820c88148"`); + await queryRunner.query(`ALTER TABLE "monitoring_balance" DROP COLUMN "rootstockBalance"`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "monitoring_balance" ADD "rootstockBalance" float NOT NULL CONSTRAINT "DF_e6e6c92e451a1c8873820c88148" DEFAULT 0`); + } +} diff --git a/migration/1769700099000-addEvmBalanceMonitoring.js b/migration/1769700099000-addEvmBalanceMonitoring.js new file mode 100644 index 00000000..81897085 --- /dev/null +++ b/migration/1769700099000-addEvmBalanceMonitoring.js @@ -0,0 +1,74 @@ +const { MigrationInterface, QueryRunner } = require("typeorm"); + +module.exports = class addEvmBalanceMonitoring1769700099000 { + name = 'addEvmBalanceMonitoring1769700099000' + + async up(queryRunner) { + // Create new monitoring_evm_balance table + await queryRunner.query(` + CREATE TABLE "monitoring_evm_balance" ( + "id" int NOT NULL IDENTITY(1,1), + "created" datetime2 NOT NULL CONSTRAINT "DF_monitoring_evm_balance_created" DEFAULT getdate(), + "updated" datetime2 NOT NULL CONSTRAINT "DF_monitoring_evm_balance_updated" DEFAULT getdate(), + "blockchain" varchar(50) NOT NULL, + "nativeSymbol" varchar(10) NOT NULL, + "nativeBalance" float NOT NULL CONSTRAINT "DF_monitoring_evm_balance_nativeBalance" DEFAULT 0, + "tokenBalances" nvarchar(max), + CONSTRAINT "PK_monitoring_evm_balance" PRIMARY KEY ("id") + ) + `); + + await queryRunner.query(`CREATE INDEX "IDX_monitoring_evm_balance_blockchain" ON "monitoring_evm_balance" ("blockchain")`); + + // Insert EVM token configurations into monitoring table + const evmConfigs = [ + { + name: 'ethereum', + config: { + nativeSymbol: 'ETH', + tokens: [ + { symbol: 'USDT', address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', decimals: 6 }, + { symbol: 'USDC', address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', decimals: 6 }, + { symbol: 'WBTC', address: '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599', decimals: 8 }, + ], + }, + }, + { + name: 'polygon', + config: { + nativeSymbol: 'MATIC', + tokens: [ + { symbol: 'USDT', address: '0xc2132D05D31c914a87C6611C10748AEb04B58e8F', decimals: 6 }, + ], + }, + }, + { + name: 'citrea', + config: { + nativeSymbol: 'cBTC', + tokens: [ + { symbol: 'JUSD', address: '0x0987D3720D38847ac6dBB9D025B9dE892a3CA35C', decimals: 18 }, + { symbol: 'WBTCe', address: '0xDF240DC08B0FdaD1d93b74b5048871232f6BEA3d', decimals: 8 }, + ], + }, + }, + ]; + + for (const { name, config } of evmConfigs) { + const value = JSON.stringify(config).replace(/'/g, "''"); + await queryRunner.query(` + INSERT INTO "monitoring" ("type", "name", "value", "created", "updated") + VALUES ('evm_token_config', '${name}', '${value}', GETDATE(), GETDATE()) + `); + } + } + + async down(queryRunner) { + // Remove EVM token configurations from monitoring table + await queryRunner.query(`DELETE FROM "monitoring" WHERE "type" = 'evm_token_config'`); + + // Drop monitoring_evm_balance table + await queryRunner.query(`DROP INDEX "IDX_monitoring_evm_balance_blockchain" ON "monitoring_evm_balance"`); + await queryRunner.query(`DROP TABLE "monitoring_evm_balance"`); + } +} diff --git a/src/config/config.ts b/src/config/config.ts index a3d53e86..16403ab4 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -148,11 +148,6 @@ export class Configuration { chainId: +(process.env.BASE_CHAIN_ID ?? -1), walletAddress: process.env.EVM_PAYMENT_ADDRESS ?? '', }, - rootstock: { - gatewayUrl: process.env.ROOTSTOCK_GATEWAY_URL ?? '', - apiKey: process.env.ALCHEMY_API_KEY ?? '', - chainId: +(process.env.ROOTSTOCK_CHAIN_ID ?? -1), - }, citrea: { gatewayUrl: process.env.CITREA_GATEWAY_URL ?? '', chainId: +(process.env.CITREA_CHAIN_ID ?? -1), diff --git a/src/integration/blockchain/blockchain.module.ts b/src/integration/blockchain/blockchain.module.ts index 5f57e745..5c3549da 100644 --- a/src/integration/blockchain/blockchain.module.ts +++ b/src/integration/blockchain/blockchain.module.ts @@ -7,7 +7,6 @@ import { EthereumModule } from './ethereum/ethereum.module'; import { LightningModule } from './lightning/lightning.module'; import { OptimismModule } from './optimism/optimism.module'; import { PolygonModule } from './polygon/polygon.module'; -import { RootstockModule } from './rootstock/rootstock.module'; import { CryptoService } from './services/crypto.service'; import { UmaModule } from './uma/uma.module'; @@ -21,7 +20,6 @@ import { UmaModule } from './uma/uma.module'; OptimismModule, PolygonModule, BaseModule, - RootstockModule, CitreaModule, ], controllers: [], diff --git a/src/integration/blockchain/citrea/citrea-client.ts b/src/integration/blockchain/citrea/citrea-client.ts index a4025473..167d2271 100644 --- a/src/integration/blockchain/citrea/citrea-client.ts +++ b/src/integration/blockchain/citrea/citrea-client.ts @@ -1,27 +1,26 @@ -import { Config } from 'src/config/config'; -import { EvmUtil } from 'src/subdomains/evm/evm.util'; -import { AssetTransferEntity } from 'src/subdomains/master-data/asset/entities/asset-transfer.entity'; -import { LightningHelper } from '../lightning/lightning-helper'; -import { EvmTokenBalance } from '../shared/evm/dto/evm-token-balance.dto'; -import { EvmClient, EvmClientParams } from '../shared/evm/evm-client'; - -export class CitreaClient extends EvmClient { - constructor(private readonly params: EvmClientParams) { - super(params); - } - - async getNativeCoinBalance(): Promise { - const walletAddress = EvmUtil.createWallet({ seed: Config.evm.walletSeed, index: 0 }).address; - - const balance = await this.provider.getBalance(walletAddress); - return LightningHelper.btcToSat(EvmUtil.fromWeiAmount(balance.toString())); - } - - async getTokenBalance(_asset: AssetTransferEntity): Promise { - throw new Error('Method not implemented.'); - } - - async getTokenBalances(_assets: AssetTransferEntity[]): Promise { - throw new Error('Method not implemented.'); - } -} +import { GetConfig } from 'src/config/config'; +import { EvmUtil } from 'src/subdomains/evm/evm.util'; +import { LightningHelper } from '../lightning/lightning-helper'; +import { EvmClient, EvmClientParams } from '../shared/evm/evm-client'; + +export class CitreaClient extends EvmClient { + private readonly walletAddress: string; + + constructor(params: EvmClientParams) { + super(params); + + this.walletAddress = EvmUtil.createWallet({ seed: GetConfig().evm.walletSeed, index: 0 }).address; + } + + async getNativeCoinBalance(): Promise { + const balance = await this.provider.getBalance(this.walletAddress); + return LightningHelper.btcToSat(EvmUtil.fromWeiAmount(balance.toString())); + } + + async getTokenBalanceByAddress(tokenAddress: string, decimals: number): Promise { + const contract = this.getERC20ContractForDex(tokenAddress); + const balance = await contract.balanceOf(this.walletAddress); + + return EvmUtil.fromWeiAmount(balance, decimals); + } +} diff --git a/src/integration/blockchain/rootstock/rootstock-client.ts b/src/integration/blockchain/rootstock/rootstock-client.ts deleted file mode 100644 index 0a551e00..00000000 --- a/src/integration/blockchain/rootstock/rootstock-client.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Config } from 'src/config/config'; -import { EvmUtil } from 'src/subdomains/evm/evm.util'; -import { AssetTransferEntity } from 'src/subdomains/master-data/asset/entities/asset-transfer.entity'; -import { LightningHelper } from '../lightning/lightning-helper'; -import { EvmTokenBalance } from '../shared/evm/dto/evm-token-balance.dto'; -import { EvmClient, EvmClientParams } from '../shared/evm/evm-client'; - -export class RootstockClient extends EvmClient { - constructor(private readonly params: EvmClientParams) { - super(params); - } - - async getNativeCoinBalance(): Promise { - const http = this.params.http; - if (!http) throw new Error('No HTTP found'); - - const url = `${this.params.gatewayUrl}/${this.params.apiKey ?? ''}`; - - const walletAddress = EvmUtil.createWallet({ seed: Config.evm.walletSeed, index: 0 }).address; - - const balanceResult = await http - .post<{ result: number }>(url, { - id: 1, - jsonrpc: '2.0', - method: 'eth_getBalance', - params: [walletAddress, 'latest'], - }) - .then((r) => r.result); - - return LightningHelper.btcToSat(EvmUtil.fromWeiAmount(balanceResult)); - } - - async getTokenBalance(_asset: AssetTransferEntity): Promise { - throw new Error('Method not implemented.'); - } - - async getTokenBalances(_assets: AssetTransferEntity[]): Promise { - throw new Error('Method not implemented.'); - } -} diff --git a/src/integration/blockchain/rootstock/rootstock.module.ts b/src/integration/blockchain/rootstock/rootstock.module.ts deleted file mode 100644 index 98a28a33..00000000 --- a/src/integration/blockchain/rootstock/rootstock.module.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Module } from '@nestjs/common'; -import { SharedModule } from 'src/shared/shared.module'; -import { AlchemyModule } from 'src/subdomains/alchemy/alchemy.module'; -import { EvmRegistryModule } from '../shared/evm/registry/evm-registry.module'; -import { RootstockService } from './rootstock.service'; - -@Module({ - imports: [SharedModule, EvmRegistryModule, AlchemyModule], - providers: [RootstockService], - exports: [], -}) -export class RootstockModule {} diff --git a/src/integration/blockchain/rootstock/rootstock.service.ts b/src/integration/blockchain/rootstock/rootstock.service.ts deleted file mode 100644 index 015d3c5d..00000000 --- a/src/integration/blockchain/rootstock/rootstock.service.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { GetConfig } from 'src/config/config'; -import { Blockchain } from 'src/shared/enums/blockchain.enum'; -import { HttpService } from 'src/shared/services/http.service'; -import { AlchemyService } from 'src/subdomains/alchemy/services/alchemy.service'; -import { EvmService } from '../shared/evm/evm.service'; -import { RootstockClient } from './rootstock-client'; - -@Injectable() -export class RootstockService extends EvmService { - constructor(http: HttpService, alchemyService: AlchemyService) { - const { gatewayUrl, apiKey, chainId } = GetConfig().blockchain.rootstock; - - super(RootstockClient, { - http: http, - alchemyService, - gatewayUrl, - apiKey, - chainId, - }); - } - - get blockchain(): Blockchain { - return Blockchain.ROOTSTOCK; - } -} diff --git a/src/integration/blockchain/shared/evm/evm-client.ts b/src/integration/blockchain/shared/evm/evm-client.ts index 222cf6e1..87fab56f 100644 --- a/src/integration/blockchain/shared/evm/evm-client.ts +++ b/src/integration/blockchain/shared/evm/evm-client.ts @@ -1,79 +1,86 @@ -import { Token } from '@uniswap/sdk-core'; -import { Contract, ethers } from 'ethers'; -import { Config } from 'src/config/config'; -import { HttpService } from 'src/shared/services/http.service'; -import { AsyncCache } from 'src/shared/utils/async-cache'; -import { AlchemyService } from 'src/subdomains/alchemy/services/alchemy.service'; -import { EvmUtil } from 'src/subdomains/evm/evm.util'; -import { AssetTransferEntity } from 'src/subdomains/master-data/asset/entities/asset-transfer.entity'; -import ERC20_ABI from './abi/erc20.abi.json'; -import { EvmTokenBalance } from './dto/evm-token-balance.dto'; - -export interface EvmClientParams { - alchemyService: AlchemyService; - gatewayUrl: string; - apiKey: string; - chainId: number; - http?: HttpService; -} - -export abstract class EvmClient { - private readonly alchemyService: AlchemyService; - private readonly chainId: number; - - protected readonly provider: ethers.providers.JsonRpcProvider; - private readonly tokens = new AsyncCache(); - - constructor(params: EvmClientParams) { - this.alchemyService = params.alchemyService; - this.chainId = params.chainId; - - const url = `${params.gatewayUrl}/${params.apiKey ?? ''}`; - this.provider = new ethers.providers.JsonRpcProvider(url); - } - - async getNativeCoinBalance(): Promise { - const balance = await this.alchemyService.getNativeCoinBalance(this.chainId, Config.payment.evmAddress); - - return EvmUtil.fromWeiAmount(balance); - } - - async getTokenBalance(asset: AssetTransferEntity): Promise { - const evmTokenBalances = await this.getTokenBalances([asset]); - - return evmTokenBalances[0]?.balance ?? 0; - } - - async getTokenBalances(assets: AssetTransferEntity[]): Promise { - const evmTokenBalances: EvmTokenBalance[] = []; - - const tokenBalances = await this.alchemyService.getTokenBalances(this.chainId, Config.payment.evmAddress, assets); - - for (const tokenBalance of tokenBalances) { - const token = await this.getTokenByAddress(tokenBalance.contractAddress); - const balance = EvmUtil.fromWeiAmount(tokenBalance.tokenBalance ?? 0, token.decimals); - - evmTokenBalances.push({ contractAddress: tokenBalance.contractAddress, balance: balance }); - } - - return evmTokenBalances; - } - - // --- PRIVATE HELPER METHODS --- // - - private async getTokenByAddress(address: string): Promise { - const contract = this.getERC20ContractForDex(address); - return this.getTokenByContract(contract); - } - - protected getERC20ContractForDex(tokenAddress: string): Contract { - return new ethers.Contract(tokenAddress, ERC20_ABI, this.provider); - } - - protected async getTokenByContract(contract: Contract): Promise { - return this.tokens.get( - contract.address, - async () => new Token(this.chainId, contract.address, await contract.decimals()), - ); - } -} +import { Token } from '@uniswap/sdk-core'; +import { Contract, ethers } from 'ethers'; +import { Config } from 'src/config/config'; +import { HttpService } from 'src/shared/services/http.service'; +import { AsyncCache } from 'src/shared/utils/async-cache'; +import { AlchemyService } from 'src/subdomains/alchemy/services/alchemy.service'; +import { EvmUtil } from 'src/subdomains/evm/evm.util'; +import { AssetTransferEntity } from 'src/subdomains/master-data/asset/entities/asset-transfer.entity'; +import ERC20_ABI from './abi/erc20.abi.json'; +import { EvmTokenBalance } from './dto/evm-token-balance.dto'; + +export interface EvmClientParams { + alchemyService: AlchemyService; + gatewayUrl: string; + apiKey: string; + chainId: number; + http?: HttpService; +} + +export abstract class EvmClient { + private readonly alchemyService: AlchemyService; + private readonly chainId: number; + + protected readonly provider: ethers.providers.JsonRpcProvider; + private readonly tokens = new AsyncCache(); + + constructor(params: EvmClientParams) { + this.alchemyService = params.alchemyService; + this.chainId = params.chainId; + + const url = `${params.gatewayUrl}/${params.apiKey ?? ''}`; + this.provider = new ethers.providers.JsonRpcProvider(url); + } + + async getNativeCoinBalance(): Promise { + const balance = await this.alchemyService.getNativeCoinBalance(this.chainId, Config.payment.evmAddress); + + return EvmUtil.fromWeiAmount(balance); + } + + async getTokenBalance(asset: AssetTransferEntity): Promise { + const evmTokenBalances = await this.getTokenBalances([asset]); + + return evmTokenBalances[0]?.balance ?? 0; + } + + async getTokenBalances(assets: AssetTransferEntity[]): Promise { + const evmTokenBalances: EvmTokenBalance[] = []; + + const tokenBalances = await this.alchemyService.getTokenBalances(this.chainId, Config.payment.evmAddress, assets); + + for (const tokenBalance of tokenBalances) { + const token = await this.getTokenByAddress(tokenBalance.contractAddress); + const balance = EvmUtil.fromWeiAmount(tokenBalance.tokenBalance ?? 0, token.decimals); + + evmTokenBalances.push({ contractAddress: tokenBalance.contractAddress, balance: balance }); + } + + return evmTokenBalances; + } + + async getTokenBalanceByAddress(tokenAddress: string, decimals: number): Promise { + const contract = this.getERC20ContractForDex(tokenAddress); + const balance = await contract.balanceOf(Config.payment.evmAddress); + + return EvmUtil.fromWeiAmount(balance, decimals); + } + + // --- PRIVATE HELPER METHODS --- // + + private async getTokenByAddress(address: string): Promise { + const contract = this.getERC20ContractForDex(address); + return this.getTokenByContract(contract); + } + + protected getERC20ContractForDex(tokenAddress: string): Contract { + return new ethers.Contract(tokenAddress, ERC20_ABI, this.provider); + } + + protected async getTokenByContract(contract: Contract): Promise { + return this.tokens.get( + contract.address, + async () => new Token(this.chainId, contract.address, await contract.decimals()), + ); + } +} diff --git a/src/shared/enums/blockchain.enum.ts b/src/shared/enums/blockchain.enum.ts index 32455de9..9147c118 100644 --- a/src/shared/enums/blockchain.enum.ts +++ b/src/shared/enums/blockchain.enum.ts @@ -6,6 +6,5 @@ export enum Blockchain { ARBITRUM = 'arbitrum', POLYGON = 'polygon', BASE = 'base', - ROOTSTOCK = 'rootstock', CITREA = 'citrea', } diff --git a/src/subdomains/alchemy/alchemy-network-mapper.ts b/src/subdomains/alchemy/alchemy-network-mapper.ts index 0a8214b1..e02dba76 100644 --- a/src/subdomains/alchemy/alchemy-network-mapper.ts +++ b/src/subdomains/alchemy/alchemy-network-mapper.ts @@ -1,54 +1,50 @@ -import { Network } from 'alchemy-sdk'; -import { GetConfig } from 'src/config/config'; -import { Blockchain } from 'src/shared/enums/blockchain.enum'; - -export class AlchemyNetworkMapper { - private static blockchainConfig = GetConfig().blockchain; - - private static readonly blockchainToChainIdMap = new Map([ - [Blockchain.ETHEREUM, this.blockchainConfig.ethereum.chainId], - [Blockchain.ARBITRUM, this.blockchainConfig.arbitrum.chainId], - [Blockchain.OPTIMISM, this.blockchainConfig.optimism.chainId], - [Blockchain.POLYGON, this.blockchainConfig.polygon.chainId], - [Blockchain.BASE, this.blockchainConfig.base.chainId], - [Blockchain.ROOTSTOCK, this.blockchainConfig.rootstock.chainId], - ]); - - private static readonly chainIdToNetworkMap = new Map([ - [1, Network.ETH_MAINNET], - [5, Network.ETH_GOERLI], - [11155111, Network.ETH_SEPOLIA], - - [42161, Network.ARB_MAINNET], - [421613, Network.ARB_GOERLI], - [421614, Network.ARB_SEPOLIA], - - [10, Network.OPT_MAINNET], - [420, Network.OPT_GOERLI], - [11155420, Network.OPT_SEPOLIA], - - [137, Network.MATIC_MAINNET], - [80001, Network.MATIC_MUMBAI], - [80002, Network.MATIC_AMOY], - - [8453, Network.BASE_MAINNET], - [84531, Network.BASE_GOERLI], - [84532, Network.BASE_SEPOLIA], - - [30, Network.ROOTSTOCK_MAINNET], - [31, Network.ROOTSTOCK_TESTNET], - ]); - - static getChainId(blockchain: Blockchain): number | undefined { - return this.blockchainToChainIdMap.get(blockchain); - } - - static toAlchemyNetworkByChainId(chainId: number): Network | undefined { - return this.chainIdToNetworkMap.get(chainId); - } - - static toAlchemyNetworkByBlockchain(blockchain: Blockchain): Network | undefined { - const chainId = this.blockchainToChainIdMap.get(blockchain); - return this.chainIdToNetworkMap.get(chainId ?? -1); - } -} +import { Network } from 'alchemy-sdk'; +import { GetConfig } from 'src/config/config'; +import { Blockchain } from 'src/shared/enums/blockchain.enum'; + +export class AlchemyNetworkMapper { + private static blockchainConfig = GetConfig().blockchain; + + private static readonly blockchainToChainIdMap = new Map([ + [Blockchain.ETHEREUM, this.blockchainConfig.ethereum.chainId], + [Blockchain.ARBITRUM, this.blockchainConfig.arbitrum.chainId], + [Blockchain.OPTIMISM, this.blockchainConfig.optimism.chainId], + [Blockchain.POLYGON, this.blockchainConfig.polygon.chainId], + [Blockchain.BASE, this.blockchainConfig.base.chainId], + ]); + + private static readonly chainIdToNetworkMap = new Map([ + [1, Network.ETH_MAINNET], + [5, Network.ETH_GOERLI], + [11155111, Network.ETH_SEPOLIA], + + [42161, Network.ARB_MAINNET], + [421613, Network.ARB_GOERLI], + [421614, Network.ARB_SEPOLIA], + + [10, Network.OPT_MAINNET], + [420, Network.OPT_GOERLI], + [11155420, Network.OPT_SEPOLIA], + + [137, Network.MATIC_MAINNET], + [80001, Network.MATIC_MUMBAI], + [80002, Network.MATIC_AMOY], + + [8453, Network.BASE_MAINNET], + [84531, Network.BASE_GOERLI], + [84532, Network.BASE_SEPOLIA], + ]); + + static getChainId(blockchain: Blockchain): number | undefined { + return this.blockchainToChainIdMap.get(blockchain); + } + + static toAlchemyNetworkByChainId(chainId: number): Network | undefined { + return this.chainIdToNetworkMap.get(chainId); + } + + static toAlchemyNetworkByBlockchain(blockchain: Blockchain): Network | undefined { + const chainId = this.blockchainToChainIdMap.get(blockchain); + return this.chainIdToNetworkMap.get(chainId ?? -1); + } +} diff --git a/src/subdomains/monitoring/dto/monitoring.dto.ts b/src/subdomains/monitoring/dto/monitoring.dto.ts new file mode 100644 index 00000000..3d6562ec --- /dev/null +++ b/src/subdomains/monitoring/dto/monitoring.dto.ts @@ -0,0 +1,23 @@ +export interface EvmChainConfig { + nativeSymbol: string; + tokens: TokenConfig[]; +} + +export interface TokenConfig { + symbol: string; + address: string; + decimals: number; +} + +export interface MonitoringBlockchainBalance { + onchainBalance: number; + lndOnchainBalance: number; + lightningBalance: number; + citreaBalance: number; +} + +export interface EvmTokenBalanceJson { + symbol: string; + address: string; + balance: number; +} diff --git a/src/subdomains/monitoring/entities/monitoring-balance.entity.ts b/src/subdomains/monitoring/entities/monitoring-balance.entity.ts index b8b289cb..7aa0b950 100644 --- a/src/subdomains/monitoring/entities/monitoring-balance.entity.ts +++ b/src/subdomains/monitoring/entities/monitoring-balance.entity.ts @@ -1,104 +1,92 @@ -import { LightningHelper } from 'src/integration/blockchain/lightning/lightning-helper'; -import { IEntity } from 'src/shared/db/entity'; -import { AssetAccountEntity } from 'src/subdomains/master-data/asset/entities/asset-account.entity'; -import { Price } from 'src/subdomains/support/dto/price.dto'; -import { LightningWalletTotalBalanceDto } from 'src/subdomains/user/application/dto/lightning-wallet.dto'; -import { Column, Entity, ManyToOne } from 'typeorm'; - -export interface MonitoringBlockchainBalance { - onchainBalance: number; - lndOnchainBalance: number; - lightningBalance: number; - rootstockBalance: number; - citreaBalance: number; -} - -@Entity('monitoring_balance') -export class MonitoringBalanceEntity extends IEntity { - @ManyToOne(() => AssetAccountEntity, { eager: true }) - asset: AssetAccountEntity; - - @Column({ type: 'float', default: 0 }) - onchainBalance: number; - - @Column({ type: 'float', default: 0 }) - lndOnchainBalance: number; - - @Column({ type: 'float', default: 0 }) - lightningBalance: number; - - @Column({ type: 'float', default: 0 }) - rootstockBalance: number; - - @Column({ type: 'float', default: 0 }) - citreaBalance: number; - - @Column({ type: 'float', default: 0 }) - customerBalance: number; - - @Column({ type: 'float', default: 0 }) - assetPriceInCHF: number; - - @Column({ type: 'float', default: 0 }) - ldsBalance: number; - - @Column({ type: 'float', default: 0 }) - ldsBalanceInCHF: number; - - // --- FACTORY METHODS --- // - - static createAsBtcEntity( - blockchainBalance: MonitoringBlockchainBalance, - internalBalance: LightningWalletTotalBalanceDto, - customerBalance: LightningWalletTotalBalanceDto, - chfPrice: Price, - ): MonitoringBalanceEntity { - const entity = new MonitoringBalanceEntity(); - - entity.asset = { id: customerBalance.assetId } as AssetAccountEntity; - entity.onchainBalance = blockchainBalance.onchainBalance; - entity.lndOnchainBalance = blockchainBalance.lndOnchainBalance; - entity.lightningBalance = blockchainBalance.lightningBalance; - entity.rootstockBalance = blockchainBalance.rootstockBalance; - entity.citreaBalance = blockchainBalance.citreaBalance; - entity.customerBalance = customerBalance.totalBalance; - - entity.ldsBalance = - entity.onchainBalance + - entity.lndOnchainBalance + - entity.lightningBalance + - entity.rootstockBalance + - entity.citreaBalance + - internalBalance.totalBalance - - entity.customerBalance; - - entity.assetPriceInCHF = chfPrice.convert(1, 2); - entity.ldsBalanceInCHF = chfPrice.convert(LightningHelper.satToBtc(entity.ldsBalance), 2); - - return entity; - } - - static createAsChfEntity( - evmchainBalance: number, - customerBalance: LightningWalletTotalBalanceDto, - ): MonitoringBalanceEntity { - const entity = new MonitoringBalanceEntity(); - - entity.asset = { id: customerBalance.assetId } as AssetAccountEntity; - entity.lndOnchainBalance = evmchainBalance; - entity.lightningBalance = 0; - entity.customerBalance = customerBalance.totalBalance; - - entity.ldsBalance = entity.lndOnchainBalance + entity.lightningBalance - entity.customerBalance; - - entity.assetPriceInCHF = 1; - entity.ldsBalanceInCHF = entity.ldsBalance; - - return entity; - } - // --- ENTITY METHODS --- // - - checksum(): number { - return this.onchainBalance + this.lightningBalance + this.customerBalance; - } -} +import { LightningHelper } from 'src/integration/blockchain/lightning/lightning-helper'; +import { IEntity } from 'src/shared/db/entity'; +import { AssetAccountEntity } from 'src/subdomains/master-data/asset/entities/asset-account.entity'; +import { Price } from 'src/subdomains/support/dto/price.dto'; +import { LightningWalletTotalBalanceDto } from 'src/subdomains/user/application/dto/lightning-wallet.dto'; +import { Column, Entity, ManyToOne } from 'typeorm'; +import { MonitoringBlockchainBalance } from '../dto/monitoring.dto'; + +@Entity('monitoring_balance') +export class MonitoringBalanceEntity extends IEntity { + @ManyToOne(() => AssetAccountEntity, { eager: true }) + asset: AssetAccountEntity; + + @Column({ type: 'float', default: 0 }) + onchainBalance: number; + + @Column({ type: 'float', default: 0 }) + lndOnchainBalance: number; + + @Column({ type: 'float', default: 0 }) + lightningBalance: number; + + @Column({ type: 'float', default: 0 }) + citreaBalance: number; + + @Column({ type: 'float', default: 0 }) + customerBalance: number; + + @Column({ type: 'float', default: 0 }) + assetPriceInCHF: number; + + @Column({ type: 'float', default: 0 }) + ldsBalance: number; + + @Column({ type: 'float', default: 0 }) + ldsBalanceInCHF: number; + + // --- FACTORY METHODS --- // + + static createAsBtcEntity( + blockchainBalance: MonitoringBlockchainBalance, + internalBalance: LightningWalletTotalBalanceDto, + customerBalance: LightningWalletTotalBalanceDto, + chfPrice: Price, + ): MonitoringBalanceEntity { + const entity = new MonitoringBalanceEntity(); + + entity.asset = { id: customerBalance.assetId } as AssetAccountEntity; + entity.onchainBalance = blockchainBalance.onchainBalance; + entity.lndOnchainBalance = blockchainBalance.lndOnchainBalance; + entity.lightningBalance = blockchainBalance.lightningBalance; + entity.citreaBalance = blockchainBalance.citreaBalance; + entity.customerBalance = customerBalance.totalBalance; + + entity.ldsBalance = + entity.onchainBalance + + entity.lndOnchainBalance + + entity.lightningBalance + + entity.citreaBalance + + internalBalance.totalBalance - + entity.customerBalance; + + entity.assetPriceInCHF = chfPrice.convert(1, 2); + entity.ldsBalanceInCHF = chfPrice.convert(LightningHelper.satToBtc(entity.ldsBalance), 2); + + return entity; + } + + static createAsChfEntity( + evmchainBalance: number, + customerBalance: LightningWalletTotalBalanceDto, + ): MonitoringBalanceEntity { + const entity = new MonitoringBalanceEntity(); + + entity.asset = { id: customerBalance.assetId } as AssetAccountEntity; + entity.lndOnchainBalance = evmchainBalance; + entity.lightningBalance = 0; + entity.customerBalance = customerBalance.totalBalance; + + entity.ldsBalance = entity.lndOnchainBalance + entity.lightningBalance - entity.customerBalance; + + entity.assetPriceInCHF = 1; + entity.ldsBalanceInCHF = entity.ldsBalance; + + return entity; + } + // --- ENTITY METHODS --- // + + checksum(): number { + return this.onchainBalance + this.lightningBalance + this.citreaBalance + this.customerBalance; + } +} diff --git a/src/subdomains/monitoring/entities/monitoring-evm-balance.entity.ts b/src/subdomains/monitoring/entities/monitoring-evm-balance.entity.ts new file mode 100644 index 00000000..959afdda --- /dev/null +++ b/src/subdomains/monitoring/entities/monitoring-evm-balance.entity.ts @@ -0,0 +1,55 @@ +import { IEntity } from 'src/shared/db/entity'; +import { Blockchain } from 'src/shared/enums/blockchain.enum'; +import { Column, Entity, Index } from 'typeorm'; +import { EvmTokenBalanceJson } from '../dto/monitoring.dto'; + +@Entity('monitoring_evm_balance') +@Index(['blockchain'], { unique: false }) +export class MonitoringEvmBalanceEntity extends IEntity { + @Column({ type: 'varchar', length: 50 }) + blockchain: Blockchain; + + @Column({ type: 'varchar', length: 10 }) + nativeSymbol: string; + + @Column({ type: 'float', default: 0 }) + nativeBalance: number; + + @Column({ type: 'nvarchar', length: 'max', nullable: true }) + tokenBalances: string; + + // --- FACTORY METHOD --- // + + static create( + blockchain: Blockchain, + nativeSymbol: string, + nativeBalance: number, + tokens: EvmTokenBalanceJson[], + ): MonitoringEvmBalanceEntity { + const entity = new MonitoringEvmBalanceEntity(); + + entity.blockchain = blockchain; + entity.nativeSymbol = nativeSymbol; + entity.nativeBalance = nativeBalance; + entity.tokenBalances = JSON.stringify({ tokens }); + + return entity; + } + + // --- ENTITY METHODS --- // + + getTokenBalances(): EvmTokenBalanceJson[] { + if (!this.tokenBalances) return []; + + try { + const parsed = JSON.parse(this.tokenBalances) as { tokens: EvmTokenBalanceJson[] }; + return parsed.tokens ?? []; + } catch { + return []; + } + } + + checksum(): string { + return `${this.nativeBalance}-${this.tokenBalances}`; + } +} diff --git a/src/subdomains/monitoring/monitoring.module.ts b/src/subdomains/monitoring/monitoring.module.ts index 2ad8f850..e2b0bfe5 100644 --- a/src/subdomains/monitoring/monitoring.module.ts +++ b/src/subdomains/monitoring/monitoring.module.ts @@ -1,27 +1,36 @@ -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { BlockchainModule } from 'src/integration/blockchain/blockchain.module'; -import { EvmRegistryModule } from 'src/integration/blockchain/shared/evm/registry/evm-registry.module'; -import { AlchemyWebhookModule } from '../alchemy/alchemy-webhook.module'; -import { AssetModule } from '../master-data/asset/asset.module'; -import { PricingModule } from '../pricing/pricing.module'; -import { MonitoringBalanceEntity } from './entities/monitoring-balance.entity'; -import { MonitoringEntity } from './entities/monitoring.entity'; -import { MonitoringBalanceRepository } from './repositories/monitoring-balance.repository'; -import { MonitoringRepository } from './repositories/monitoring.repository'; -import { MonitoringService } from './services/monitoring.service'; - -@Module({ - imports: [ - TypeOrmModule.forFeature([MonitoringEntity, MonitoringBalanceEntity]), - PricingModule, - AssetModule, - BlockchainModule, - EvmRegistryModule, - AlchemyWebhookModule, - ], - controllers: [], - providers: [MonitoringRepository, MonitoringBalanceRepository, MonitoringService], - exports: [MonitoringService], -}) -export class MonitoringModule {} +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { BlockchainModule } from 'src/integration/blockchain/blockchain.module'; +import { EvmRegistryModule } from 'src/integration/blockchain/shared/evm/registry/evm-registry.module'; +import { AlchemyWebhookModule } from '../alchemy/alchemy-webhook.module'; +import { AssetModule } from '../master-data/asset/asset.module'; +import { PricingModule } from '../pricing/pricing.module'; +import { MonitoringBalanceEntity } from './entities/monitoring-balance.entity'; +import { MonitoringEvmBalanceEntity } from './entities/monitoring-evm-balance.entity'; +import { MonitoringEntity } from './entities/monitoring.entity'; +import { MonitoringBalanceRepository } from './repositories/monitoring-balance.repository'; +import { MonitoringEvmBalanceRepository } from './repositories/monitoring-evm-balance.repository'; +import { MonitoringRepository } from './repositories/monitoring.repository'; +import { MonitoringEvmService } from './services/monitoring-evm.service'; +import { MonitoringService } from './services/monitoring.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([MonitoringEntity, MonitoringBalanceEntity, MonitoringEvmBalanceEntity]), + PricingModule, + AssetModule, + BlockchainModule, + EvmRegistryModule, + AlchemyWebhookModule, + ], + controllers: [], + providers: [ + MonitoringRepository, + MonitoringBalanceRepository, + MonitoringEvmBalanceRepository, + MonitoringService, + MonitoringEvmService, + ], + exports: [MonitoringService, MonitoringEvmService], +}) +export class MonitoringModule {} diff --git a/src/subdomains/monitoring/repositories/monitoring-evm-balance.repository.ts b/src/subdomains/monitoring/repositories/monitoring-evm-balance.repository.ts new file mode 100644 index 00000000..562b3986 --- /dev/null +++ b/src/subdomains/monitoring/repositories/monitoring-evm-balance.repository.ts @@ -0,0 +1,28 @@ +import { Injectable } from '@nestjs/common'; +import { BaseRepository } from 'src/shared/db/base.repository'; +import { Blockchain } from 'src/shared/enums/blockchain.enum'; +import { EntityManager } from 'typeorm'; +import { MonitoringEvmBalanceEntity } from '../entities/monitoring-evm-balance.entity'; + +@Injectable() +export class MonitoringEvmBalanceRepository extends BaseRepository { + constructor(manager: EntityManager) { + super(MonitoringEvmBalanceEntity, manager); + } + + async saveIfBalanceDiff(entity: MonitoringEvmBalanceEntity): Promise { + const lastEntity = await this.getLastByBlockchain(entity.blockchain); + if (!lastEntity) return this.save(entity); + + if (entity.checksum() === lastEntity.checksum()) return lastEntity; + + return this.save(entity); + } + + async getLastByBlockchain(blockchain: Blockchain): Promise { + return this.findOne({ + where: { blockchain }, + order: { id: 'DESC' }, + }); + } +} diff --git a/src/subdomains/monitoring/services/monitoring-evm.service.ts b/src/subdomains/monitoring/services/monitoring-evm.service.ts new file mode 100644 index 00000000..c729e81b --- /dev/null +++ b/src/subdomains/monitoring/services/monitoring-evm.service.ts @@ -0,0 +1,92 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { Config, Process } from 'src/config/config'; +import { EvmRegistryService } from 'src/integration/blockchain/shared/evm/registry/evm-registry.service'; +import { Blockchain } from 'src/shared/enums/blockchain.enum'; +import { LightningLogger } from 'src/shared/services/lightning-logger'; +import { Lock } from 'src/shared/utils/lock'; +import { Equal } from 'typeorm'; +import { EvmChainConfig, EvmTokenBalanceJson } from '../dto/monitoring.dto'; +import { MonitoringEvmBalanceEntity } from '../entities/monitoring-evm-balance.entity'; +import { MonitoringEvmBalanceRepository } from '../repositories/monitoring-evm-balance.repository'; +import { MonitoringRepository } from '../repositories/monitoring.repository'; + +const EVM_TOKEN_CONFIG_TYPE = 'evm_token_config'; + +@Injectable() +export class MonitoringEvmService implements OnModuleInit { + private readonly logger = new LightningLogger(MonitoringEvmService); + + constructor( + private readonly evmRegistryService: EvmRegistryService, + private readonly monitoringRepository: MonitoringRepository, + private readonly monitoringEvmBalanceRepository: MonitoringEvmBalanceRepository, + ) {} + + onModuleInit() { + void this.processEvmBalances(); + } + + @Cron(CronExpression.EVERY_5_MINUTES) + @Lock(1800) + async processEvmBalances(): Promise { + if (Config.processDisabled(Process.UPDATE_WALLET_BALANCE)) return; + + const chainConfigs = await this.getEvmChainConfigs(); + + for (const [blockchain, config] of chainConfigs) { + try { + await this.processChainBalance(blockchain, config); + } catch (e) { + this.logger.error(`Error processing ${blockchain} balance`, e); + } + } + } + + private async getEvmChainConfigs(): Promise> { + const configs = await this.monitoringRepository.findBy({ + type: Equal(EVM_TOKEN_CONFIG_TYPE), + }); + + const result = new Map(); + + for (const config of configs) { + try { + const blockchain = config.name as Blockchain; + const parsed = JSON.parse(config.value) as EvmChainConfig; + result.set(blockchain, parsed); + } catch (e) { + this.logger.error(`Failed to parse EVM config for ${config.name}`, e); + } + } + + return result; + } + + private async processChainBalance(blockchain: Blockchain, config: EvmChainConfig): Promise { + const evmClient = this.evmRegistryService.getClient(blockchain); + + // Get native balance + const nativeBalance = await evmClient.getNativeCoinBalance(); + + // Get token balances + const tokenBalances: EvmTokenBalanceJson[] = []; + + for (const token of config.tokens) { + try { + const balance = await evmClient.getTokenBalanceByAddress(token.address, token.decimals); + tokenBalances.push({ + symbol: token.symbol, + address: token.address, + balance, + }); + } catch (e) { + this.logger.warn(`Failed to get ${token.symbol} balance on ${blockchain}: ${e.message}`); + } + } + + const entity = MonitoringEvmBalanceEntity.create(blockchain, config.nativeSymbol, nativeBalance, tokenBalances); + + await this.monitoringEvmBalanceRepository.saveIfBalanceDiff(entity); + } +} diff --git a/src/subdomains/monitoring/services/monitoring.service.ts b/src/subdomains/monitoring/services/monitoring.service.ts index 7776175b..4ca74455 100644 --- a/src/subdomains/monitoring/services/monitoring.service.ts +++ b/src/subdomains/monitoring/services/monitoring.service.ts @@ -1,172 +1,168 @@ -import { Injectable, InternalServerErrorException, OnModuleInit } from '@nestjs/common'; -import { BitcoinClient } from 'src/integration/blockchain/bitcoin/bitcoin-client'; -import { BitcoinService } from 'src/integration/blockchain/bitcoin/bitcoin.service'; -import { CitreaClient } from 'src/integration/blockchain/citrea/citrea-client'; -import { LndChannelDto } from 'src/integration/blockchain/lightning/dto/lnd.dto'; -import { LightningClient } from 'src/integration/blockchain/lightning/lightning-client'; -import { LightningService } from 'src/integration/blockchain/lightning/services/lightning.service'; -import { RootstockClient } from 'src/integration/blockchain/rootstock/rootstock-client'; -import { EvmRegistryService } from 'src/integration/blockchain/shared/evm/registry/evm-registry.service'; -import { Blockchain } from 'src/shared/enums/blockchain.enum'; -import { LightningLogger } from 'src/shared/services/lightning-logger'; -import { QueueHandler } from 'src/shared/utils/queue-handler'; -import { AssetService } from 'src/subdomains/master-data/asset/services/asset.service'; -import { CoinGeckoService } from 'src/subdomains/pricing/services/coingecko.service'; -import { LightningWalletTotalBalanceDto } from 'src/subdomains/user/application/dto/lightning-wallet.dto'; -import { MonitoringBalanceEntity, MonitoringBlockchainBalance } from '../entities/monitoring-balance.entity'; -import { MonitoringBalanceRepository } from '../repositories/monitoring-balance.repository'; -import { MonitoringRepository } from '../repositories/monitoring.repository'; - -@Injectable() -export class MonitoringService implements OnModuleInit { - private readonly logger = new LightningLogger(MonitoringService); - - private readonly bitcoinClient: BitcoinClient; - private readonly lightningClient: LightningClient; - private rootstockClient: RootstockClient; - private citreaClient: CitreaClient; - - private readonly processBalancesQueue: QueueHandler; - - constructor( - bitcoinservice: BitcoinService, - lightningService: LightningService, - private readonly coinGeckoService: CoinGeckoService, - private readonly assetService: AssetService, - private readonly evmRegistryService: EvmRegistryService, - private readonly monitoringRepository: MonitoringRepository, - private readonly monitoringBalanceRepository: MonitoringBalanceRepository, - ) { - this.bitcoinClient = bitcoinservice.getDefaultClient(); - this.lightningClient = lightningService.getDefaultClient(); - - this.processBalancesQueue = new QueueHandler(); - } - - onModuleInit() { - this.rootstockClient = this.evmRegistryService.getClient(Blockchain.ROOTSTOCK) as RootstockClient; - this.citreaClient = this.evmRegistryService.getClient(Blockchain.CITREA) as CitreaClient; - } - - // --- LIGHTNING --- // - - async processBalanceMonitoring( - internalBalances: LightningWalletTotalBalanceDto[], - customerBalances: LightningWalletTotalBalanceDto[], - ): Promise { - this.processBalancesQueue - .handle(async () => { - await this.processBalances(internalBalances, customerBalances); - await this.processChannels(); - }) - .catch((e) => { - this.logger.error('Error while processing new balances and channels', e); - }); - } - - private async processBalances( - internalBalances: LightningWalletTotalBalanceDto[], - customerBalances: LightningWalletTotalBalanceDto[], - ): Promise { - try { - const blockchainBalance = await this.getBlockchainBalances(); - - const btcAccountAsset = await this.assetService.getBtcAccountAssetOrThrow(); - const btcAccountAssetId = btcAccountAsset.id; - - const internalBtcBalance = internalBalances?.find((b) => b.assetId === btcAccountAssetId) ?? { - assetId: btcAccountAssetId, - totalBalance: 0, - }; - - const customerBtcBalance = customerBalances.find((b) => b.assetId === btcAccountAssetId) ?? { - assetId: btcAccountAssetId, - totalBalance: 0, - }; - - const customerFiatBalances = customerBalances.filter((b) => b.assetId !== btcAccountAssetId); - - await this.processBtcBalance(blockchainBalance, internalBtcBalance, customerBtcBalance); - await this.processFiatBalances(customerFiatBalances); - } catch (e) { - this.logger.error('Error while processing balances', e); - } - } - - private async processChannels(): Promise { - try { - const channels = await this.getChannels(); - - for (const channel of channels) { - const monitoringEntity = this.monitoringRepository.create({ - type: 'lightningchannel', - name: channel.remote_pubkey, - value: `${channel.capacity},${channel.local_balance},${channel.remote_balance}`, - }); - - await this.monitoringRepository.saveIfValueDiff(monitoringEntity); - } - } catch (e) { - this.logger.error('Error while processing channels', e); - } - } - - private async processBtcBalance( - blockchainBalance: MonitoringBlockchainBalance, - internalBtcBalance: LightningWalletTotalBalanceDto, - customerBtcBalance: LightningWalletTotalBalanceDto, - ) { - const chfPrice = await this.coinGeckoService.getPrice('BTC', 'CHF'); - if (!chfPrice.isValid) throw new InternalServerErrorException(`Invalid price from BTC to CHF`); - - const btcMonitoringEntity = MonitoringBalanceEntity.createAsBtcEntity( - blockchainBalance, - internalBtcBalance, - customerBtcBalance, - chfPrice, - ); - - await this.monitoringBalanceRepository.saveIfBalanceDiff(btcMonitoringEntity); - } - - private async processFiatBalances(customerFiatBalances: LightningWalletTotalBalanceDto[]) { - const zchfBalance = await this.getZchfBalance(); - - for (const customerFiatBalance of customerFiatBalances) { - if (customerFiatBalance.totalBalance) { - const fiatMonitoringEntity = MonitoringBalanceEntity.createAsChfEntity(zchfBalance, customerFiatBalance); - - await this.monitoringBalanceRepository.saveIfBalanceDiff(fiatMonitoringEntity); - } - } - } - - private async getZchfBalance(): Promise { - let balance = 0; - - const zchfTransferAssets = await this.assetService.getAllZchfTransferAssets(); - - for (const zchfTransferAsset of zchfTransferAssets) { - if (zchfTransferAsset.address) { - const evmClient = this.evmRegistryService.getClient(zchfTransferAsset.blockchain); - balance += await evmClient.getTokenBalance(zchfTransferAsset); - } - } - - return balance; - } - - private async getBlockchainBalances(): Promise { - return { - onchainBalance: await this.bitcoinClient.getWalletBalance(), - lndOnchainBalance: await this.lightningClient.getLndConfirmedWalletBalance(), - lightningBalance: await this.lightningClient.getLndLightningBalance(), - rootstockBalance: await this.rootstockClient.getNativeCoinBalance(), - citreaBalance: await this.citreaClient.getNativeCoinBalance(), - }; - } - - private async getChannels(): Promise { - return this.lightningClient.getChannels(); - } -} +import { Injectable, InternalServerErrorException, OnModuleInit } from '@nestjs/common'; +import { BitcoinClient } from 'src/integration/blockchain/bitcoin/bitcoin-client'; +import { BitcoinService } from 'src/integration/blockchain/bitcoin/bitcoin.service'; +import { CitreaClient } from 'src/integration/blockchain/citrea/citrea-client'; +import { LndChannelDto } from 'src/integration/blockchain/lightning/dto/lnd.dto'; +import { LightningClient } from 'src/integration/blockchain/lightning/lightning-client'; +import { LightningService } from 'src/integration/blockchain/lightning/services/lightning.service'; +import { EvmRegistryService } from 'src/integration/blockchain/shared/evm/registry/evm-registry.service'; +import { Blockchain } from 'src/shared/enums/blockchain.enum'; +import { LightningLogger } from 'src/shared/services/lightning-logger'; +import { QueueHandler } from 'src/shared/utils/queue-handler'; +import { AssetService } from 'src/subdomains/master-data/asset/services/asset.service'; +import { CoinGeckoService } from 'src/subdomains/pricing/services/coingecko.service'; +import { LightningWalletTotalBalanceDto } from 'src/subdomains/user/application/dto/lightning-wallet.dto'; +import { MonitoringBalanceEntity, MonitoringBlockchainBalance } from '../entities/monitoring-balance.entity'; +import { MonitoringBalanceRepository } from '../repositories/monitoring-balance.repository'; +import { MonitoringRepository } from '../repositories/monitoring.repository'; + +@Injectable() +export class MonitoringService implements OnModuleInit { + private readonly logger = new LightningLogger(MonitoringService); + + private readonly bitcoinClient: BitcoinClient; + private readonly lightningClient: LightningClient; + private citreaClient: CitreaClient; + + private readonly processBalancesQueue: QueueHandler; + + constructor( + bitcoinservice: BitcoinService, + lightningService: LightningService, + private readonly coinGeckoService: CoinGeckoService, + private readonly assetService: AssetService, + private readonly evmRegistryService: EvmRegistryService, + private readonly monitoringRepository: MonitoringRepository, + private readonly monitoringBalanceRepository: MonitoringBalanceRepository, + ) { + this.bitcoinClient = bitcoinservice.getDefaultClient(); + this.lightningClient = lightningService.getDefaultClient(); + + this.processBalancesQueue = new QueueHandler(); + } + + onModuleInit() { + this.citreaClient = this.evmRegistryService.getClient(Blockchain.CITREA) as CitreaClient; + } + + // --- LIGHTNING --- // + + async processBalanceMonitoring( + internalBalances: LightningWalletTotalBalanceDto[], + customerBalances: LightningWalletTotalBalanceDto[], + ): Promise { + this.processBalancesQueue + .handle(async () => { + await this.processBalances(internalBalances, customerBalances); + await this.processChannels(); + }) + .catch((e) => { + this.logger.error('Error while processing new balances and channels', e); + }); + } + + private async processBalances( + internalBalances: LightningWalletTotalBalanceDto[], + customerBalances: LightningWalletTotalBalanceDto[], + ): Promise { + try { + const blockchainBalance = await this.getBlockchainBalances(); + + const btcAccountAsset = await this.assetService.getBtcAccountAssetOrThrow(); + const btcAccountAssetId = btcAccountAsset.id; + + const internalBtcBalance = internalBalances?.find((b) => b.assetId === btcAccountAssetId) ?? { + assetId: btcAccountAssetId, + totalBalance: 0, + }; + + const customerBtcBalance = customerBalances.find((b) => b.assetId === btcAccountAssetId) ?? { + assetId: btcAccountAssetId, + totalBalance: 0, + }; + + const customerFiatBalances = customerBalances.filter((b) => b.assetId !== btcAccountAssetId); + + await this.processBtcBalance(blockchainBalance, internalBtcBalance, customerBtcBalance); + await this.processFiatBalances(customerFiatBalances); + } catch (e) { + this.logger.error('Error while processing balances', e); + } + } + + private async processChannels(): Promise { + try { + const channels = await this.getChannels(); + + for (const channel of channels) { + const monitoringEntity = this.monitoringRepository.create({ + type: 'lightningchannel', + name: channel.remote_pubkey, + value: `${channel.capacity},${channel.local_balance},${channel.remote_balance}`, + }); + + await this.monitoringRepository.saveIfValueDiff(monitoringEntity); + } + } catch (e) { + this.logger.error('Error while processing channels', e); + } + } + + private async processBtcBalance( + blockchainBalance: MonitoringBlockchainBalance, + internalBtcBalance: LightningWalletTotalBalanceDto, + customerBtcBalance: LightningWalletTotalBalanceDto, + ) { + const chfPrice = await this.coinGeckoService.getPrice('BTC', 'CHF'); + if (!chfPrice.isValid) throw new InternalServerErrorException(`Invalid price from BTC to CHF`); + + const btcMonitoringEntity = MonitoringBalanceEntity.createAsBtcEntity( + blockchainBalance, + internalBtcBalance, + customerBtcBalance, + chfPrice, + ); + + await this.monitoringBalanceRepository.saveIfBalanceDiff(btcMonitoringEntity); + } + + private async processFiatBalances(customerFiatBalances: LightningWalletTotalBalanceDto[]) { + const zchfBalance = await this.getZchfBalance(); + + for (const customerFiatBalance of customerFiatBalances) { + if (customerFiatBalance.totalBalance) { + const fiatMonitoringEntity = MonitoringBalanceEntity.createAsChfEntity(zchfBalance, customerFiatBalance); + + await this.monitoringBalanceRepository.saveIfBalanceDiff(fiatMonitoringEntity); + } + } + } + + private async getZchfBalance(): Promise { + let balance = 0; + + const zchfTransferAssets = await this.assetService.getAllZchfTransferAssets(); + + for (const zchfTransferAsset of zchfTransferAssets) { + if (zchfTransferAsset.address) { + const evmClient = this.evmRegistryService.getClient(zchfTransferAsset.blockchain); + balance += await evmClient.getTokenBalance(zchfTransferAsset); + } + } + + return balance; + } + + private async getBlockchainBalances(): Promise { + return { + onchainBalance: await this.bitcoinClient.getWalletBalance(), + lndOnchainBalance: await this.lightningClient.getLndConfirmedWalletBalance(), + lightningBalance: await this.lightningClient.getLndLightningBalance(), + citreaBalance: await this.citreaClient.getNativeCoinBalance(), + }; + } + + private async getChannels(): Promise { + return this.lightningClient.getChannels(); + } +} From 0e07637a3ef4b6651a2dcddd79a9c7af0ec6f188 Mon Sep 17 00:00:00 2001 From: Bernd Date: Mon, 2 Feb 2026 10:57:38 +0100 Subject: [PATCH 2/5] Fix import of MonitoringBlockchainBalance from correct module --- src/subdomains/monitoring/services/monitoring.service.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/subdomains/monitoring/services/monitoring.service.ts b/src/subdomains/monitoring/services/monitoring.service.ts index 4ca74455..3b110967 100644 --- a/src/subdomains/monitoring/services/monitoring.service.ts +++ b/src/subdomains/monitoring/services/monitoring.service.ts @@ -12,7 +12,8 @@ import { QueueHandler } from 'src/shared/utils/queue-handler'; import { AssetService } from 'src/subdomains/master-data/asset/services/asset.service'; import { CoinGeckoService } from 'src/subdomains/pricing/services/coingecko.service'; import { LightningWalletTotalBalanceDto } from 'src/subdomains/user/application/dto/lightning-wallet.dto'; -import { MonitoringBalanceEntity, MonitoringBlockchainBalance } from '../entities/monitoring-balance.entity'; +import { MonitoringBlockchainBalance } from '../dto/monitoring.dto'; +import { MonitoringBalanceEntity } from '../entities/monitoring-balance.entity'; import { MonitoringBalanceRepository } from '../repositories/monitoring-balance.repository'; import { MonitoringRepository } from '../repositories/monitoring.repository'; From 65cc076d110c58e75e6a67e3a47cc683831bb251 Mon Sep 17 00:00:00 2001 From: Bernd Date: Fri, 6 Feb 2026 09:13:51 +0100 Subject: [PATCH 3/5] Add Telegram balance threshold alerts for BTC, Lightning and EVM chains --- ...770363785003-createMonitoringEvmBalance.js | 28 ++++ src/config/balance-thresholds.config.ts | 34 +++++ src/config/config.ts | 6 + .../telegram/services/telegram.service.ts | 50 +++++++ src/integration/telegram/telegram.module.ts | 10 ++ src/subdomains/boltz/boltz.module.ts | 2 +- .../boltz/services/boltz-balance.service.ts | 83 +++++++----- .../monitoring/monitoring.module.ts | 8 ++ .../services/balance-alert.service.ts | 123 ++++++++++++++++++ .../services/monitoring-evm.service.ts | 101 +++++++------- 10 files changed, 366 insertions(+), 79 deletions(-) create mode 100644 migration/1770363785003-createMonitoringEvmBalance.js create mode 100644 src/config/balance-thresholds.config.ts create mode 100644 src/integration/telegram/services/telegram.service.ts create mode 100644 src/integration/telegram/telegram.module.ts create mode 100644 src/subdomains/monitoring/services/balance-alert.service.ts diff --git a/migration/1770363785003-createMonitoringEvmBalance.js b/migration/1770363785003-createMonitoringEvmBalance.js new file mode 100644 index 00000000..40891059 --- /dev/null +++ b/migration/1770363785003-createMonitoringEvmBalance.js @@ -0,0 +1,28 @@ +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + * @typedef {import('typeorm').QueryRunner} QueryRunner + */ + +/** + * @class + * @implements {MigrationInterface} + */ +module.exports = class CreateMonitoringEvmBalance1770363785003 { + name = 'CreateMonitoringEvmBalance1770363785003' + + /** + * @param {QueryRunner} queryRunner + */ + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "monitoring_evm_balance" ("id" int NOT NULL IDENTITY(1,1), "created" datetime2 NOT NULL CONSTRAINT "DF_a12be90866896c9f2bfca372813" DEFAULT getdate(), "updated" datetime2 NOT NULL CONSTRAINT "DF_58ba0fc2da4061d234fac997dbb" DEFAULT getdate(), "blockchain" varchar(50) NOT NULL, "nativeSymbol" varchar(10) NOT NULL, "nativeBalance" float NOT NULL CONSTRAINT "DF_3223bf469c487599a6954599f5c" DEFAULT 0, "tokenBalances" nvarchar(max), CONSTRAINT "PK_9e27ea97b0eb8872a956abd8e2f" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_708ecfcf63cd709863b65da2e1" ON "monitoring_evm_balance" ("blockchain") `); + } + + /** + * @param {QueryRunner} queryRunner + */ + async down(queryRunner) { + await queryRunner.query(`DROP INDEX "IDX_708ecfcf63cd709863b65da2e1" ON "monitoring_evm_balance"`); + await queryRunner.query(`DROP TABLE "monitoring_evm_balance"`); + } +} diff --git a/src/config/balance-thresholds.config.ts b/src/config/balance-thresholds.config.ts new file mode 100644 index 00000000..1dd9726c --- /dev/null +++ b/src/config/balance-thresholds.config.ts @@ -0,0 +1,34 @@ +import { Blockchain } from 'src/shared/enums/blockchain.enum'; +import { Direction } from 'src/subdomains/boltz/dto/boltz.dto'; + +export interface BalanceThreshold { + blockchain: Blockchain; + asset: string; + minBalance: number; + maxBalance: number; + direction?: Direction; +} + +export const BALANCE_THRESHOLDS: BalanceThreshold[] = [ + // Bitcoin + { blockchain: Blockchain.BITCOIN, asset: 'BTC', minBalance: 0.1, maxBalance: 1 }, + + // Lightning (Onchain, Outgoing and Incoming Channels) + { blockchain: Blockchain.LIGHTNING, asset: 'BTC', minBalance: 0.1, maxBalance: 1 }, + { blockchain: Blockchain.LIGHTNING, asset: 'BTC', minBalance: 1, maxBalance: 5, direction: Direction.OUTGOING }, + { blockchain: Blockchain.LIGHTNING, asset: 'BTC', minBalance: 1, maxBalance: 5, direction: Direction.INCOMING }, + + // Citrea + { blockchain: Blockchain.CITREA, asset: 'cBTC', minBalance: 0.1, maxBalance: 1 }, + { blockchain: Blockchain.CITREA, asset: 'JUSD', minBalance: 1000, maxBalance: 100000 }, + + // Ethereum + { blockchain: Blockchain.ETHEREUM, asset: 'ETH', minBalance: 0.001, maxBalance: 0.1 }, + { blockchain: Blockchain.ETHEREUM, asset: 'USDC', minBalance: 1000, maxBalance: 100000 }, + { blockchain: Blockchain.ETHEREUM, asset: 'USDT', minBalance: 1000, maxBalance: 100000 }, + { blockchain: Blockchain.ETHEREUM, asset: 'WBTC', minBalance: 0, maxBalance: 1 }, + + // Polygon + { blockchain: Blockchain.POLYGON, asset: 'POL', minBalance: 1, maxBalance: 100 }, + { blockchain: Blockchain.POLYGON, asset: 'USDT', minBalance: 1000, maxBalance: 100000 }, +]; diff --git a/src/config/config.ts b/src/config/config.ts index 16403ab4..d4568bb3 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -9,6 +9,7 @@ export enum Process { UPDATE_WALLET_BALANCE = 'UpdateWalletBalance', UPDATE_LIGHTNING_USER_TRANSACTION = 'UpdateLightingUserTransaction', UPDATE_PAYMENT_REQUEST = 'UpdatePaymentRequest', + MONITORING = 'Monitoring', } export enum Environment { @@ -210,6 +211,11 @@ export class Configuration { evmWalletAddress: process.env.BOLTZ_WALLET_ADDRESS ?? '', }; + telegram = { + botToken: process.env.TELEGRAM_BOT_TOKEN ?? '', + chatId: process.env.TELEGRAM_CHAT_ID ?? '', + }; + // --- GETTERS --- // get baseUrl(): string { return this.environment === Environment.LOC diff --git a/src/integration/telegram/services/telegram.service.ts b/src/integration/telegram/services/telegram.service.ts new file mode 100644 index 00000000..5d591b31 --- /dev/null +++ b/src/integration/telegram/services/telegram.service.ts @@ -0,0 +1,50 @@ +import { Injectable } from '@nestjs/common'; +import { Config } from 'src/config/config'; +import { HttpService } from 'src/shared/services/http.service'; +import { LightningLogger } from 'src/shared/services/lightning-logger'; + +interface TelegramSendMessageResponse { + ok: boolean; + result?: { + message_id: number; + }; + description?: string; +} + +@Injectable() +export class TelegramService { + private readonly logger = new LightningLogger(TelegramService); + private readonly baseUrl = 'https://api.telegram.org'; + + constructor(private readonly httpService: HttpService) {} + + async sendMessage(message: string): Promise { + if (!Config.telegram.botToken || !Config.telegram.chatId) { + this.logger.info('Telegram not configured, skipping message'); + return false; + } + + try { + const url = `${this.baseUrl}/bot${Config.telegram.botToken}/sendMessage`; + const response = await this.httpService.post( + url, + { + chat_id: Config.telegram.chatId, + text: message, + parse_mode: 'HTML', + }, + { tryCount: 5, retryDelay: 2000 }, + ); + + if (!response.ok) { + this.logger.error(`Telegram API error: ${response.description}`); + return false; + } + + return true; + } catch (e) { + this.logger.error('Failed to send Telegram message', e); + return false; + } + } +} diff --git a/src/integration/telegram/telegram.module.ts b/src/integration/telegram/telegram.module.ts new file mode 100644 index 00000000..ca05fc8c --- /dev/null +++ b/src/integration/telegram/telegram.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { SharedModule } from 'src/shared/shared.module'; +import { TelegramService } from './services/telegram.service'; + +@Module({ + imports: [SharedModule], + providers: [TelegramService], + exports: [TelegramService], +}) +export class TelegramModule {} diff --git a/src/subdomains/boltz/boltz.module.ts b/src/subdomains/boltz/boltz.module.ts index f62082d1..3ef992b3 100644 --- a/src/subdomains/boltz/boltz.module.ts +++ b/src/subdomains/boltz/boltz.module.ts @@ -11,6 +11,6 @@ import { BoltzBalanceService } from './services/boltz-balance.service'; imports: [TypeOrmModule.forFeature([AssetBoltzEntity]), BlockchainModule, AlchemyModule], controllers: [BoltzController], providers: [AssetBoltzRepository, BoltzBalanceService], - exports: [], + exports: [BoltzBalanceService], }) export class BoltzModule {} diff --git a/src/subdomains/boltz/services/boltz-balance.service.ts b/src/subdomains/boltz/services/boltz-balance.service.ts index c45389ac..acf1c299 100644 --- a/src/subdomains/boltz/services/boltz-balance.service.ts +++ b/src/subdomains/boltz/services/boltz-balance.service.ts @@ -19,6 +19,7 @@ import { AssetBoltzRepository } from '../repositories/asset-boltz.repository'; interface ChainConfig { blockchain: Blockchain; chainId: number; + nativeCoin: string; usesAlchemy: boolean; } @@ -31,7 +32,7 @@ export class BoltzBalanceService implements OnModuleInit { private evmWalletAddress = ''; private citreaProvider: ethers.providers.JsonRpcProvider | null = null; - private chains: ChainConfig[] = []; + private chainConfigs: ChainConfig[] = []; constructor( bitcoinService: BitcoinService, @@ -48,10 +49,10 @@ export class BoltzBalanceService implements OnModuleInit { this.evmWalletAddress = config.boltz.evmWalletAddress; const blockchainConfig = config.blockchain; - this.chains = [ - { blockchain: Blockchain.ETHEREUM, chainId: blockchainConfig.ethereum.chainId, usesAlchemy: true }, - { blockchain: Blockchain.POLYGON, chainId: blockchainConfig.polygon.chainId, usesAlchemy: true }, - { blockchain: Blockchain.CITREA, chainId: blockchainConfig.citrea.chainId, usesAlchemy: false }, + this.chainConfigs = [ + { blockchain: Blockchain.ETHEREUM, chainId: blockchainConfig.ethereum.chainId, nativeCoin: 'ETH', usesAlchemy: true }, + { blockchain: Blockchain.POLYGON, chainId: blockchainConfig.polygon.chainId, nativeCoin: 'POL', usesAlchemy: true }, + { blockchain: Blockchain.CITREA, chainId: blockchainConfig.citrea.chainId, nativeCoin: 'cBTC', usesAlchemy: false }, ]; if (blockchainConfig.citrea.gatewayUrl) { @@ -76,14 +77,14 @@ export class BoltzBalanceService implements OnModuleInit { const onchainNode = await this.bitcoinClient.getWalletBalance(); balances.push({ blockchain: Blockchain.BITCOIN, asset: 'BTC', balance: LightningHelper.satToBtc(onchainNode) }); } catch (error) { - this.logger.warn(`Failed to fetch BTC onchain balance: ${error.message}`); + this.logger.error(`Failed to fetch BTC onchain balance: ${error.message}`); } try { const lndNode = await this.lightningClient.getLndConfirmedWalletBalance(); balances.push({ blockchain: Blockchain.LIGHTNING, asset: 'BTC', balance: LightningHelper.satToBtc(lndNode) }); } catch (error) { - this.logger.warn(`Failed to fetch LND wallet balance: ${error.message}`); + this.logger.error(`Failed to fetch LND wallet balance: ${error.message}`); } return balances; @@ -100,36 +101,58 @@ export class BoltzBalanceService implements OnModuleInit { balances.push({ blockchain: Blockchain.LIGHTNING, asset: 'BTC', balance: LightningHelper.satToBtc(outgoing), direction: Direction.OUTGOING}); balances.push({ blockchain: Blockchain.LIGHTNING, asset: 'BTC', balance: LightningHelper.satToBtc(incoming), direction: Direction.INCOMING }); } catch (error) { - this.logger.warn(`Failed to fetch Lightning balance: ${error.message}`); + this.logger.error(`Failed to fetch Lightning balance: ${error.message}`); } return balances; } - private async getEvmBalances(): Promise { + async getEvmBalances(): Promise { const balances: BalanceDto[] = []; // Citrea cBTC (native balance) const citreaBalance = await this.getCitreaNativeBalance(); if (citreaBalance) balances.push(citreaBalance); - // Token balances from all chains - for (const chain of this.chains) { + // Native and token balances from all chains + for (const chainConfig of this.chainConfigs) { try { - if (chain.chainId <= 0) continue; - - const tokenBalances = chain.usesAlchemy - ? await this.getAlchemyTokenBalances(chain) - : await this.getDirectTokenBalances(chain); - balances.push(...tokenBalances); + if (chainConfig.chainId <= 0) continue; + + if (chainConfig.usesAlchemy) { + // Native balance + const nativeBalance = await this.getAlchemyNativeBalance(chainConfig); + if (nativeBalance) balances.push(nativeBalance); + + // Token balances + const tokenBalances = await this.getAlchemyTokenBalances(chainConfig); + balances.push(...tokenBalances); + } else { + const tokenBalances = await this.getDirectTokenBalances(chainConfig); + balances.push(...tokenBalances); + } } catch (error) { - this.logger.warn(`Failed to fetch balances for ${chain.blockchain}: ${error.message}`); + this.logger.error(`Failed to fetch balances for ${chainConfig.blockchain}: ${error.message}`); } } return balances; } + private async getAlchemyNativeBalance(chainConfig: ChainConfig): Promise { + if (!this.evmWalletAddress) return null; + + try { + const balanceWei = await this.alchemyService.getNativeCoinBalance(chainConfig.chainId, this.evmWalletAddress); + const balance = EvmUtil.fromWeiAmount(balanceWei.toString()); + + return { blockchain: chainConfig.blockchain, asset: chainConfig.nativeCoin, balance }; + } catch (error) { + this.logger.error(`Failed to fetch native balance for ${chainConfig.blockchain}: ${error.message}`); + return null; + } + } + private async getCitreaNativeBalance(): Promise { if (!this.citreaProvider || !this.evmWalletAddress) return null; @@ -139,24 +162,24 @@ export class BoltzBalanceService implements OnModuleInit { return { blockchain: Blockchain.CITREA, asset: 'cBTC', balance }; } catch (error) { - this.logger.warn(`Failed to fetch Citrea native balance: ${error.message}`); + this.logger.error(`Failed to fetch Citrea native balance: ${error.message}`); return null; } } - private async getAlchemyTokenBalances(chain: ChainConfig): Promise { + private async getAlchemyTokenBalances(chainConfig: ChainConfig): Promise { const balances: BalanceDto[] = []; try { - if (!AlchemyNetworkMapper.toAlchemyNetworkByChainId(chain.chainId)) return balances; + if (!AlchemyNetworkMapper.toAlchemyNetworkByChainId(chainConfig.chainId)) return balances; if (!this.evmWalletAddress) return balances; - const tokens = await this.assetBoltzRepository.getByBlockchain(chain.blockchain); + const tokens = await this.assetBoltzRepository.getByBlockchain(chainConfig.blockchain); if (tokens.length === 0) return balances; const tokenAddresses = tokens.map((t) => t.address); - const tokenBalances = await this.alchemyService.getTokenBalancesByAddresses(chain.chainId, this.evmWalletAddress, tokenAddresses); + const tokenBalances = await this.alchemyService.getTokenBalancesByAddresses(chainConfig.chainId, this.evmWalletAddress, tokenAddresses); for (const tokenBalance of tokenBalances) { const token = tokens.find((t) => Util.equalsIgnoreCase(t.address,tokenBalance.contractAddress)); @@ -165,25 +188,25 @@ export class BoltzBalanceService implements OnModuleInit { const rawBalance = BigInt(tokenBalance.tokenBalance ?? '0'); balances.push({ - blockchain: chain.blockchain, + blockchain: chainConfig.blockchain, asset: token.name, balance: EvmUtil.fromWeiAmount(rawBalance.toString(), token.decimals), }); } } catch (error) { - this.logger.warn(`Failed to fetch Alchemy token balances for ${chain.blockchain}: ${error.message}`); + this.logger.error(`Failed to fetch Alchemy token balances for ${chainConfig.blockchain}: ${error.message}`); } return balances; } - private async getDirectTokenBalances(chain: ChainConfig): Promise { + private async getDirectTokenBalances(chainConfig: ChainConfig): Promise { const balances: BalanceDto[] = []; if (!this.citreaProvider || !this.evmWalletAddress) return balances; - if (chain.blockchain !== Blockchain.CITREA) return balances; + if (chainConfig.blockchain !== Blockchain.CITREA) return balances; - const tokens = await this.assetBoltzRepository.getByBlockchain(chain.blockchain); + const tokens = await this.assetBoltzRepository.getByBlockchain(chainConfig.blockchain); if (tokens.length === 0) return balances; for (const token of tokens) { @@ -192,12 +215,12 @@ export class BoltzBalanceService implements OnModuleInit { const rawBalance: ethers.BigNumber = await contract.balanceOf(this.evmWalletAddress); balances.push({ - blockchain: chain.blockchain, + blockchain: chainConfig.blockchain, asset: token.name, balance: EvmUtil.fromWeiAmount(rawBalance.toString(), token.decimals), }); } catch (error) { - this.logger.warn(`Failed to fetch ${token.name} balance on ${chain.blockchain}: ${error.message}`); + this.logger.warn(`Failed to fetch ${token.name} balance on ${chainConfig.blockchain}: ${error.message}`); } } diff --git a/src/subdomains/monitoring/monitoring.module.ts b/src/subdomains/monitoring/monitoring.module.ts index e2b0bfe5..ea364b46 100644 --- a/src/subdomains/monitoring/monitoring.module.ts +++ b/src/subdomains/monitoring/monitoring.module.ts @@ -2,7 +2,10 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { BlockchainModule } from 'src/integration/blockchain/blockchain.module'; import { EvmRegistryModule } from 'src/integration/blockchain/shared/evm/registry/evm-registry.module'; +import { TelegramModule } from 'src/integration/telegram/telegram.module'; +import { SharedModule } from 'src/shared/shared.module'; import { AlchemyWebhookModule } from '../alchemy/alchemy-webhook.module'; +import { BoltzModule } from '../boltz/boltz.module'; import { AssetModule } from '../master-data/asset/asset.module'; import { PricingModule } from '../pricing/pricing.module'; import { MonitoringBalanceEntity } from './entities/monitoring-balance.entity'; @@ -11,17 +14,21 @@ import { MonitoringEntity } from './entities/monitoring.entity'; import { MonitoringBalanceRepository } from './repositories/monitoring-balance.repository'; import { MonitoringEvmBalanceRepository } from './repositories/monitoring-evm-balance.repository'; import { MonitoringRepository } from './repositories/monitoring.repository'; +import { BalanceAlertService } from './services/balance-alert.service'; import { MonitoringEvmService } from './services/monitoring-evm.service'; import { MonitoringService } from './services/monitoring.service'; @Module({ imports: [ TypeOrmModule.forFeature([MonitoringEntity, MonitoringBalanceEntity, MonitoringEvmBalanceEntity]), + SharedModule, PricingModule, AssetModule, BlockchainModule, EvmRegistryModule, + BoltzModule, AlchemyWebhookModule, + TelegramModule, ], controllers: [], providers: [ @@ -30,6 +37,7 @@ import { MonitoringService } from './services/monitoring.service'; MonitoringEvmBalanceRepository, MonitoringService, MonitoringEvmService, + BalanceAlertService, ], exports: [MonitoringService, MonitoringEvmService], }) diff --git a/src/subdomains/monitoring/services/balance-alert.service.ts b/src/subdomains/monitoring/services/balance-alert.service.ts new file mode 100644 index 00000000..7fbdbac2 --- /dev/null +++ b/src/subdomains/monitoring/services/balance-alert.service.ts @@ -0,0 +1,123 @@ +import { Injectable } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { BALANCE_THRESHOLDS, BalanceThreshold } from 'src/config/balance-thresholds.config'; +import { Config, Process } from 'src/config/config'; +import { TelegramService } from 'src/integration/telegram/services/telegram.service'; +import { LightningLogger } from 'src/shared/services/lightning-logger'; +import { Lock } from 'src/shared/utils/lock'; +import { BalanceDto } from 'src/subdomains/boltz/dto/boltz.dto'; +import { BoltzBalanceService } from 'src/subdomains/boltz/services/boltz-balance.service'; + +enum AlertType { + LOW = 'LOW', + HIGH = 'HIGH', +} + +@Injectable() +export class BalanceAlertService { + private readonly logger = new LightningLogger(BalanceAlertService); + + private readonly alertState = new Map(); + + constructor( + private readonly telegramService: TelegramService, + private readonly boltzBalanceService: BoltzBalanceService, + ) {} + + @Cron(CronExpression.EVERY_5_MINUTES) + @Lock(1800) + async processBalanceAlerts(): Promise { + if (Config.processDisabled(Process.MONITORING)) return; + + try { + const balances = await this.boltzBalanceService.getWalletBalance(); + await this.checkAndAlert(balances); + } catch (e) { + this.logger.error('Error processing balance alerts', e); + } + } + + async checkAndAlert(balances: BalanceDto[]): Promise { + for (const threshold of BALANCE_THRESHOLDS) { + const balance = balances.find( + (b) => + b.blockchain === threshold.blockchain && + b.asset === threshold.asset && + b.direction === threshold.direction, + ); + + if (!balance) continue; + + await this.checkThreshold(threshold, balance.balance, AlertType.LOW); + await this.checkThreshold(threshold, balance.balance, AlertType.HIGH); + } + } + + private async checkThreshold( + threshold: BalanceThreshold, + balance: number, + alertType: AlertType, + ): Promise { + const directionSuffix = threshold.direction ? `:${threshold.direction}` : ''; + const key = `${threshold.blockchain}:${threshold.asset}${directionSuffix}:${alertType}`; + const isActive = this.alertState.get(key) ?? false; + + const thresholdValue = alertType === AlertType.LOW ? threshold.minBalance : threshold.maxBalance; + const isViolation = + alertType === AlertType.LOW ? balance < thresholdValue : balance > thresholdValue; + const isRecovered = + alertType === AlertType.LOW ? balance >= thresholdValue : balance <= thresholdValue; + + if (isViolation && !isActive) { + const sent = await this.sendBalanceAlert(alertType, threshold, balance, thresholdValue); + + if (sent) { + this.alertState.set(key, true); + this.logger.info(`Sent ${alertType} alert for ${threshold.asset} on ${threshold.blockchain}`); + } + } else if (isRecovered && isActive) { + await this.sendRecoveryMessage(threshold, balance); + this.alertState.set(key, false); + this.logger.info(`Cleared ${alertType} alert for ${threshold.asset} on ${threshold.blockchain}`); + } + } + + private async sendBalanceAlert( + alertType: AlertType, + threshold: BalanceThreshold, + balance: number, + thresholdValue: number, + ): Promise { + const description = + alertType === AlertType.LOW + ? 'Balance is below minimum threshold!' + : 'Balance exceeds maximum threshold!'; + + const directionInfo = threshold.direction ? ` (${threshold.direction} channels)` : ''; + + return this.telegramService.sendMessage(` +🔴 Balance Alert: ${alertType} + +Asset: ${threshold.asset} +Chain: ${threshold.blockchain}${directionInfo} +Balance: ${balance.toFixed(6)} +Threshold: ${thresholdValue.toFixed(6)} + +${description} +`); + } + + private async sendRecoveryMessage(threshold: BalanceThreshold, balance: number): Promise { + const directionInfo = threshold.direction ? ` (${threshold.direction} channels)` : ''; + + await this.telegramService.sendMessage(` +✅ Balance Recovered + +Asset: ${threshold.asset} +Chain: ${threshold.blockchain}${directionInfo} +Balance: ${balance.toFixed(6)} + +Balance is back within normal range. +`); + } +} diff --git a/src/subdomains/monitoring/services/monitoring-evm.service.ts b/src/subdomains/monitoring/services/monitoring-evm.service.ts index c729e81b..4392355c 100644 --- a/src/subdomains/monitoring/services/monitoring-evm.service.ts +++ b/src/subdomains/monitoring/services/monitoring-evm.service.ts @@ -1,25 +1,33 @@ import { Injectable, OnModuleInit } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; import { Config, Process } from 'src/config/config'; -import { EvmRegistryService } from 'src/integration/blockchain/shared/evm/registry/evm-registry.service'; import { Blockchain } from 'src/shared/enums/blockchain.enum'; import { LightningLogger } from 'src/shared/services/lightning-logger'; import { Lock } from 'src/shared/utils/lock'; -import { Equal } from 'typeorm'; -import { EvmChainConfig, EvmTokenBalanceJson } from '../dto/monitoring.dto'; +import { BalanceDto } from 'src/subdomains/boltz/dto/boltz.dto'; +import { BoltzBalanceService } from 'src/subdomains/boltz/services/boltz-balance.service'; +import { EvmTokenBalanceJson } from '../dto/monitoring.dto'; import { MonitoringEvmBalanceEntity } from '../entities/monitoring-evm-balance.entity'; import { MonitoringEvmBalanceRepository } from '../repositories/monitoring-evm-balance.repository'; -import { MonitoringRepository } from '../repositories/monitoring.repository'; -const EVM_TOKEN_CONFIG_TYPE = 'evm_token_config'; +interface ChainBalanceData { + nativeSymbol: string; + nativeBalance: number; + tokens: EvmTokenBalanceJson[]; +} + +const NATIVE_ASSETS: Record = { + [Blockchain.CITREA]: 'cBTC', + [Blockchain.ETHEREUM]: 'ETH', + [Blockchain.POLYGON]: 'POL', +}; @Injectable() export class MonitoringEvmService implements OnModuleInit { private readonly logger = new LightningLogger(MonitoringEvmService); constructor( - private readonly evmRegistryService: EvmRegistryService, - private readonly monitoringRepository: MonitoringRepository, + private readonly boltzBalanceService: BoltzBalanceService, private readonly monitoringEvmBalanceRepository: MonitoringEvmBalanceRepository, ) {} @@ -30,62 +38,59 @@ export class MonitoringEvmService implements OnModuleInit { @Cron(CronExpression.EVERY_5_MINUTES) @Lock(1800) async processEvmBalances(): Promise { - if (Config.processDisabled(Process.UPDATE_WALLET_BALANCE)) return; + if (Config.processDisabled(Process.MONITORING)) return; - const chainConfigs = await this.getEvmChainConfigs(); + try { + const balances = await this.boltzBalanceService.getEvmBalances(); + const groupedBalances = this.groupBalancesByBlockchain(balances); - for (const [blockchain, config] of chainConfigs) { - try { - await this.processChainBalance(blockchain, config); - } catch (e) { - this.logger.error(`Error processing ${blockchain} balance`, e); + for (const [blockchain, data] of groupedBalances) { + await this.saveChainBalance(blockchain, data); } + } catch (e) { + this.logger.error('Error processing EVM balances', e); } } - private async getEvmChainConfigs(): Promise> { - const configs = await this.monitoringRepository.findBy({ - type: Equal(EVM_TOKEN_CONFIG_TYPE), - }); + private groupBalancesByBlockchain(balances: BalanceDto[]): Map { + const result = new Map(); - const result = new Map(); + for (const balance of balances) { + const nativeAsset = NATIVE_ASSETS[balance.blockchain]; + if (!nativeAsset) continue; - for (const config of configs) { - try { - const blockchain = config.name as Blockchain; - const parsed = JSON.parse(config.value) as EvmChainConfig; - result.set(blockchain, parsed); - } catch (e) { - this.logger.error(`Failed to parse EVM config for ${config.name}`, e); + if (!result.has(balance.blockchain)) { + result.set(balance.blockchain, { + nativeSymbol: nativeAsset, + nativeBalance: 0, + tokens: [], + }); } - } - - return result; - } - - private async processChainBalance(blockchain: Blockchain, config: EvmChainConfig): Promise { - const evmClient = this.evmRegistryService.getClient(blockchain); - - // Get native balance - const nativeBalance = await evmClient.getNativeCoinBalance(); - // Get token balances - const tokenBalances: EvmTokenBalanceJson[] = []; + const chainData = result.get(balance.blockchain); + if (!chainData) continue; - for (const token of config.tokens) { - try { - const balance = await evmClient.getTokenBalanceByAddress(token.address, token.decimals); - tokenBalances.push({ - symbol: token.symbol, - address: token.address, - balance, + if (balance.asset === nativeAsset) { + chainData.nativeBalance = balance.balance; + } else { + chainData.tokens.push({ + symbol: balance.asset, + address: '', + balance: balance.balance, }); - } catch (e) { - this.logger.warn(`Failed to get ${token.symbol} balance on ${blockchain}: ${e.message}`); } } - const entity = MonitoringEvmBalanceEntity.create(blockchain, config.nativeSymbol, nativeBalance, tokenBalances); + return result; + } + + private async saveChainBalance(blockchain: Blockchain, data: ChainBalanceData): Promise { + const entity = MonitoringEvmBalanceEntity.create( + blockchain, + data.nativeSymbol, + data.nativeBalance, + data.tokens, + ); await this.monitoringEvmBalanceRepository.saveIfBalanceDiff(entity); } From ab9cc0133131948b31906b45057228209e864ef2 Mon Sep 17 00:00:00 2001 From: Bernd Date: Fri, 6 Feb 2026 09:30:20 +0100 Subject: [PATCH 4/5] Consolidate migrations into single EVM balance monitoring migration --- .../1769698161000-removeRootstockBalance.js | 14 ---- .../1769700099000-addEvmBalanceMonitoring.js | 74 ------------------- ...70366346107-createMonitoringEvmBalance.js} | 4 +- 3 files changed, 2 insertions(+), 90 deletions(-) delete mode 100644 migration/1769698161000-removeRootstockBalance.js delete mode 100644 migration/1769700099000-addEvmBalanceMonitoring.js rename migration/{1770363785003-createMonitoringEvmBalance.js => 1770366346107-createMonitoringEvmBalance.js} (91%) diff --git a/migration/1769698161000-removeRootstockBalance.js b/migration/1769698161000-removeRootstockBalance.js deleted file mode 100644 index 60db355c..00000000 --- a/migration/1769698161000-removeRootstockBalance.js +++ /dev/null @@ -1,14 +0,0 @@ -const { MigrationInterface, QueryRunner } = require("typeorm"); - -module.exports = class removeRootstockBalance1769698161000 { - name = 'removeRootstockBalance1769698161000' - - async up(queryRunner) { - await queryRunner.query(`ALTER TABLE "monitoring_balance" DROP CONSTRAINT "DF_e6e6c92e451a1c8873820c88148"`); - await queryRunner.query(`ALTER TABLE "monitoring_balance" DROP COLUMN "rootstockBalance"`); - } - - async down(queryRunner) { - await queryRunner.query(`ALTER TABLE "monitoring_balance" ADD "rootstockBalance" float NOT NULL CONSTRAINT "DF_e6e6c92e451a1c8873820c88148" DEFAULT 0`); - } -} diff --git a/migration/1769700099000-addEvmBalanceMonitoring.js b/migration/1769700099000-addEvmBalanceMonitoring.js deleted file mode 100644 index 81897085..00000000 --- a/migration/1769700099000-addEvmBalanceMonitoring.js +++ /dev/null @@ -1,74 +0,0 @@ -const { MigrationInterface, QueryRunner } = require("typeorm"); - -module.exports = class addEvmBalanceMonitoring1769700099000 { - name = 'addEvmBalanceMonitoring1769700099000' - - async up(queryRunner) { - // Create new monitoring_evm_balance table - await queryRunner.query(` - CREATE TABLE "monitoring_evm_balance" ( - "id" int NOT NULL IDENTITY(1,1), - "created" datetime2 NOT NULL CONSTRAINT "DF_monitoring_evm_balance_created" DEFAULT getdate(), - "updated" datetime2 NOT NULL CONSTRAINT "DF_monitoring_evm_balance_updated" DEFAULT getdate(), - "blockchain" varchar(50) NOT NULL, - "nativeSymbol" varchar(10) NOT NULL, - "nativeBalance" float NOT NULL CONSTRAINT "DF_monitoring_evm_balance_nativeBalance" DEFAULT 0, - "tokenBalances" nvarchar(max), - CONSTRAINT "PK_monitoring_evm_balance" PRIMARY KEY ("id") - ) - `); - - await queryRunner.query(`CREATE INDEX "IDX_monitoring_evm_balance_blockchain" ON "monitoring_evm_balance" ("blockchain")`); - - // Insert EVM token configurations into monitoring table - const evmConfigs = [ - { - name: 'ethereum', - config: { - nativeSymbol: 'ETH', - tokens: [ - { symbol: 'USDT', address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', decimals: 6 }, - { symbol: 'USDC', address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', decimals: 6 }, - { symbol: 'WBTC', address: '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599', decimals: 8 }, - ], - }, - }, - { - name: 'polygon', - config: { - nativeSymbol: 'MATIC', - tokens: [ - { symbol: 'USDT', address: '0xc2132D05D31c914a87C6611C10748AEb04B58e8F', decimals: 6 }, - ], - }, - }, - { - name: 'citrea', - config: { - nativeSymbol: 'cBTC', - tokens: [ - { symbol: 'JUSD', address: '0x0987D3720D38847ac6dBB9D025B9dE892a3CA35C', decimals: 18 }, - { symbol: 'WBTCe', address: '0xDF240DC08B0FdaD1d93b74b5048871232f6BEA3d', decimals: 8 }, - ], - }, - }, - ]; - - for (const { name, config } of evmConfigs) { - const value = JSON.stringify(config).replace(/'/g, "''"); - await queryRunner.query(` - INSERT INTO "monitoring" ("type", "name", "value", "created", "updated") - VALUES ('evm_token_config', '${name}', '${value}', GETDATE(), GETDATE()) - `); - } - } - - async down(queryRunner) { - // Remove EVM token configurations from monitoring table - await queryRunner.query(`DELETE FROM "monitoring" WHERE "type" = 'evm_token_config'`); - - // Drop monitoring_evm_balance table - await queryRunner.query(`DROP INDEX "IDX_monitoring_evm_balance_blockchain" ON "monitoring_evm_balance"`); - await queryRunner.query(`DROP TABLE "monitoring_evm_balance"`); - } -} diff --git a/migration/1770363785003-createMonitoringEvmBalance.js b/migration/1770366346107-createMonitoringEvmBalance.js similarity index 91% rename from migration/1770363785003-createMonitoringEvmBalance.js rename to migration/1770366346107-createMonitoringEvmBalance.js index 40891059..1fd6d546 100644 --- a/migration/1770363785003-createMonitoringEvmBalance.js +++ b/migration/1770366346107-createMonitoringEvmBalance.js @@ -7,8 +7,8 @@ * @class * @implements {MigrationInterface} */ -module.exports = class CreateMonitoringEvmBalance1770363785003 { - name = 'CreateMonitoringEvmBalance1770363785003' +module.exports = class CreateMonitoringEvmBalance1770366346107 { + name = 'CreateMonitoringEvmBalance1770366346107' /** * @param {QueryRunner} queryRunner From 7d5c59d075312bfda107e6683bfb31e97dda80ab Mon Sep 17 00:00:00 2001 From: Bernd Date: Fri, 6 Feb 2026 14:22:25 +0100 Subject: [PATCH 5/5] Move index decorator to column level in MonitoringEvmBalanceEntity --- .../monitoring/entities/monitoring-evm-balance.entity.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/subdomains/monitoring/entities/monitoring-evm-balance.entity.ts b/src/subdomains/monitoring/entities/monitoring-evm-balance.entity.ts index 959afdda..851e87ca 100644 --- a/src/subdomains/monitoring/entities/monitoring-evm-balance.entity.ts +++ b/src/subdomains/monitoring/entities/monitoring-evm-balance.entity.ts @@ -4,9 +4,9 @@ import { Column, Entity, Index } from 'typeorm'; import { EvmTokenBalanceJson } from '../dto/monitoring.dto'; @Entity('monitoring_evm_balance') -@Index(['blockchain'], { unique: false }) export class MonitoringEvmBalanceEntity extends IEntity { @Column({ type: 'varchar', length: 50 }) + @Index({ unique: false }) blockchain: Blockchain; @Column({ type: 'varchar', length: 10 })