Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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"
2 changes: 2 additions & 0 deletions packages/backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -40,6 +41,7 @@ import { ScheduleModule } from '@nestjs/schedule';
PartnerModule,
QuestModule,
RewardModule,
BalanceAlertModule,
ScheduleModule.forRoot(),
],
})
Expand Down
9 changes: 9 additions & 0 deletions packages/backend/src/balance-alert/balance-alert.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
124 changes: 124 additions & 0 deletions packages/backend/src/balance-alert/balance-alert.scheduler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
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<string>(
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<string>(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
},
{
name: isMainnet ? 'Base Mainnet' : 'Base Sepolia',
chain: isMainnet ? base : baseSepolia,
threshold: 500000000000000n, // 0.0005 ETH
},
];

this.logger.log(
`Balance alert initialized for relayer: ${this.walletAddress}`,
);
}

// 7:00 AM Vietnam (00:00 UTC)
@Cron('0 0 * * *', { timeZone: 'UTC' })
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(
`🔴 <b>${name}</b>\n` +
` <code>${balanceShort} / ${thresholdShort} ETH</code>`,
);
} else {
this.logger.log(`${name} balance OK: ${balanceFormatted} ETH`);
}
} catch (error) {
this.logger.error(
`Failed to check balance on ${name}: ${error.message}`,
);
alerts.push(
`<b>${name}</b>\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 =
`⚠️ <b>PolyPay Relayer — Low Balance</b>\n\n` +
`🔑 <code>${this.walletAddress}</code>\n\n` +
alerts.join('\n\n') +
`\n\n⏰ ${now} (UTC+7)`;

await this.telegramService.sendMessage(message);
}
}
}
80 changes: 80 additions & 0 deletions packages/backend/src/balance-alert/telegram.service.ts
Original file line number Diff line number Diff line change
@@ -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<string>('telegram.botToken');
this.chatId = this.configService.get<string>('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<void> {
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<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}
4 changes: 4 additions & 0 deletions packages/backend/src/config/config.keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
9 changes: 8 additions & 1 deletion packages/backend/src/config/config.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions packages/backend/src/config/env.validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
});
6 changes: 6 additions & 0 deletions packages/backend/src/config/telegram.config.ts
Original file line number Diff line number Diff line change
@@ -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,
}));
Loading