From 010dfe08f6eebcb0e1fbd7673edbbf2c301b598f Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sun, 26 Apr 2026 18:59:36 +0200 Subject: [PATCH 1/2] Telegram alerts for expiring and expired positions (#97) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: telegram alerts for expiring soon and expired positions Ports the position-lifecycle watchers from upstream (Frankencoin PR #32, later refined to a single 24h alert in 756b50a) which never made it into this fork. When the WFPS forced-sale attack hit on 2026-04-25, no alert went out during the 46-hour window between clone setup and drain — the existing telegram service only emits PositionProposal/Challenge/Bid/Trade events and is silent for expirations. This adds two filters in doSendUpdates(): - PositionExpiringSoon (24h warning) - PositionExpired (with phase 1/2 forceSell decay schedule) Both messages mirror the upstream layout, adjusted for dEURO V2/V3 only (no V1 challenge body) and use principal instead of minted (the field name on PositionQuery in this codebase). * fix: initialize expiration state to service startup, not +1y future The existing TelegramState pattern initializes all dedup-timestamps to Date.now() + 365 days. That works for "new event" trackers (minter applied, bids, etc.) where any past event should not alert. For position expirations it does not work: expirations are absolute timestamps. With a +1y-future stateDate, the isNew predicate (stateDate < expiration*1000) is false for every realistic expiration, and the else-branch reset (stateDate = now - 5min) never triggers either because Date.now() - stateDate is permanently negative. Net effect: PositionExpired and PositionExpiringSoon alerts would never fire. Fix by initializing those two fields to service startup (Date.now()), matching the upstream Frankencoin pattern. * feat: add mini-lifetime and phase-2 watchers for full coverage Both api and monitoring should run the same four position-lifecycle watchers independently — defense in depth in case one service is down. This adds the two missing alert types here: - PositionMiniLifetime — clones with sub-day (expiration - created) lifetime, the WFPS attack pattern - PositionPhase2 — expired position has entered phase 2 of the forced-sale decay (1x -> 0x liq-price), the actionable arbitrage window Together with the existing PositionExpiringSoon and PositionExpired, this gives the same coverage as the monitoring repo. State dedup: in-memory timestamps (positionsMiniLifetime, positionsPhase2) following the same pattern as the existing fields. Phase-2 includes the same 1h self-heal as positionsExpired. * chore: cross-PR consistency fixes Aligns the api watchers with the monitoring repo so both report the same events: - Expired filter: skip positions with principal == 0 (clean exits are not actionable; matches monitoring's findUnalertedExpired) - Phase-2 filter: drop the upper bound (Date.now < phase2EndMs). Post-decay state (price = 0) is still actionable — anyone can take collateral for free and trigger coverLoss; the operator must still see it. Matches monitoring's checkExpiredInPhase2 behavior. - PositionMiniLifetime / PositionPhase2 messages: add Retained Reserve, Bal min, and collateral address fields for visual parity with PositionExpiringSoon and PositionExpired. * chore: phase-2 trigger inclusive (<=) to match monitoring Monitoring's checkExpiredInPhase2 uses `if (timePassed < cP) continue` (alerting at timePassed >= cP, inclusive). The api had a strict `phase2EntryMs < Date.now()` check, which would skip the cycle that runs at the exact phase-2 entry second. Marginal in practice (cycle resolution is 5min vs the 1s edge), but align both PRs on inclusive semantics so they fire on the same block. * fix: harden alert messages against telegram-markdown injection + dedup overlap Two real production bugs: 1. Markdown injection via on-chain ERC-20 metadata. collateralName and collateralSymbol come from the token contract and are attacker-controlled. A malicious token whose name() returns '*pwnd*[click](evil)' or even just an unbalanced '_' or '[' would make Telegram reject the entire message with 'Bad Request: can't parse entities' — silently dropping the alert. That is exactly the attack we are trying to detect (suspicious clone). Add safeMarkdown helper that replaces *, _, `, [, ], (, ) with their fullwidth Unicode counterparts so the text still reads naturally without breaking Telegram's parser. 2. Expired/phase-2 double-alert in the same cycle. If a position is first observed already past one challengePeriod (service downtime longer than cP, or backfill), both the expired and phase-2 filters match and the operator gets two alerts with overlapping content. Skip expired when alreadyInPhase2 — the phase-2 watcher fires in the same cycle with more actionable decay info. Apply safeMarkdown to the four messages (PositionExpiringSoon, PositionExpired, PositionMiniLifetime, PositionPhase2) for collateralName + collateralSymbol. * fix: initialize position-lifecycle state to epoch, not service startup The previous fix (startUpTime instead of +365d) avoided the original "alerts never fire" bug, but introduced a different miss: positions already in actionable state at service start are silently skipped, because isNew (stateDate < expiration*1000 / created*1000 > stateDate / stateDate+24h < expiration*1000) requires the action timestamp to be strictly after init. That is exactly the WFPS-restart scenario the alerts exist to cover — operator restarts the service mid-attack, and gets nothing. Initialize the four position-lifecycle state fields to 0 (epoch). The first cycle then fires for every currently actionable position, and stateDate advances normally on the second cycle. Any spam concern on initial deploy is bounded by the actual count of actionable positions, which today is 0 (and the alerts are exactly what we want to see if there are any). * fix: await + throttle between sends in position-lifecycle watchers Two related issues: 1. The new four watcher blocks called this.sendMessageAll(...) without awaiting, matching the legacy fire-and-forget pattern of the older watchers. With init=0 firing for every currently actionable position on first deploy, that is N parallel telegram sends to the same chat, guaranteed to hit the per-chat rate limit and 429 most of them. Switch to await so each send completes before the next. 2. Add a 100 ms throttle between sends. Telegram per-chat limit is ~30 msg/s; this stays well under it. Note: this does not address the underlying stateDate single-timestamp dedup pattern, which remains susceptible to losing alerts on partial delivery failure (already documented as a follow-up — needs per-address Set persisted via StorageService). The throttle reduces 429 likelihood to near zero in practice; the structural fix is a separate PR. * refactor: replace stateDate dedup with persisted per-address arrays After 9 review rounds, every remaining defect (Findings #1, #3, #4 of the latest agent review) traced back to the same root cause: the four position-lifecycle watchers used a single per-type stateDate timestamp to dedup alerts. That pattern fails on: - service restart with positions already in actionable state (init=0 fix patched the first cycle, but stateDate then advances and the same dead-zone re-opens for everything that enters the window after the first fire) - telegram outage / token rotation (the previous "return false when disabled" partial fix kept the row unmarked, but the stateDate was still set the moment ANY position in the batch alerted, so a partial-batch failure lost specific positions silently) - ponder reorg / back-fill: an older `created` appearing after stateDate is silently skipped - partial group delivery: stateDate advances even when delivery to one of multiple groups failed Replace with persisted per-address dedup: - 4 new fields on Groups DTO and TelegramGroupState (alertedMiniLifetime, alertedExpiringSoon, alertedExpired, alertedPhase2). Marked @IsOptional so existing backup files without these fields still validate; constructor initialises to []. - filters check `!alerted*.includes(addr)` instead of timestamp arithmetic - sendMessageAll and sendMessage now return Promise (true iff at least one group accepted delivery). Existing fire-and-forget callers (minter / leadrate / challenge / bid / saving / etc.) are unaffected because they ignore the return value. - position address pushed to alerted-array AND backup written ONLY when delivery succeeded; backup-after-each-alert means a service crash mid-loop does not lose previously confirmed deliveries - removed the four position-lifecycle stateDate fields from TelegramState; removed the self-heal `else` blocks (no longer needed — failed delivery just leaves the row off the alerted list and retries next cycle) Memory cost: ~50 bytes per ever-alerted position, persisted to groupsJson. Disk I/O cost: one storage.write per delivered alert. Both negligible at expected scale. --- socialmedia/telegram/dtos/groups.dto.ts | 30 ++++- .../messages/PositionExpired.message.ts | 58 ++++++++++ .../messages/PositionExpiringSoon.message.ts | 38 +++++++ .../messages/PositionMiniLifetime.message.ts | 42 +++++++ .../messages/PositionPhase2.message.ts | 52 +++++++++ socialmedia/telegram/telegram.service.ts | 107 +++++++++++++++++- socialmedia/telegram/telegram.types.ts | 9 +- utils/format.ts | 21 ++++ 8 files changed, 351 insertions(+), 6 deletions(-) create mode 100644 socialmedia/telegram/messages/PositionExpired.message.ts create mode 100644 socialmedia/telegram/messages/PositionExpiringSoon.message.ts create mode 100644 socialmedia/telegram/messages/PositionMiniLifetime.message.ts create mode 100644 socialmedia/telegram/messages/PositionPhase2.message.ts 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, ')'); +}; From 8bafe33b4327b4e26c87bf1ddb8b4af72b4df10b Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 29 Apr 2026 19:28:36 +0200 Subject: [PATCH 2/2] Redact secrets in bootstrap config log (#99) * Redact secrets in bootstrap config log logConfig() serialized the full CONFIG object at startup, which exposed coingeckoApiKey, the Alchemy mainnet/polygon RPC keys, telegram.botToken, twitter.clientSecret, twitter.appKey and twitter.appSecret to Loki. Replace sensitive values with *** before serializing. * Use generic walk-based redactConfig helper Replace the hardcoded object-spread with a small recursive walker driven by a SENSITIVE_KEYS set of dot-paths. Same behaviour, matches the helper used in the monitoring repo. --- api.config.ts | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) 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