Skip to content
Merged
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
78 changes: 73 additions & 5 deletions src/TroveManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
//
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
};
Expand Down
4 changes: 2 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
148 changes: 143 additions & 5 deletions src/telegram.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
'0': 'WBTC',
'1': 'xWBTC'
'1': 'TBTC',
'2': 'SOLVBTC'
};

// Price cache with 5-minute expiry
Expand Down Expand Up @@ -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<void> {
// 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;
}

Expand Down Expand Up @@ -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
Expand All @@ -132,6 +153,7 @@ export async function logToTelegram(
'━━━━━━━━━━━━━━━━━━━━',
title,
'',
`🕐 ${timestampStr}`,
`👤 Borrower: ${trove.borrower}`,
`💎 Collateral: ${collateralName}`,
`📊 Interest Rate: ${interestRate}%`,
Expand All @@ -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');

Expand All @@ -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<void> {
// 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);
}
}