diff --git a/migration/1770366346107-createMonitoringEvmBalance.js b/migration/1770366346107-createMonitoringEvmBalance.js new file mode 100644 index 00000000..1fd6d546 --- /dev/null +++ b/migration/1770366346107-createMonitoringEvmBalance.js @@ -0,0 +1,28 @@ +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + * @typedef {import('typeorm').QueryRunner} QueryRunner + */ + +/** + * @class + * @implements {MigrationInterface} + */ +module.exports = class CreateMonitoringEvmBalance1770366346107 { + name = 'CreateMonitoringEvmBalance1770366346107' + + /** + * @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 a3d53e86..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 { @@ -148,11 +149,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), @@ -215,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/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/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/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/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/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..851e87ca --- /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') +export class MonitoringEvmBalanceEntity extends IEntity { + @Column({ type: 'varchar', length: 50 }) + @Index({ unique: false }) + 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..ea364b46 100644 --- a/src/subdomains/monitoring/monitoring.module.ts +++ b/src/subdomains/monitoring/monitoring.module.ts @@ -1,27 +1,44 @@ -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 { 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'; +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 { 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: [ + MonitoringRepository, + MonitoringBalanceRepository, + MonitoringEvmBalanceRepository, + MonitoringService, + MonitoringEvmService, + BalanceAlertService, + ], + 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/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 new file mode 100644 index 00000000..4392355c --- /dev/null +++ b/src/subdomains/monitoring/services/monitoring-evm.service.ts @@ -0,0 +1,97 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { Config, Process } from 'src/config/config'; +import { Blockchain } from 'src/shared/enums/blockchain.enum'; +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'; +import { EvmTokenBalanceJson } from '../dto/monitoring.dto'; +import { MonitoringEvmBalanceEntity } from '../entities/monitoring-evm-balance.entity'; +import { MonitoringEvmBalanceRepository } from '../repositories/monitoring-evm-balance.repository'; + +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 boltzBalanceService: BoltzBalanceService, + private readonly monitoringEvmBalanceRepository: MonitoringEvmBalanceRepository, + ) {} + + onModuleInit() { + void this.processEvmBalances(); + } + + @Cron(CronExpression.EVERY_5_MINUTES) + @Lock(1800) + async processEvmBalances(): Promise { + if (Config.processDisabled(Process.MONITORING)) return; + + try { + const balances = await this.boltzBalanceService.getEvmBalances(); + const groupedBalances = this.groupBalancesByBlockchain(balances); + + for (const [blockchain, data] of groupedBalances) { + await this.saveChainBalance(blockchain, data); + } + } catch (e) { + this.logger.error('Error processing EVM balances', e); + } + } + + private groupBalancesByBlockchain(balances: BalanceDto[]): Map { + const result = new Map(); + + for (const balance of balances) { + const nativeAsset = NATIVE_ASSETS[balance.blockchain]; + if (!nativeAsset) continue; + + if (!result.has(balance.blockchain)) { + result.set(balance.blockchain, { + nativeSymbol: nativeAsset, + nativeBalance: 0, + tokens: [], + }); + } + + const chainData = result.get(balance.blockchain); + if (!chainData) continue; + + if (balance.asset === nativeAsset) { + chainData.nativeBalance = balance.balance; + } else { + chainData.tokens.push({ + symbol: balance.asset, + address: '', + balance: balance.balance, + }); + } + } + + 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); + } +} diff --git a/src/subdomains/monitoring/services/monitoring.service.ts b/src/subdomains/monitoring/services/monitoring.service.ts index 7776175b..3b110967 100644 --- a/src/subdomains/monitoring/services/monitoring.service.ts +++ b/src/subdomains/monitoring/services/monitoring.service.ts @@ -1,172 +1,169 @@ -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 { 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'; + +@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(); + } +}