diff --git a/api.config.ts b/api.config.ts index 488bb74..b7aa24d 100644 --- a/api.config.ts +++ b/api.config.ts @@ -63,10 +63,37 @@ export const CONFIG: ConfigType = { }, }; +const SENSITIVE_KEYS = new Set([ + 'coingeckoApiKey', + 'network.mainnet', + 'network.polygon', + 'telegram.botToken', + 'twitter.clientSecret', + 'twitter.appKey', + 'twitter.appSecret', +]); + +function redactConfig(config: T): T { + return walkRedact(config, '') as T; +} + +function walkRedact(value: unknown, path: string): unknown { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return Object.fromEntries( + Object.entries(value as Record).map(([key, val]) => { + const childPath = path ? `${path}.${key}` : key; + if (SENSITIVE_KEYS.has(childPath) && val) return [key, '***']; + return [key, walkRedact(val, childPath)]; + }) + ); + } + return value; +} + export function logConfig() { const logger = new Logger('ApiConfig'); logger.log(`Starting API with this config:`); - logger.log(JSON.stringify(CONFIG)); + logger.log(JSON.stringify(redactConfig(CONFIG))); } // Refer to https://github.com/yagop/node-telegram-bot-api/blob/master/doc/usage.md#sending-files diff --git a/socialmedia/telegram/dtos/groups.dto.ts b/socialmedia/telegram/dtos/groups.dto.ts index d6a0f69..a338425 100644 --- a/socialmedia/telegram/dtos/groups.dto.ts +++ b/socialmedia/telegram/dtos/groups.dto.ts @@ -1,4 +1,4 @@ -import { IsArray, IsNumber, IsString } from 'class-validator'; +import { IsArray, IsNumber, IsOptional, IsString } from 'class-validator'; export class Groups { // @dev: react/nextjs/ts causes type error. (lint in yarn install, deployment) @@ -8,6 +8,10 @@ export class Groups { this.createdAt = 0; this.updatedAt = 0; this.groups = []; + this.alertedMiniLifetime = []; + this.alertedExpiringSoon = []; + this.alertedExpired = []; + this.alertedPhase2 = []; } @IsString() @@ -22,4 +26,28 @@ export class Groups { @IsArray() @IsString({ each: true }) groups: string[]; + + // Per-address alert dedup. Persisting these means a service restart does not silently + // drop alerts for positions that were actionable when the service went down, and a + // telegram outage does not lose alerts (positions stay un-listed until delivery + // confirms). @IsOptional so older backup files without these fields still validate. + @IsOptional() + @IsArray() + @IsString({ each: true }) + alertedMiniLifetime: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + alertedExpiringSoon: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + alertedExpired: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + alertedPhase2: string[]; } diff --git a/socialmedia/telegram/messages/PositionExpired.message.ts b/socialmedia/telegram/messages/PositionExpired.message.ts new file mode 100644 index 0000000..1209ec4 --- /dev/null +++ b/socialmedia/telegram/messages/PositionExpired.message.ts @@ -0,0 +1,58 @@ +import { PositionQuery } from 'positions/positions.types'; +import { formatCurrency, safeMarkdown } from 'utils/format'; +import { AppUrl, ExplorerAddressUrl } from 'utils/func-helper'; +import { formatUnits } from 'viem'; + +export function PositionExpiredMessage(position: PositionQuery): string { + const bal: number = parseInt(formatUnits(BigInt(position.collateralBalance), position.collateralDecimals - 2)) / 100; + const min: number = parseInt(formatUnits(BigInt(position.minimumCollateral), position.collateralDecimals - 2)) / 100; + const price: number = parseInt(formatUnits(BigInt(position.price), 36 - position.collateralDecimals - 2)) / 100; + const duration: number = position.challengePeriod * 1000; + const collateralName = safeMarkdown(position.collateralName); + const collateralSymbol = safeMarkdown(position.collateralSymbol); + + const begin = new Date(position.expiration * 1000); + const mid = new Date(position.expiration * 1000 + 1 * duration); + const zero = new Date(position.expiration * 1000 + 2 * duration); + + const header = ` +*Position is expired* + +Position: ${position.position} (v${position.version}) +Owner: ${position.owner} + +Principal: ${formatCurrency(formatUnits(BigInt(position.principal), 18), 2, 2)} dEURO +Retained Reserve: ${formatCurrency(position.reserveContribution / 10000, 1, 1)}% +Auction Duration: ${Math.floor(position.challengePeriod / 60 / 60)} hours + +Collateral: ${collateralName} (${collateralSymbol}) +At: ${position.collateral} +Balance: ${formatCurrency(bal, 2, 2)} ${collateralSymbol} +Bal. min.: ${formatCurrency(min, 2, 2)} ${collateralSymbol} +`; + + const body = ` +*ForceSell is available* + +Declines (10x -> 1x Price): ${begin.toUTCString()} +Price (10x): ${formatCurrency(price * 10, 2, 2)} dEURO per 1 ${collateralSymbol} + +Continues (1x -> 0x Price): ${mid.toUTCString()} +Price (1x): ${formatCurrency(price, 2, 2)} dEURO per 1 ${collateralSymbol} + +Zero: ${zero.toUTCString()} +Price (0x): 0.00 dEURO per 1 ${collateralSymbol} +`; + + const footer = ` + +[Overview Position](${AppUrl(`/monitoring/${position.position}`)}) +[Buy Collateral](${AppUrl(`/monitoring/${position.position}/forceSell`)}) + +[Explorer Position](${ExplorerAddressUrl(position.position)}) +[Explorer Owner](${ExplorerAddressUrl(position.owner)}) +[Explorer Collateral](${ExplorerAddressUrl(position.collateral)}) +`; + + return header + body + footer; +} diff --git a/socialmedia/telegram/messages/PositionExpiringSoon.message.ts b/socialmedia/telegram/messages/PositionExpiringSoon.message.ts new file mode 100644 index 0000000..aae67b7 --- /dev/null +++ b/socialmedia/telegram/messages/PositionExpiringSoon.message.ts @@ -0,0 +1,38 @@ +import { PositionQuery } from 'positions/positions.types'; +import { formatCurrency, safeMarkdown } from 'utils/format'; +import { AppUrl, ExplorerAddressUrl } from 'utils/func-helper'; +import { formatUnits } from 'viem'; + +export function PositionExpiringSoonMessage(position: PositionQuery): string { + const bal: number = parseInt(formatUnits(BigInt(position.collateralBalance), position.collateralDecimals - 2)) / 100; + const min: number = parseInt(formatUnits(BigInt(position.minimumCollateral), position.collateralDecimals - 2)) / 100; + const price: number = parseInt(formatUnits(BigInt(position.price), 36 - position.collateralDecimals - 2)) / 100; + const date = new Date(position.expiration * 1000); + const collateralName = safeMarkdown(position.collateralName); + const collateralSymbol = safeMarkdown(position.collateralSymbol); + + return ` +*Position will expire soon* + +Position: ${position.position} (v${position.version}) +Owner: ${position.owner} + +Principal: ${formatCurrency(formatUnits(BigInt(position.principal), 18), 2, 2)} dEURO +Retained Reserve: ${formatCurrency(position.reserveContribution / 10000, 1, 1)}% +Auction Duration: ${Math.floor(position.challengePeriod / 60 / 60)} hours +Expiration: ${formatCurrency((position.expiration * 1000 - Date.now()) / 1000 / 60 / 60 / 24)} days +At: ${date.toUTCString()} + +Collateral: ${collateralName} (${collateralSymbol}) +At: ${position.collateral} +Balance: ${formatCurrency(bal, 2, 2)} ${collateralSymbol} +Bal. min.: ${formatCurrency(min, 2, 2)} ${collateralSymbol} +Price: ${formatCurrency(price, 2, 2)} dEURO + +[Overview Position](${AppUrl(`/monitoring/${position.position}`)}) + +[Explorer Position](${ExplorerAddressUrl(position.position)}) +[Explorer Owner](${ExplorerAddressUrl(position.owner)}) +[Explorer Collateral](${ExplorerAddressUrl(position.collateral)}) + `; +} diff --git a/socialmedia/telegram/messages/PositionMiniLifetime.message.ts b/socialmedia/telegram/messages/PositionMiniLifetime.message.ts new file mode 100644 index 0000000..b331fb7 --- /dev/null +++ b/socialmedia/telegram/messages/PositionMiniLifetime.message.ts @@ -0,0 +1,42 @@ +import { PositionQuery } from 'positions/positions.types'; +import { formatCurrency, safeMarkdown } from 'utils/format'; +import { AppUrl, ExplorerAddressUrl } from 'utils/func-helper'; +import { formatUnits } from 'viem'; + +export function PositionMiniLifetimeMessage(position: PositionQuery): string { + const lifetimeSeconds = position.expiration - position.created; + const bal: number = parseInt(formatUnits(BigInt(position.collateralBalance), position.collateralDecimals - 2)) / 100; + const min: number = parseInt(formatUnits(BigInt(position.minimumCollateral), position.collateralDecimals - 2)) / 100; + const price: number = parseInt(formatUnits(BigInt(position.price), 36 - position.collateralDecimals - 2)) / 100; + const collateralName = safeMarkdown(position.collateralName); + const collateralSymbol = safeMarkdown(position.collateralSymbol); + + return ` +*Suspicious clone detected* + +Position: ${position.position} (v${position.version}) +Owner: ${position.owner} + +Lifetime: ${lifetimeSeconds} seconds +Principal: ${formatCurrency(formatUnits(BigInt(position.principal), 18), 2, 2)} dEURO +Retained Reserve: ${formatCurrency(position.reserveContribution / 10000, 1, 1)}% +Auction Duration: ${Math.floor(position.challengePeriod / 60 / 60)} hours + +Collateral: ${collateralName} (${collateralSymbol}) +At: ${position.collateral} +Balance: ${formatCurrency(bal, 2, 2)} ${collateralSymbol} +Bal. min.: ${formatCurrency(min, 2, 2)} ${collateralSymbol} +Price: ${formatCurrency(price, 2, 2)} dEURO + +This pattern matches the WFPS forced-sale attack vector — a clone with +sub-day lifetime, set up to be drained via expiredPurchasePrice decay. +Mitigation: open a challenge or call buyExpiredCollateral once the +position enters phase 2. + +[Overview Position](${AppUrl(`/monitoring/${position.position}`)}) + +[Explorer Position](${ExplorerAddressUrl(position.position)}) +[Explorer Owner](${ExplorerAddressUrl(position.owner)}) +[Explorer Collateral](${ExplorerAddressUrl(position.collateral)}) + `; +} diff --git a/socialmedia/telegram/messages/PositionPhase2.message.ts b/socialmedia/telegram/messages/PositionPhase2.message.ts new file mode 100644 index 0000000..cd814d7 --- /dev/null +++ b/socialmedia/telegram/messages/PositionPhase2.message.ts @@ -0,0 +1,52 @@ +import { PositionQuery } from 'positions/positions.types'; +import { formatCurrency, safeMarkdown } from 'utils/format'; +import { AppUrl, ExplorerAddressUrl } from 'utils/func-helper'; +import { formatUnits } from 'viem'; + +/** + * Fired once when an expired position enters phase 2 of the forced-sale decay + * (price drops linearly from 1× to 0× liq-price over one challengePeriod). + * Phase 2 is the actionable arbitrage window before the equity reserve absorbs + * the loss via coverLoss. + */ +export function PositionPhase2Message(position: PositionQuery): string { + const bal: number = parseInt(formatUnits(BigInt(position.collateralBalance), position.collateralDecimals - 2)) / 100; + const min: number = parseInt(formatUnits(BigInt(position.minimumCollateral), position.collateralDecimals - 2)) / 100; + const price: number = parseInt(formatUnits(BigInt(position.price), 36 - position.collateralDecimals - 2)) / 100; + const collateralName = safeMarkdown(position.collateralName); + const collateralSymbol = safeMarkdown(position.collateralSymbol); + const phase2Start = new Date((position.expiration + position.challengePeriod) * 1000); + const phase2End = new Date((position.expiration + position.challengePeriod * 2) * 1000); + + return ` +*Forced-sale phase 2 entered* + +Position: ${position.position} (v${position.version}) +Owner: ${position.owner} + +Principal: ${formatCurrency(formatUnits(BigInt(position.principal), 18), 2, 2)} dEURO +Retained Reserve: ${formatCurrency(position.reserveContribution / 10000, 1, 1)}% +Auction Duration: ${Math.floor(position.challengePeriod / 60 / 60)} hours +Liq. Price: ${formatCurrency(price, 2, 2)} dEURO per 1 ${collateralSymbol} + +Phase 2 window: +Start: ${phase2Start.toUTCString()} (price = 1× liq) +End: ${phase2End.toUTCString()} (price = 0) + +Collateral: ${collateralName} (${collateralSymbol}) +At: ${position.collateral} +Balance: ${formatCurrency(bal, 2, 2)} ${collateralSymbol} +Bal. min.: ${formatCurrency(min, 2, 2)} ${collateralSymbol} + +This is the arbitrage window — call MintingHub.buyExpiredCollateral +to repay the debt at decay price before the equity reserve covers +the gap. + +[Overview Position](${AppUrl(`/monitoring/${position.position}`)}) +[Buy Collateral](${AppUrl(`/monitoring/${position.position}/forceSell`)}) + +[Explorer Position](${ExplorerAddressUrl(position.position)}) +[Explorer Owner](${ExplorerAddressUrl(position.owner)}) +[Explorer Collateral](${ExplorerAddressUrl(position.collateral)}) + `; +} diff --git a/socialmedia/telegram/telegram.service.ts b/socialmedia/telegram/telegram.service.ts index 6b22c2b..da0a4d7 100644 --- a/socialmedia/telegram/telegram.service.ts +++ b/socialmedia/telegram/telegram.service.ts @@ -21,12 +21,19 @@ import { LeadrateProposalMessage } from './messages/LeadrateProposal.message'; import { MinterProposalMessage } from './messages/MinterProposal.message'; import { MinterProposalVetoedMessage } from './messages/MinterProposalVetoed.message'; import { MintingUpdateMessage } from './messages/MintingUpdate.message'; +import { PositionExpiredMessage } from './messages/PositionExpired.message'; +import { PositionExpiringSoonMessage } from './messages/PositionExpiringSoon.message'; +import { PositionMiniLifetimeMessage } from './messages/PositionMiniLifetime.message'; +import { PositionPhase2Message } from './messages/PositionPhase2.message'; import { PositionProposalMessage } from './messages/PositionProposal.message'; import { SavingUpdateMessage } from './messages/SavingUpdate.message'; import { StablecoinBridgeMessage } from './messages/StablecoinBridgeUpdate.message'; import { TradeMessage } from './messages/Trade.message'; import { TelegramGroupState, TelegramState } from './telegram.types'; +// Stay under telegram per-chat rate limit (~30 msg/s) when bursting position-lifecycle alerts. +const TELEGRAM_THROTTLE_MS = 100; + @Injectable() export class TelegramService implements OnModuleInit, SocialMediaFct { private readonly logger = new Logger(this.constructor.name); @@ -60,6 +67,10 @@ export class TelegramService implements OnModuleInit, SocialMediaFct { createdAt: time, updatedAt: time, groups: [], + alertedMiniLifetime: [], + alertedExpiringSoon: [], + alertedExpired: [], + alertedPhase2: [], }; } @@ -164,6 +175,79 @@ export class TelegramService implements OnModuleInit, SocialMediaFct { } } + // Position-lifecycle alerts use per-address dedup (alerted* arrays in + // telegramGroupState, persisted via writeBackupGroups). Compared to a single + // stateDate timestamp this correctly handles: service restart with already- + // actionable positions, telegram outage (positions stay un-listed until delivered), + // ponder reorg/back-fill, and partial group delivery. + const openPositions = Object.values(this.position.getPositionsOpen().map); + const miniLifetimeThreshold = 24 * 60 * 60; // 1 day, in seconds (position timestamps are seconds) + const warningWindowMs = 24 * 60 * 60 * 1000; + + // Mini-lifetime clones — sub-day (expiration - created) lifetime, the WFPS attack pattern. + const miniLifetimePositions = openPositions.filter((p) => { + if (this.telegramGroupState.alertedMiniLifetime.includes(p.position.toLowerCase())) return false; + return p.expiration - p.created < miniLifetimeThreshold; + }); + for (const p of miniLifetimePositions) { + const delivered = await this.sendMessageAll(PositionMiniLifetimeMessage(p)); + if (delivered) { + this.telegramGroupState.alertedMiniLifetime.push(p.position.toLowerCase()); + await this.writeBackupGroups(); + } + await this.sleep(TELEGRAM_THROTTLE_MS); + } + + // Positions expiring within the next warning window. + const expiringSoonPositions = openPositions.filter((p) => { + if (this.telegramGroupState.alertedExpiringSoon.includes(p.position.toLowerCase())) return false; + return p.expiration * 1000 < Date.now() + warningWindowMs; + }); + for (const p of expiringSoonPositions) { + const delivered = await this.sendMessageAll(PositionExpiringSoonMessage(p)); + if (delivered) { + this.telegramGroupState.alertedExpiringSoon.push(p.position.toLowerCase()); + await this.writeBackupGroups(); + } + await this.sleep(TELEGRAM_THROTTLE_MS); + } + + // Positions expired with outstanding principal — clean exits are not actionable. + // Skip if already in phase 2: the phase-2 watcher fires for it instead. + const expiredPositions = openPositions.filter((p) => { + if (this.telegramGroupState.alertedExpired.includes(p.position.toLowerCase())) return false; + if (BigInt(p.principal || '0') === 0n) return false; + const isExpired = p.expiration * 1000 < Date.now(); + const alreadyInPhase2 = (p.expiration + p.challengePeriod) * 1000 <= Date.now(); + return isExpired && !alreadyInPhase2; + }); + for (const p of expiredPositions) { + const delivered = await this.sendMessageAll(PositionExpiredMessage(p)); + if (delivered) { + this.telegramGroupState.alertedExpired.push(p.position.toLowerCase()); + await this.writeBackupGroups(); + } + await this.sleep(TELEGRAM_THROTTLE_MS); + } + + // Forced-sale phase 2 — actionable arbitrage window (1× → 0× liq-price). No upper + // bound: post-decay (price = 0) is still actionable, anyone can take collateral for + // free and trigger coverLoss; the operator should still be alerted. + const phase2Positions = openPositions.filter((p) => { + if (this.telegramGroupState.alertedPhase2.includes(p.position.toLowerCase())) return false; + if (BigInt(p.principal || '0') === 0n) return false; + const phase2EntryMs = (p.expiration + p.challengePeriod) * 1000; + return phase2EntryMs <= Date.now(); // inclusive — match monitoring's `timePassed >= cP` + }); + for (const p of phase2Positions) { + const delivered = await this.sendMessageAll(PositionPhase2Message(p)); + if (delivered) { + this.telegramGroupState.alertedPhase2.push(p.position.toLowerCase()); + await this.writeBackupGroups(); + } + await this.sleep(TELEGRAM_THROTTLE_MS); + } + // Challenges started const challengesStarted = Object.values(this.challenge.getChallengesMapping().map).filter( (c) => parseInt(c.created.toString()) * 1000 > this.telegramState.challenges @@ -223,17 +307,27 @@ export class TelegramService implements OnModuleInit, SocialMediaFct { this.sendMessageAll(messageInfo[0], messageInfo[1]); } - private async sendMessageAll(message: string, video?: string) { - if (this.telegramGroupState.groups.length == 0) return; + /** + * Send to all groups. Returns true iff delivery succeeded to at least one group + * (or there are no groups configured — nothing to deliver to). Callers that need + * delivery confirmation (e.g. for per-address alert dedup) should check the return + * value before marking the alert as sent. + */ + private async sendMessageAll(message: string, video?: string): Promise { + if (this.telegramGroupState.groups.length == 0) return true; + let anyDelivered = false; for (const group of this.telegramGroupState.groups) { - await this.sendMessage(group, message, video); + const ok = await this.sendMessage(group, message, video); + if (ok) anyDelivered = true; } + return anyDelivered; } - private async sendMessage(group: string | number, message: string, video?: string) { + private async sendMessage(group: string | number, message: string, video?: string): Promise { try { this.logger.log(`Sending message to group id: ${group}`); video ? await this.doSendVideo(group, message, video) : await this.doSendMessage(group, message); + return true; } catch (error) { const msg = { notFound: 'chat not found', @@ -257,6 +351,7 @@ export class TelegramService implements OnModuleInit, SocialMediaFct { } else { this.logger.warn(error); } + return false; } } @@ -315,4 +410,8 @@ export class TelegramService implements OnModuleInit, SocialMediaFct { this.writeBackupGroups(); } + + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } } diff --git a/socialmedia/telegram/telegram.types.ts b/socialmedia/telegram/telegram.types.ts index 814f058..4158b4c 100644 --- a/socialmedia/telegram/telegram.types.ts +++ b/socialmedia/telegram/telegram.types.ts @@ -1,4 +1,7 @@ -// @dev: timestamps of last trigger emits +// @dev: timestamps of last trigger emits. Position-lifecycle events use per-address +// dedup persisted in TelegramGroupState (alerted* arrays) instead of a single timestamp, +// because a single timestamp cannot correctly handle: service restart with already- +// actionable positions, telegram outage, ponder reorg/back-fill, or partial delivery. export type TelegramState = { minterApplied: number; minterVetoed: number; @@ -21,4 +24,8 @@ export type TelegramGroupState = { createdAt: number; updatedAt: number; groups: string[]; + alertedMiniLifetime: string[]; + alertedExpiringSoon: string[]; + alertedExpired: string[]; + alertedPhase2: string[]; }; diff --git a/utils/format.ts b/utils/format.ts index f9bcc3a..16badfb 100644 --- a/utils/format.ts +++ b/utils/format.ts @@ -14,3 +14,24 @@ export const formatCurrency = (value: string | number, minimumFractionDigits = 2 return formatter.format(amount); }; + +/** + * Sanitize untrusted strings before interpolating them into Telegram Markdown V1 messages. + * + * On-chain values like ERC-20 `name()` / `symbol()` are attacker-controlled. A token whose + * name is `*x*` or contains an unbalanced `[` will either be rendered as formatting or cause + * Telegram to reject the entire message with `Bad Request: can't parse entities` — silently + * dropping the alert. We replace the special chars with their fullwidth Unicode counterparts + * so the text reads naturally without breaking parsing. + */ +export const safeMarkdown = (value: string | undefined | null): string => { + if (!value) return ''; + return value + .replace(/\*/g, '*') + .replace(/_/g, '_') + .replace(/`/g, '`') + .replace(/\[/g, '[') + .replace(/\]/g, ']') + .replace(/\(/g, '(') + .replace(/\)/g, ')'); +};