diff --git a/src/TroveManager.ts b/src/TroveManager.ts index c6c4ef6..edb718c 100644 --- a/src/TroveManager.ts +++ b/src/TroveManager.ts @@ -6,7 +6,7 @@ import { Context } from './index'; import { toHexAddress } from './shared'; import { CairoCustomEnum } from 'starknet'; import { BatchManager } from '../.checkpoint/models'; -import { logToTelegram } from './telegram'; +import { logToTelegram, logAlertToTelegram, AlertEventData } from './telegram'; // see Operation enum in contracts // @@ -83,12 +83,62 @@ export function createTroveOperationHandler(context: Context): starknet.Writer { trove.redeemedDebt = ( BigInt(trove.redeemedDebt) + BigInt(event.debt_change_from_operation.abs) ).toString(); + + // Fetch tx to get redeemer address + let sender = 'unknown'; + try { + const tx = await context.provider.getTransaction(txId); + sender = toHexAddress((tx as { sender_address?: string }).sender_address || 0); + } catch (e) { + console.error('Failed to fetch tx for redeemer address:', e); + } + + const eventData: AlertEventData = { + txId, + blockTimestamp: timestamp, + debtChange: BigInt(event.debt_change_from_operation.abs), + collChange: BigInt(event.coll_change_from_operation.abs), + sender + }; + await logAlertToTelegram( + 'redemption', + trove, + collId, + block.block_number, + eventData, + indexerName + ); } // Liquidation if (operation === OP_LIQUIDATE) { trove.status = 'liquidated'; trove.liquidationTx = txId; + + // Fetch tx to get liquidator address + let sender = 'unknown'; + try { + const tx = await context.provider.getTransaction(txId); + sender = toHexAddress((tx as { sender_address?: string }).sender_address || 0); + } catch (e) { + console.error('Failed to fetch tx for liquidator address:', e); + } + + const eventData: AlertEventData = { + txId, + blockTimestamp: timestamp, + debtChange: BigInt(event.debt_change_from_operation.abs), + collChange: BigInt(event.coll_change_from_operation.abs), + sender + }; + await logAlertToTelegram( + 'liquidation', + trove, + collId, + block.block_number, + eventData, + indexerName + ); } // Infer leverage flag on opening & adjustment @@ -280,7 +330,7 @@ export function createTrove(id: string, createdAt: number, indexerName: string): } export function createTroveUpdatedHandler(ctx: Context): starknet.Writer { - return async ({ block, event, rawEvent }) => { + return async ({ block, event, rawEvent, txId }) => { if (!block || !event) return; const indexerName = ctx.indexerName; @@ -319,14 +369,23 @@ export function createTroveUpdatedHandler(ctx: Context): starknet.Writer { // Send Telegram notification // Pass created flag and batched=false for regular TroveUpdated events - await logToTelegram(created, false, trove, collId, block.block_number); + await logToTelegram( + created, + false, + trove, + collId, + block.block_number, + block.timestamp, + indexerName, + txId + ); await trove.save(); }; } export function createBatchedTroveUpdatedHandler(ctx: Context): starknet.Writer { - return async ({ block, event, rawEvent }) => { + return async ({ block, event, rawEvent, txId }) => { if (!block || !event) return; const indexerName = ctx.indexerName; @@ -385,7 +444,16 @@ export function createBatchedTroveUpdatedHandler(ctx: Context): starknet.Writer // Send Telegram notification // Pass created flag and batched=true for BatchedTroveUpdated events - await logToTelegram(created, true, trove, collId, block.block_number); + await logToTelegram( + created, + true, + trove, + collId, + block.block_number, + block.timestamp, + indexerName, + txId + ); await trove.save(); }; diff --git a/src/index.ts b/src/index.ts index c0772c9..a1bf5d8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -66,8 +66,8 @@ async function run() { console.log('Delaying indexer to prevent multiple processes indexing at the same time.'); await sleep(PRODUCTION_INDEXER_DELAY); } - await checkpoint.resetMetadata(); - await checkpoint.reset(); + // await checkpoint.resetMetadata(); + // await checkpoint.reset(); await checkpoint.start(); } run(); diff --git a/src/telegram.ts b/src/telegram.ts index 34617fa..06a4a54 100644 --- a/src/telegram.ts +++ b/src/telegram.ts @@ -2,9 +2,16 @@ import { Trove } from '../.checkpoint/models'; const START_BLOCK = '3245544'; +// Store indexer launch time to skip historical notifications +const INDEXER_LAUNCH_TIME = Math.floor(Date.now() / 1000); +console.log( + `Telegram module initialized. Launch time: ${INDEXER_LAUNCH_TIME} (${new Date().toISOString()})` +); + const COLLATERAL_MAPPING: Record = { '0': 'WBTC', - '1': 'xWBTC' + '1': 'TBTC', + '2': 'SOLVBTC' }; // Price cache with 5-minute expiry @@ -66,12 +73,22 @@ export async function logToTelegram( batched: boolean, trove: Trove, collId: string, - blockNumber: number + blockNumber: number, + blockTimestamp: number, + indexerName: string, + txId: string ): Promise { // Only send notifications on mainnet - const chain = process.env.CHAIN; // TODO this doens't exist anymore - if (chain !== 'mainnet') { - console.log(`Skipping Telegram notification: chain is ${chain}, not mainnet`); + if (indexerName !== 'mainnet') { + console.log(`Skipping Telegram notification: indexer is ${indexerName}, not mainnet`); + return; + } + + // Skip historical events (before indexer launch) + if (blockTimestamp < INDEXER_LAUNCH_TIME) { + console.log( + `Skipping notification: event timestamp ${blockTimestamp} < launch time ${INDEXER_LAUNCH_TIME}` + ); return; } @@ -114,6 +131,10 @@ export async function logToTelegram( } } + // Format timestamp as human readable + const date = new Date(blockTimestamp * 1000); + const timestampStr = date.toISOString().replace('T', ' ').replace('.000Z', ' UTC'); + // Determine title based on criteria: // - If created=true, it's a Position Created // - If debt=0, it's a Position Closed @@ -132,6 +153,7 @@ export async function logToTelegram( '━━━━━━━━━━━━━━━━━━━━', title, '', + `🕐 ${timestampStr}`, `👤 Borrower: ${trove.borrower}`, `💎 Collateral: ${collateralName}`, `📊 Interest Rate: ${interestRate}%`, @@ -145,6 +167,7 @@ export async function logToTelegram( messageLines.push(`🎯 Batch: ${batchAddress}`); } + messageLines.push(`🔗 Tx: https://voyager.online/tx/${txId}`); messageLines.push('━━━━━━━━━━━━━━━━━━━━'); const message = messageLines.join('\n'); @@ -171,3 +194,118 @@ export async function logToTelegram( console.error('Error sending Telegram notification:', error); } } + +export interface AlertEventData { + txId: string; + blockTimestamp: number; + debtChange: bigint; // absolute value of debt change + collChange: bigint; // absolute value of coll change + sender: string; // redeemer or liquidator address +} + +export async function logAlertToTelegram( + alertType: 'redemption' | 'liquidation', + trove: Trove, + collId: string, + blockNumber: number, + eventData: AlertEventData, + indexerName: string +): Promise { + // Only send notifications on mainnet + if (indexerName !== 'mainnet') { + console.log(`Skipping Telegram alert: indexer is ${indexerName}, not mainnet`); + return; + } + + // Skip historical events (before indexer launch) + if (eventData.blockTimestamp < INDEXER_LAUNCH_TIME) { + console.log( + `Skipping alert: event timestamp ${eventData.blockTimestamp} < launch time ${INDEXER_LAUNCH_TIME}` + ); + return; + } + + const botToken = process.env.TELEGRAM_BOT_TOKEN_CRITICAL_ALERTS; + const chatId = process.env.TELEGRAM_CHAT_ID_CRITICAL_ALERTS; + + if (!botToken || !chatId) { + console.log('Telegram alert credentials not configured, skipping'); + return; + } + + const startBlock = process.env.START_BLOCK || START_BLOCK; + if (startBlock) { + const startBlockNum = parseInt(startBlock, 10); + if (blockNumber < startBlockNum) { + console.log(`Skipping alert: block ${blockNumber} < START_BLOCK ${startBlockNum}`); + return; + } + } + + try { + const collateralName = COLLATERAL_MAPPING[collId] || `Unknown (${collId})`; + + // Use event data for debt/coll that was redeemed/liquidated + const debtAmount = (Number(eventData.debtChange) / 1e18).toFixed(2); + const collBTC = Number(eventData.collChange) / 1e18; + + let collInfo = `${collBTC.toFixed(5)} BTC`; + if (collateralName === 'WBTC') { + const wbtcPrice = await getWBTCPrice(); + if (wbtcPrice) { + const collUSD = collBTC * wbtcPrice; + collInfo = `${collBTC.toFixed(5)} BTC ($${collUSD.toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + })})`; + } + } + + // Format timestamp as human readable + const date = new Date(eventData.blockTimestamp * 1000); + const timestampStr = date.toISOString().replace('T', ' ').replace('.000Z', ' UTC'); + + const title = alertType === 'liquidation' ? '🚨 Liquidation' : '⚠️ Redemption'; + const actorLabel = alertType === 'liquidation' ? 'Liquidator' : 'Redeemer'; + const debtLabel = alertType === 'liquidation' ? 'Debt Liquidated' : 'Debt Redeemed'; + const collLabel = alertType === 'liquidation' ? 'Coll Liquidated' : 'Coll Redeemed'; + + const messageLines = [ + '━━━━━━━━━━━━━━━━━━━━', + title, + '', + `🕐 ${timestampStr}`, + `👤 Borrower: ${trove.borrower}`, + `🎯 ${actorLabel}: ${eventData.sender}`, + `💎 Collateral: ${collateralName}`, + `💰 ${debtLabel}: ${debtAmount}`, + `🔒 ${collLabel}: ${collInfo}`, + `🔗 Tx: https://voyager.online/tx/${eventData.txId}`, + '━━━━━━━━━━━━━━━━━━━━' + ]; + + const message = messageLines.join('\n'); + + const url = `https://api.telegram.org/bot${botToken}/sendMessage`; + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + chat_id: chatId, + text: message + }) + }); + + if (!response.ok) { + const errorData = await response.text(); + console.error('Failed to send Telegram alert:', errorData); + } else { + console.log(`Telegram ${alertType} alert sent for borrower ${trove.borrower}`); + } + } catch (error) { + console.error('Error sending Telegram alert:', error); + } +}