From 8ba40893ba860515be8e0f6559254e0318e5a56e Mon Sep 17 00:00:00 2001 From: BoHsuu Date: Tue, 17 Mar 2026 10:59:25 +0700 Subject: [PATCH 1/3] feat: add daily relayer balance alert via Telegram Cron job checks Horizen and Base chain balances, sends Telegram alert when below threshold. Includes retry logic and network-aware config. --- packages/backend/.env.example | 4 + packages/backend/src/app.module.ts | 2 + .../src/balance-alert/balance-alert.module.ts | 9 ++ .../balance-alert/balance-alert.scheduler.ts | 127 ++++++++++++++++++ .../src/balance-alert/telegram.service.ts | 80 +++++++++++ packages/backend/src/config/config.keys.ts | 4 + packages/backend/src/config/config.module.ts | 9 +- packages/backend/src/config/env.validation.ts | 4 + .../backend/src/config/telegram.config.ts | 6 + 9 files changed, 244 insertions(+), 1 deletion(-) create mode 100644 packages/backend/src/balance-alert/balance-alert.module.ts create mode 100644 packages/backend/src/balance-alert/balance-alert.scheduler.ts create mode 100644 packages/backend/src/balance-alert/telegram.service.ts create mode 100644 packages/backend/src/config/telegram.config.ts diff --git a/packages/backend/.env.example b/packages/backend/.env.example index 930dcd62..54b30bf5 100644 --- a/packages/backend/.env.example +++ b/packages/backend/.env.example @@ -32,3 +32,7 @@ NETWORK="testnet" # User for analytics ADMIN_API_KEY=admin-key PARTNER_API_KEY=partner-key + +# Telegram alerts (optional - for relayer balance monitoring) +TELEGRAM_BOT_TOKEN="your-telegram-bot-token-here" +TELEGRAM_CHAT_ID="your-telegram-chat-id-here" diff --git a/packages/backend/src/app.module.ts b/packages/backend/src/app.module.ts index cef4dc1d..1e95158b 100644 --- a/packages/backend/src/app.module.ts +++ b/packages/backend/src/app.module.ts @@ -18,6 +18,7 @@ import { AdminModule } from './admin/admin.module'; import { PartnerModule } from './partner/partner.module'; import { QuestModule } from './quest/quest.module'; import { RewardModule } from './reward/reward.module'; +import { BalanceAlertModule } from './balance-alert/balance-alert.module'; import { ScheduleModule } from '@nestjs/schedule'; @Module({ @@ -40,6 +41,7 @@ import { ScheduleModule } from '@nestjs/schedule'; PartnerModule, QuestModule, RewardModule, + BalanceAlertModule, ScheduleModule.forRoot(), ], }) diff --git a/packages/backend/src/balance-alert/balance-alert.module.ts b/packages/backend/src/balance-alert/balance-alert.module.ts new file mode 100644 index 00000000..e583ae74 --- /dev/null +++ b/packages/backend/src/balance-alert/balance-alert.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { BalanceAlertScheduler } from './balance-alert.scheduler'; +import { TelegramService } from './telegram.service'; + +@Module({ + providers: [TelegramService, BalanceAlertScheduler], + exports: [TelegramService], +}) +export class BalanceAlertModule {} diff --git a/packages/backend/src/balance-alert/balance-alert.scheduler.ts b/packages/backend/src/balance-alert/balance-alert.scheduler.ts new file mode 100644 index 00000000..cb24fe41 --- /dev/null +++ b/packages/backend/src/balance-alert/balance-alert.scheduler.ts @@ -0,0 +1,127 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { ConfigService } from '@nestjs/config'; +import { createPublicClient, http, formatEther } from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; +import { base, baseSepolia } from 'viem/chains'; +import { CONFIG_KEYS } from '@/config/config.keys'; +import { getChain } from '@polypay/shared'; +import { TelegramService } from './telegram.service'; + +interface ChainCheck { + name: string; + chain: any; + threshold: bigint; +} + +@Injectable() +export class BalanceAlertScheduler { + private readonly logger = new Logger(BalanceAlertScheduler.name); + private readonly walletAddress: string; + private readonly chains: ChainCheck[]; + + constructor( + private readonly configService: ConfigService, + private readonly telegramService: TelegramService, + ) { + const privateKey = this.configService.get( + CONFIG_KEYS.RELAYER_WALLET_KEY, + ) as `0x${string}`; + + if (!privateKey) { + throw new Error('RELAYER_WALLET_KEY is not set'); + } + + const account = privateKeyToAccount(privateKey); + this.walletAddress = account.address; + + const network = this.configService.get(CONFIG_KEYS.APP_NETWORK); + const isMainnet = network === 'mainnet'; + + this.chains = [ + { + name: isMainnet ? 'Horizen Mainnet' : 'Horizen Testnet', + chain: getChain(network as 'mainnet' | 'testnet'), + // threshold: 100000000000000n, // 0.0001 ETH + threshold: 1000000000000000000n, // 0.0001 ETH + }, + { + name: isMainnet ? 'Base Mainnet' : 'Base Sepolia', + chain: isMainnet ? base : baseSepolia, + // threshold: 500000000000000n, // 0.0005 ETH + threshold: 5000000000000000000n, // 0.0005 ETH + }, + ]; + + this.logger.log( + `Balance alert initialized for relayer: ${this.walletAddress}`, + ); + } + + // Production: 6:00 AM Vietnam (23:00 UTC) + // @Cron('0 23 * * *', { timeZone: 'UTC' }) + @Cron('*/10 * * * * *') + async checkBalances() { + this.logger.log('Running daily relayer balance check'); + + const alerts: string[] = []; + + for (const { name, chain, threshold } of this.chains) { + try { + const client = createPublicClient({ + chain, + transport: http(), + }); + + const balance = await client.getBalance({ + address: this.walletAddress as `0x${string}`, + }); + + const balanceFormatted = formatEther(balance); + const thresholdFormatted = formatEther(threshold); + + if (balance < threshold) { + this.logger.warn( + `Low balance on ${name}: ${balanceFormatted} ETH (threshold: ${thresholdFormatted} ETH)`, + ); + + const balanceShort = parseFloat(balanceFormatted).toFixed(6); + const thresholdShort = parseFloat(thresholdFormatted).toFixed(4); + + alerts.push( + `šŸ”“ ${name}\n` + + ` ${balanceShort} / ${thresholdShort} ETH`, + ); + } else { + this.logger.log(`${name} balance OK: ${balanceFormatted} ETH`); + } + } catch (error) { + this.logger.error( + `Failed to check balance on ${name}: ${error.message}`, + ); + alerts.push( + `${name}\nFailed to check balance: ${error.message}`, + ); + } + } + + if (alerts.length > 0) { + const now = new Date().toLocaleString('vi-VN', { + timeZone: 'Asia/Ho_Chi_Minh', + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + + const message = + `āš ļø PolyPay Relayer — Low Balance\n\n` + + `šŸ”‘ ${this.walletAddress}\n\n` + + alerts.join('\n\n') + + `\n\nā° ${now} (UTC+7)`; + + await this.telegramService.sendMessage(message); + } + } +} diff --git a/packages/backend/src/balance-alert/telegram.service.ts b/packages/backend/src/balance-alert/telegram.service.ts new file mode 100644 index 00000000..df1b1d2a --- /dev/null +++ b/packages/backend/src/balance-alert/telegram.service.ts @@ -0,0 +1,80 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class TelegramService { + private readonly logger = new Logger(TelegramService.name); + private readonly botToken: string | undefined; + private readonly chatId: string | undefined; + private readonly maxRetries = 3; + + constructor(private readonly configService: ConfigService) { + this.botToken = this.configService.get('telegram.botToken'); + this.chatId = this.configService.get('telegram.chatId'); + + if (!this.botToken || !this.chatId) { + this.logger.warn( + 'TELEGRAM_BOT_TOKEN or TELEGRAM_CHAT_ID is not set. Telegram alerts are disabled.', + ); + } + } + + async sendMessage(message: string): Promise { + if (!this.botToken || !this.chatId) { + this.logger.warn('Telegram not configured, skipping alert'); + return; + } + + const url = `https://api.telegram.org/bot${this.botToken}/sendMessage`; + + for (let attempt = 1; attempt <= this.maxRetries; attempt++) { + try { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chat_id: this.chatId, + text: message, + parse_mode: 'HTML', + }), + }); + + if (response.ok) { + this.logger.log('Telegram alert sent successfully'); + return; + } + + const body = await response.text(); + const status = response.status; + + // Don't retry on 4xx errors (except 429 rate limit) — these are config issues + if (status >= 400 && status < 500 && status !== 429) { + this.logger.error( + `Telegram API error (${status}), not retrying: ${body}`, + ); + return; + } + + throw new Error(`Telegram API error: ${status} - ${body}`); + } catch (error) { + this.logger.error( + `Failed to send Telegram alert (attempt ${attempt}/${this.maxRetries}): ${error.message}`, + ); + + if (attempt < this.maxRetries) { + const delay = Math.pow(2, attempt) * 1000; // 2s, 4s, 8s + this.logger.log(`Retrying in ${delay / 1000}s...`); + await this.sleep(delay); + } else { + this.logger.error( + `All ${this.maxRetries} attempts failed. Telegram alert not sent.`, + ); + } + } + } + } + + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} diff --git a/packages/backend/src/config/config.keys.ts b/packages/backend/src/config/config.keys.ts index 8a338f3a..a6b78436 100644 --- a/packages/backend/src/config/config.keys.ts +++ b/packages/backend/src/config/config.keys.ts @@ -20,4 +20,8 @@ export const CONFIG_KEYS = { RELAYER_ZK_VERIFY_API_KEY: 'relayer.zkVerifyApiKey', RELAYER_WALLET_KEY: 'relayer.walletKey', REWARD_WALLET_KEY: 'relayer.rewardWalletKey', + + // Telegram + TELEGRAM_BOT_TOKEN: 'telegram.botToken', + TELEGRAM_CHAT_ID: 'telegram.chatId', } as const; diff --git a/packages/backend/src/config/config.module.ts b/packages/backend/src/config/config.module.ts index 270fdc05..a1c781e8 100644 --- a/packages/backend/src/config/config.module.ts +++ b/packages/backend/src/config/config.module.ts @@ -4,13 +4,20 @@ import databaseConfig from './database.config'; import appConfig from './app.config'; import jwtConfig from './jwt.config'; import relayerConfig from './relayer.config'; +import telegramConfig from './telegram.config'; import { validationSchema } from './env.validation'; @Module({ imports: [ NestConfigModule.forRoot({ isGlobal: true, - load: [appConfig, databaseConfig, jwtConfig, relayerConfig], + load: [ + appConfig, + databaseConfig, + jwtConfig, + relayerConfig, + telegramConfig, + ], envFilePath: ['.env.local', '.env'], cache: true, expandVariables: true, diff --git a/packages/backend/src/config/env.validation.ts b/packages/backend/src/config/env.validation.ts index 71289130..ccf96eab 100644 --- a/packages/backend/src/config/env.validation.ts +++ b/packages/backend/src/config/env.validation.ts @@ -36,4 +36,8 @@ export const validationSchema = Joi.object({ 'string.empty': 'RELAYER_WALLET_KEY is required', 'any.required': 'RELAYER_WALLET_KEY is required', }), + + // Telegram alerts - optional + TELEGRAM_BOT_TOKEN: Joi.string().optional(), + TELEGRAM_CHAT_ID: Joi.string().optional(), }); diff --git a/packages/backend/src/config/telegram.config.ts b/packages/backend/src/config/telegram.config.ts new file mode 100644 index 00000000..58ee07c2 --- /dev/null +++ b/packages/backend/src/config/telegram.config.ts @@ -0,0 +1,6 @@ +import { registerAs } from '@nestjs/config'; + +export default registerAs('telegram', () => ({ + botToken: process.env.TELEGRAM_BOT_TOKEN, + chatId: process.env.TELEGRAM_CHAT_ID, +})); From c6cdb25ed5b1671e04c146237b354164452d12d9 Mon Sep 17 00:00:00 2001 From: BoHsuu Date: Tue, 17 Mar 2026 11:05:59 +0700 Subject: [PATCH 2/3] fix: correct balance alert thresholds and update cron job timing - Adjusted balance alert thresholds for Horizen and Base chains to 0.0001 ETH and 0.0005 ETH respectively. - Updated cron job timing to run daily at 6:00 AM Vietnam time (23:00 UTC). --- .../src/balance-alert/balance-alert.scheduler.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/backend/src/balance-alert/balance-alert.scheduler.ts b/packages/backend/src/balance-alert/balance-alert.scheduler.ts index cb24fe41..a0bb82b0 100644 --- a/packages/backend/src/balance-alert/balance-alert.scheduler.ts +++ b/packages/backend/src/balance-alert/balance-alert.scheduler.ts @@ -42,14 +42,12 @@ export class BalanceAlertScheduler { { name: isMainnet ? 'Horizen Mainnet' : 'Horizen Testnet', chain: getChain(network as 'mainnet' | 'testnet'), - // threshold: 100000000000000n, // 0.0001 ETH - threshold: 1000000000000000000n, // 0.0001 ETH + threshold: 100000000000000n, // 0.0001 ETH }, { name: isMainnet ? 'Base Mainnet' : 'Base Sepolia', chain: isMainnet ? base : baseSepolia, - // threshold: 500000000000000n, // 0.0005 ETH - threshold: 5000000000000000000n, // 0.0005 ETH + threshold: 500000000000000n, // 0.0005 ETH }, ]; @@ -58,9 +56,8 @@ export class BalanceAlertScheduler { ); } - // Production: 6:00 AM Vietnam (23:00 UTC) - // @Cron('0 23 * * *', { timeZone: 'UTC' }) - @Cron('*/10 * * * * *') + // 6:00 AM Vietnam (23:00 UTC) + @Cron('0 23 * * *', { timeZone: 'UTC' }) async checkBalances() { this.logger.log('Running daily relayer balance check'); From d2babc24b169aa206448e425389bbeaa1cdb1dc9 Mon Sep 17 00:00:00 2001 From: BoHsuu Date: Tue, 17 Mar 2026 11:20:43 +0700 Subject: [PATCH 3/3] fix: update cron job timing for balance alert scheduler - Changed cron job to run daily at 7:00 AM Vietnam time (00:00 UTC) for balance checks, correcting the previous timing. --- packages/backend/src/balance-alert/balance-alert.scheduler.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/balance-alert/balance-alert.scheduler.ts b/packages/backend/src/balance-alert/balance-alert.scheduler.ts index a0bb82b0..adebd8a1 100644 --- a/packages/backend/src/balance-alert/balance-alert.scheduler.ts +++ b/packages/backend/src/balance-alert/balance-alert.scheduler.ts @@ -56,8 +56,8 @@ export class BalanceAlertScheduler { ); } - // 6:00 AM Vietnam (23:00 UTC) - @Cron('0 23 * * *', { timeZone: 'UTC' }) + // 7:00 AM Vietnam (00:00 UTC) + @Cron('0 0 * * *', { timeZone: 'UTC' }) async checkBalances() { this.logger.log('Running daily relayer balance check');