From 1f2bb1d00b7da5921d33a908ab1bd657ebffc61c Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:29:20 +0200 Subject: [PATCH] 007697c8 - Alert before protocol equity reaches MINIMUM_EQUITY (#84) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Alert before protocol equity reaches MINIMUM_EQUITY (#83) deuro.equity() was already read every cycle and persisted as DeuroState.reserveEquity, but nothing alerted on it. Two paths in Equity.sol are gated on that value falling below MINIMUM_EQUITY (1'000 dEURO): _calculateShares takes its bootstrap branch and assigns a flat 10'000'000 nDEPS for a single deposit, and restructureCapTable becomes callable and burns the balances of the holders passed to it. Both gates are evaluated per transaction, so a single block below the threshold is enough. The watcher computes headroom = reserveEquity - MINIMUM_EQUITY from the value already fetched — no new contract call — and pages on two tiers: a configurable warning floor, and critical once headroom is gone or the trend projects it gone within a configurable horizon. deuro_state is a single upserted row and keeps no history, so the trend had nowhere to come from. A new equity_samples table takes one sample per cycle (30-day retention) and the watcher fits an ordinary least-squares slope over the last 24 h of it; a series that is too thin or too short is never projected from. Dedup, escalation and re-arm follow the PositionState.miniLifetimeAlertedAt pattern: the marker is written only after telegramService.sendCriticalAlert confirms delivery, so a failed send is retried next cycle. A WARNING -> CRITICAL step bypasses the repeat window, a demoted level never lowers the stored marker, and only a recovery above the floor clears it and sends a resolve message. MINIMUM_EQUITY is a private constant of Equity.sol with no on-chain getter (eth_call reverts), so it is hardcoded next to the logic. The alert body names the mitigation, because it is neither obvious nor expensive: equity() is balanceOf(reserve) - minterReserve() and reserve() is the Equity contract itself, so a plain dEURO transfer to that contract raises the headroom 1:1 without minting shares and without a vote. * Correct the alert copy and harden the headroom edge cases Review findings on the first commit: equity() is clamped at zero — DecentralizedEURO.sol returns 0 when balanceOf(reserve) <= minterReserve() instead of a negative number. The mitigation text claimed a transfer raises the headroom 1:1, which is false during a reserve deficit: equity() stays at 0 and the transfer has no effect until the deficit is covered. An operator following that text under pressure would act into the void. The copy now states the clamp, and the alert gains a "Reserve deficit" line — computed from reserveTotal and reserveMinter, which the state already carries — that names how much has to arrive before the headroom moves at all. Both contract gates compare strictly (capitalBefore < MINIMUM_EQUITY and require(dEURO.equity() < MINIMUM_EQUITY)), so at exactly zero headroom neither path is open yet. Alerting there stays as a safety margin, but the wording no longer claims the paths are already live; the reason text now distinguishes reaching the boundary from crossing it. Three edge cases in the pure logic: - computeTrend accepted non-finite timestamps. NaN comparisons are always false, so neither the span guard nor the zero-variance guard caught them and the result was usable:true with a NaN slope. Guarded at both ends now. - formatDeuro printed -0.00 for sub-cent negative amounts, because the sign was taken before cent truncation. - warningFloorWei=0n is documented to mute the warning tier, but a negative headroom still collected a "below warning floor of 0.00" reason into the critical message. The formatDeuro doc comment overstated its precision guarantee and now says what actually holds. Four tests added for the new edge cases (62 total). * Give warnings and the all-clear their own Telegram header Second review round on this branch. Every headroom message went through sendCriticalAlert, which stamps "🚨 CRITICAL ALERT" on whatever it is handed. A warning arrived under a critical banner, and so did the all-clear — the graduated severity this watcher is built around never reached the operator. TelegramService gains sendAlert(icon, header, message); sendCriticalAlert keeps its signature and behaviour and now delegates to it, so the twelve existing call sites are untouched. The watcher passes its own header per level, and the first line of the body no longer repeats what the header already says. The message also claimed both gated contract paths go live the moment headroom turns negative. Only restructureCapTable does. Equity._invest reverts with InsufficientEquity when post-transfer equity is still below MINIMUM_EQUITY, so the bootstrap branch needs a deposit large enough to lift equity back over the line in the same transaction. Said properly now, in the alert body and in the trigger reason. The reserve-deficit line was off by one wei: equity() clamps at balance <= minterReserve, so covering exactly the deficit reaches equality and still yields zero. It now says the headroom starts moving once the balance exceeds the minter reserve. Comment corrections, all of them things that were not true as written: - the watcher registration still described the pre-correction boundary - "NaN/Infinity comparisons are always false" holds for NaN only - the varX === 0 branch is unreachable behind the span guard, and now says so - escalation bypasses the repeat window only out of a WARNING marker; once a reminder has raised the stored level to CRITICAL, a relapse waits for the window. That is the price of the flutter guard and is now written down - the second inconsistency throw is redundant (strictNullChecks is off), kept as fail-loud defence Tests: the "identical timestamps" case was passing for the wrong reason — it never reached the zero-variance guard it claimed to cover, the span guard returns first. Renamed to what it actually exercises. The non-finite guard is now parametrised over NaN, Infinity and -Infinity, and a new case pins the accepted CRITICAL → reminder → relapse suppression (65 total). * Let computeTrend enforce its own window, and stop projecting the past Third review round on this branch. Two hardenings and two comments that described something the code does not do. computeTrend documented TREND_WINDOW_HOURS as its lookback but never enforced it — only the caller's DB query did. That works today and fails quietly the moment anything else calls it: a single old sample outside the window can turn a steeply falling series into a rising slope and the projection then never fires. It now filters relative to its newest sample, so the constant is enforced where it is defined; the caller's query stays as an efficiency measure. Trigger (2) fired on an already-breached floor as well, because hoursToDepletion is 0 there. The message then carried both "equity is below MINIMUM_EQUITY" and "projected to deplete in 0.0 h" — a forecast of something that has happened. The projection now requires positive headroom. Classification is unchanged; trigger (1) already covers that case. decideAction now rejects a non-positive repeat window instead of turning every cycle into a reminder. Not reachable through the current caller, which passes a constant, but this module fails loud rather than degrade. Comment corrections: the module header still described the bootstrap branch as opening on any deposit below MINIMUM_EQUITY (it needs one large enough to lift equity back over the line), and hoursToDepletion is documented as null-when-not-falling while it returns 0 for a breached headroom. Ten tests added (75 total): empty and single-sample series, the window filter, a non-finite slope reached through a finite series, a non-linear irregularly spaced fixture with a hand-derived OLS expectation that a naive two-point slope would fail, and the classification boundaries — headroom exactly at the floor, depletion exactly at the horizon, a muted floor that still allows a critical projection, and a breached headroom that no longer claims to project. * Make the four descriptions of the bootstrap gate agree Fourth review round. Neither lens found a behaviour defect this time; what was left was prose that had drifted and tests that did not pin what their names claimed. The same contract mechanic is described in four places — the logic module header, the checkEquityHeadroom docstring, .env.example and the alert text itself. The qualifier added earlier (the bootstrap branch needs a deposit large enough to lift equity back over MINIMUM_EQUITY, otherwise Equity._invest reverts with InsufficientEquity) had only reached two of them. All four now say the same thing, and the three short ones stay short so they are less likely to drift apart again. The watcher registration comment claimed the alert fires when equity reaches MINIMUM_EQUITY. It has three triggers: the configured warning floor, well above that; a projected breach within the horizon; and the boundary itself. computeTrend's docstring described the regression but not the window it enforces since the last commit, so the returned sampleCount and spanHours looked like they described the raw input. Documented, along with why the caller's DB query is a load bound rather than the window: the query cuts at wall-clock now, computeTrend cuts relative to the newest sample, and since that sample is never in the future its cutoff is always the earlier one — the internal filter can never drop what the query returned. Four tests added (79 total): the window boundary is inclusive to the millisecond, a series that only becomes unusable after filtering reports the filtered metadata, zero headroom with a falling trend emits no depletion projection (a guard against >= creeping back into that condition), and the repeat-window guard rejects negative values too — the existing test only covered zero, so narrowing it to === 0n would have gone unnoticed. * Fail loudly on an empty threshold, and stop overclaiming in comments Fifth review round. The two new thresholds fell back to their defaults through `||`, which also swallows a variable that is set but empty. These two values decide whether the alert fires at all, so an unparseable value has to reach the existing validation instead of vanishing. The default now applies only when the variable is unset. Verified against the real factory: unset gives 5000, "0" stays 0 (it mutes the warning tier), and both an empty value and garbage now fail validation at boot. Two comments claimed more than the code guarantees — the same failure the last round was about: - computeTrend's docstring said sampleCount and spanHours always describe the filtered window. On the early non-finite-timestamp path they do not: it returns the raw sorted length with a span of 0, before any window is formed. - The note on the sample query said computeTrend's cutoff can never drop what the query returned. That holds only while no sample timestamp sits in the future. If the clock jumps backward, computeTrend's window becomes the later bound and does drop samples — which is its documented semantics, but not what the comment promised. The test for a series that only becomes unusable after filtering asserted against a hardcoded span of 93 hours rather than the span of the samples it had just built, so a drifting fixture would have kept the sanity check green. It now derives the span from the timestamps. * Test the marker-after-delivery guarantee, and reject partial numbers Sixth review round. The feature's core promise — the dedup marker is written only after Telegram confirms delivery — had no test. Neither did the recovery path. deuro.service.spec.ts now drives checkEquityHeadroom against a stubbed repository and a stubbed sender: a quiet state alerts nothing, a confirmed warning persists the marker, a failed send leaves it untouched so the next cycle retries, a confirmed recovery clears it, a failed all-clear does not, and each level arrives under its own header. The thresholds still accepted a partially numeric value: parseInt('72h') is 72, so EQUITY_HEADROOM_PROJECTION_HOURS=72h would have booted with a plausible-looking wrong horizon. Number() is not the fix — Number('') is 0, and 0 on the warning floor means "mute the warning tier", so an empty value would silently disable the warning instead of failing. A strict integer check rejects both. monitoring.config.spec.ts pins the whole contract: unset, "0", explicit value, empty, non-numeric, partially numeric, surrounding whitespace, and 0 for the horizon, which @Min(1) must reject even though the floor accepts it. Two comment corrections. decideAction said an escalation "must always page" — it is a pure function that can only decide; delivery belongs to the service layer. And the note on the sample query described the clock skew case but not the ordinary one: after an outage the wall-clock query is the tighter bound, so the regression runs over less than TREND_WINDOW_HOURS — fewer data points, not a wrong result. .env.example now states that the defaults apply only when a variable is unset, and that anything set but unparseable stops the boot. 94 tests across 5 suites. * Cover escalation, reminder and the empty first run Seventh review round. Three orchestration paths had no service-level test. The escalation is the one that matters in a real incident: a stored WARNING and a headroom that has since reached zero must page immediately, in the middle of the repeat window, or the deterioration is swallowed by the dedup it was supposed to escape. Also covered now: the reminder that fires once the window has elapsed, and the very first run, where no state is persisted yet and the watcher must return without touching Telegram or the marker. parseIntegerEnv accepted '-1' as a well-formed integer and left the rejection to @Min(0), but refused '+5000' outright. The asymmetry was arbitrary; both signs parse now, and the tests pin which layer rejects what — format for exponential and hexadecimal notation, the value constraint for a negative floor. 101 tests across 5 suites. * Make the negative-floor test prove which layer rejects it Eighth review round, one finding. The test named "rejects a negative warning floor via @Min(0), not the format regex" asserted only that something throws. Drop the minus from the integer pattern and '-1' would fail at the format check instead — earlier, for a different reason — and the test would stay green while its name became false. It now asserts that the validation error names the min constraint and does not name isNumber, so the layer that rejects the value is pinned, not just the fact that it is rejected. * Describe the constraint check as it actually works Ninth review round, one comment. The note on the negative-floor test said a broken regex would make @IsNumber "fail first", implying class-validator stops at the first violation. It does not: NaN violates @Min(0) and @IsNumber at once and both names land in the message. The assertion was right for that reason, not despite it — the comment now says so. --- .env.example | 26 + src/config/config.service.ts | 8 + src/config/monitoring.config.spec.ts | 105 +++ src/config/monitoring.config.ts | 34 + src/monitoringV2/deuro.service.spec.ts | 222 ++++++ src/monitoringV2/deuro.service.ts | 202 +++++ .../equity-headroom.logic.spec.ts | 747 ++++++++++++++++++ src/monitoringV2/equity-headroom.logic.ts | 344 ++++++++ src/monitoringV2/monitoring.service.ts | 6 + .../0006_equity_headroom_alert/migration.sql | 17 + .../prisma/repositories/deuro.repository.ts | 60 ++ src/monitoringV2/prisma/schema.prisma | 16 + src/monitoringV2/telegram.service.ts | 22 +- 13 files changed, 1803 insertions(+), 6 deletions(-) create mode 100644 src/config/monitoring.config.spec.ts create mode 100644 src/monitoringV2/deuro.service.spec.ts create mode 100644 src/monitoringV2/equity-headroom.logic.spec.ts create mode 100644 src/monitoringV2/equity-headroom.logic.ts create mode 100644 src/monitoringV2/prisma/migrations/0006_equity_headroom_alert/migration.sql diff --git a/.env.example b/.env.example index ac76da3..fef71e7 100644 --- a/.env.example +++ b/.env.example @@ -92,3 +92,29 @@ GECKOTERMINAL_BASE_URL=http://pricing-proxy:8080/geckoterminal # than 1h is IRREVERSIBLY denied — independent of price. The attack heuristic still # runs inside the grace for fast protection. Use GUARD_POSITION_ALLOWLIST_FILE to # exempt any known-good position from this net. + +# Equity headroom alert (Issue #83) — always on, no enable flag. +# +# headroom = deuro.equity() - MINIMUM_EQUITY, where MINIMUM_EQUITY = 1'000 dEURO is a PRIVATE +# constant of Equity.sol (no on-chain getter, hardcoded in equity-headroom.logic.ts). Below it, +# Equity._calculateShares takes its bootstrap branch (flat 10'000'000 nDEPS for a single deposit, +# regardless of size) — but only for a deposit that lifts equity back to at least MINIMUM_EQUITY +# in the same transaction, otherwise Equity._invest reverts first — and Equity.restructureCapTable +# becomes callable (burns the nDEPS of the addresses passed in; only further gated by the 2% vote +# quorum). Both gates are evaluated per transaction, so a single block below the threshold is enough. +# +# EQUITY_HEADROOM_WARNING_DEURO Warning floor in whole dEURO of headroom. Default 5000, chosen +# above the most recent realized single coverLoss (4'623.86 dEURO on +# 2026-04-25), NOT above the daily drift. Set to 0 to mute the warning tier; +# the critical tier (headroom <= 0, or a projected crossing) stays active. +# The default applies only when the variable is unset; a set but empty or +# non-integer value (e.g. 72h) fails boot with a validation error instead of +# silently falling back to the default. +# EQUITY_HEADROOM_WARNING_DEURO=5000 +# +# EQUITY_HEADROOM_PROJECTION_HOURS Critical when the trend over the last 24 h projects headroom +# to reach zero within this horizon. Default 72. +# The default applies only when the variable is unset; a set but empty or +# non-integer value (e.g. 72h) fails boot with a validation error instead of +# silently falling back to the default. +# EQUITY_HEADROOM_PROJECTION_HOURS=72 diff --git a/src/config/config.service.ts b/src/config/config.service.ts index 818a254..6b8fa45 100644 --- a/src/config/config.service.ts +++ b/src/config/config.service.ts @@ -115,4 +115,12 @@ export class AppConfigService { get guardPositionAllowlistFile(): string | undefined { return this.monitoringConfig.guardPositionAllowlistFile || undefined; } + + get equityHeadroomWarningDeuro(): number { + return this.monitoringConfig.equityHeadroomWarningDeuro; + } + + get equityHeadroomProjectionHours(): number { + return this.monitoringConfig.equityHeadroomProjectionHours; + } } diff --git a/src/config/monitoring.config.spec.ts b/src/config/monitoring.config.spec.ts new file mode 100644 index 0000000..da27a33 --- /dev/null +++ b/src/config/monitoring.config.spec.ts @@ -0,0 +1,105 @@ +import monitoringConfig from './monitoring.config'; + +// Factory contract for the two equity-headroom env vars: defaults only when unset; a set but +// empty / non-integer / partially numeric value must fail validation loudly (never silent fallback). +// Required siblings (rpcUrl, databaseUrl) have no factory default, so every case sets them first. + +const ORIGINAL_ENV = process.env; + +beforeEach(() => { + process.env = { ...ORIGINAL_ENV }; + process.env.RPC_URL = 'https://example.com'; + process.env.DATABASE_URL = 'postgresql://user:pass@host:5432/db'; + process.env.COINGECKO_BASE_URL = 'http://localhost:8080'; + delete process.env.EQUITY_HEADROOM_WARNING_DEURO; + delete process.env.EQUITY_HEADROOM_PROJECTION_HOURS; +}); + +afterEach(() => { + process.env = ORIGINAL_ENV; +}); + +describe('monitoringConfig equity headroom env', () => { + it('applies defaults when neither equity-headroom variable is set', () => { + const config = monitoringConfig(); + expect(config.equityHeadroomWarningDeuro).toBe(5000); + expect(config.equityHeadroomProjectionHours).toBe(72); + }); + + it('accepts 0 as the warning floor (valid mute of the warning tier)', () => { + // `0` is intentional operator config — must not be treated as "unparseable" or fall back to 5000. + process.env.EQUITY_HEADROOM_WARNING_DEURO = '0'; + expect(monitoringConfig().equityHeadroomWarningDeuro).toBe(0); + }); + + it('accepts an explicit non-default warning floor', () => { + process.env.EQUITY_HEADROOM_WARNING_DEURO = '7500'; + expect(monitoringConfig().equityHeadroomWarningDeuro).toBe(7500); + }); + + it('rejects an empty warning floor (set but blank must fail loudly)', () => { + // Empty string is "set" — Number('') would become 0 and silently mute the warning tier. + process.env.EQUITY_HEADROOM_WARNING_DEURO = ''; + expect(() => monitoringConfig()).toThrow(); + }); + + it('rejects a non-numeric warning floor', () => { + process.env.EQUITY_HEADROOM_WARNING_DEURO = 'abc'; + expect(() => monitoringConfig()).toThrow(); + }); + + it('rejects a partially numeric warning floor (parseInt would accept the leading digits)', () => { + // parseInt('12000EUR') === 12000 — the strict path must refuse this. + process.env.EQUITY_HEADROOM_WARNING_DEURO = '12000EUR'; + expect(() => monitoringConfig()).toThrow(); + }); + + it('rejects a partially numeric projection horizon', () => { + // parseInt('72h') === 72 — same silent-acceptance trap as the warning floor. + process.env.EQUITY_HEADROOM_PROJECTION_HOURS = '72h'; + expect(() => monitoringConfig()).toThrow(); + }); + + it('rejects 0 as the projection horizon (@Min(1) — unlike the warning floor)', () => { + process.env.EQUITY_HEADROOM_PROJECTION_HOURS = '0'; + expect(() => monitoringConfig()).toThrow(); + }); + + it('accepts a warning floor surrounded by whitespace (trim before the integer check)', () => { + process.env.EQUITY_HEADROOM_WARNING_DEURO = ' 5000 '; + expect(monitoringConfig().equityHeadroomWarningDeuro).toBe(5000); + }); + + it('accepts a warning floor with an explicit plus sign', () => { + process.env.EQUITY_HEADROOM_WARNING_DEURO = '+5000'; + expect(monitoringConfig().equityHeadroomWarningDeuro).toBe(5000); + }); + + it('rejects exponential notation as a warning floor', () => { + // Number('5e3') === 5000, but exponential form is not a strict integer format. + process.env.EQUITY_HEADROOM_WARNING_DEURO = '5e3'; + expect(() => monitoringConfig()).toThrow(); + }); + + it('rejects hexadecimal notation as a warning floor', () => { + // Hex is not a strict integer format for this env var. + process.env.EQUITY_HEADROOM_WARNING_DEURO = '0x10'; + expect(() => monitoringConfig()).toThrow(); + }); + + it('rejects a negative warning floor via @Min(0), not the format regex', () => { + // '-1' must pass the integer format check and fail only at @Min(0). A bare toThrow() would not + // show that: a regex that rejected the minus would yield NaN, and NaN violates @Min(0) AND + // @IsNumber. class-validator does not stop at the first violation, so the message would then + // carry 'isNumber' next to 'min' — its absence is what proves the value reached @Min(0) as a number. + expect.assertions(3); + process.env.EQUITY_HEADROOM_WARNING_DEURO = '-1'; + try { + monitoringConfig(); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain('min'); + expect((error as Error).message).not.toContain('isNumber'); + } + }); +}); diff --git a/src/config/monitoring.config.ts b/src/config/monitoring.config.ts index dc0be56..cd0fffb 100644 --- a/src/config/monitoring.config.ts +++ b/src/config/monitoring.config.ts @@ -98,6 +98,27 @@ export class MonitoringConfig { @IsOptional() @IsString() guardPositionAllowlistFile?: string; + + @Transform(({ value }) => parseInt(value)) + @IsNumber() + @Min(0) + equityHeadroomWarningDeuro: number; + + @Transform(({ value }) => parseInt(value)) + @IsNumber() + @Min(1) + equityHeadroomProjectionHours: number; +} + +/** + * Parse an env value that must be a plain integer. Returns NaN for anything else — including an + * empty value and a partially numeric one — so the class-validator check in the factory rejects it + * and the boot fails loudly. `parseInt` alone would accept '72h' as 72, and `Number` would turn an + * empty value into 0, which for the warning floor silently means "mute the warning tier". + */ +function parseIntegerEnv(value: string): number { + const trimmed = value.trim(); + return /^[+-]?\d+$/.test(trimmed) ? Number(trimmed) : NaN; } export default registerAs('monitoring', () => { @@ -128,6 +149,19 @@ export default registerAs('monitoring', () => { config.guardEnabled = (process.env.GUARD_ENABLED || 'false').toLowerCase() === 'true'; config.guardPrivateKey = process.env.GUARD_PRIVATE_KEY || ''; config.guardPositionAllowlistFile = process.env.GUARD_POSITION_ALLOWLIST_FILE || ''; + // Defaults apply only when the variable is unset. A variable that IS set but empty, non-numeric, or + // only partially numeric (e.g. '72h', '12000EUR' — parseInt would silently accept the leading digits) + // must fail validation loudly rather than fall back silently — these two thresholds decide whether + // the equity-headroom alert fires at all. Note `0` is a valid value for the warning floor (it mutes + // the warning tier), so it must survive this path unchanged. + config.equityHeadroomWarningDeuro = 5000; + if (process.env.EQUITY_HEADROOM_WARNING_DEURO !== undefined) { + config.equityHeadroomWarningDeuro = parseIntegerEnv(process.env.EQUITY_HEADROOM_WARNING_DEURO); + } + config.equityHeadroomProjectionHours = 72; + if (process.env.EQUITY_HEADROOM_PROJECTION_HOURS !== undefined) { + config.equityHeadroomProjectionHours = parseIntegerEnv(process.env.EQUITY_HEADROOM_PROJECTION_HOURS); + } const errors = validateSync(plainToClass(MonitoringConfig, config)); if (errors.length > 0) throw new Error(`Config validation failed: ${errors}`); diff --git a/src/monitoringV2/deuro.service.spec.ts b/src/monitoringV2/deuro.service.spec.ts new file mode 100644 index 0000000..2762332 --- /dev/null +++ b/src/monitoringV2/deuro.service.spec.ts @@ -0,0 +1,222 @@ +import { DeuroService } from './deuro.service'; +import { DeuroState } from './types'; +import { MINIMUM_EQUITY_WEI, REPEAT_ALERT_HOURS } from './equity-headroom.logic'; + +// Orchestration only: checkEquityHeadroom reads state → assesses → decides → sends Telegram → +// writes/clears the dedup marker ONLY after confirmed delivery. Core guarantee under test. + +const WEI = 10n ** 18n; + +const configStub = { + equityHeadroomWarningDeuro: 5000, + equityHeadroomProjectionHours: 72, + blockchainId: 1, // Mainnet — ADDRESS[1].equity is used in the alert body only +}; + +/** Full DeuroState with zeros; reserveTotal >= reserveMinter so the deficit line stays neutral. */ +function makeState(overrides: Partial = {}): DeuroState { + return { + deuroTotalSupply: 0n, + depsTotalSupply: 0n, + equityShares: 0n, + equityPrice: 0n, + reserveTotal: 0n, + reserveMinter: 0n, + reserveEquity: 0n, + savingsTotal: 0n, + savingsInterestCollected: 0n, + savingsRate: 0, + deuroLoss: 0n, + deuroProfit: 0n, + deuroProfitDistributed: 0n, + frontendFeesCollected: 0n, + frontendsActive: 0, + usdToEurRate: 0, + usdToChfRate: 0, + savingsInterestCollected24h: 0n, + savingsAdded24h: 0n, + savingsWithdrawn24h: 0n, + equityTradeVolume24h: 0n, + equityTradeCount24h: 0, + equityDelegations24h: 0, + blockNumber: 1n, + timestamp: new Date(), + ...overrides, + }; +} + +describe('DeuroService.checkEquityHeadroom', () => { + let deuroRepoStub: { + getState: jest.Mock; + findEquitySamplesSince: jest.Mock; + getHeadroomAlertState: jest.Mock; + setHeadroomAlertState: jest.Mock; + clearHeadroomAlertState: jest.Mock; + }; + let telegramServiceStub: { sendAlert: jest.Mock }; + let service: DeuroService; + + beforeEach(() => { + deuroRepoStub = { + getState: jest.fn(), + findEquitySamplesSince: jest.fn(), + getHeadroomAlertState: jest.fn(), + setHeadroomAlertState: jest.fn(), + clearHeadroomAlertState: jest.fn(), + }; + telegramServiceStub = { sendAlert: jest.fn() }; + // Only config + deuroRepo are used by checkEquityHeadroom; strictNullChecks is off. + service = new DeuroService(configStub as any, deuroRepoStub as any, null, null, null, null); + // Empty samples → trend unusable; level comes from headroom vs floor alone. + deuroRepoStub.findEquitySamplesSince.mockResolvedValue([]); + }); + + it('does not alert when headroom is comfortably above the warning floor', async () => { + // Headroom 50_000 dEURO >> floor 5_000 → level null, no stored marker → action none. + deuroRepoStub.getState.mockResolvedValue(makeState({ reserveEquity: MINIMUM_EQUITY_WEI + 50_000n * WEI })); + deuroRepoStub.getHeadroomAlertState.mockResolvedValue({ alertedAt: null, level: null }); + + await service.checkEquityHeadroom(telegramServiceStub as any); + + expect(telegramServiceStub.sendAlert).not.toHaveBeenCalled(); + expect(deuroRepoStub.setHeadroomAlertState).not.toHaveBeenCalled(); + expect(deuroRepoStub.clearHeadroomAlertState).not.toHaveBeenCalled(); + }); + + it('persists a WARNING marker after a confirmed delivery', async () => { + // Headroom 1_000 dEURO < floor 5_000 but > 0 → WARNING, not CRITICAL. + deuroRepoStub.getState.mockResolvedValue(makeState({ reserveEquity: MINIMUM_EQUITY_WEI + 1_000n * WEI })); + deuroRepoStub.getHeadroomAlertState.mockResolvedValue({ alertedAt: null, level: null }); + telegramServiceStub.sendAlert.mockResolvedValue(true); + + await service.checkEquityHeadroom(telegramServiceStub as any); + + expect(deuroRepoStub.setHeadroomAlertState).toHaveBeenCalledTimes(1); + expect(deuroRepoStub.setHeadroomAlertState.mock.calls[0][1]).toBe('WARNING'); + expect(deuroRepoStub.clearHeadroomAlertState).not.toHaveBeenCalled(); + }); + + it("leaves the marker untouched when Telegram delivery fails, so the next cycle retries — the feature's core guarantee", async () => { + // Core guarantee: the dedup marker is written ONLY after confirmed Telegram delivery. + // A failed send must leave the marker unset so the next cycle retries instead of silencing. + deuroRepoStub.getState.mockResolvedValue(makeState({ reserveEquity: MINIMUM_EQUITY_WEI + 1_000n * WEI })); + deuroRepoStub.getHeadroomAlertState.mockResolvedValue({ alertedAt: null, level: null }); + telegramServiceStub.sendAlert.mockResolvedValue(false); + + await service.checkEquityHeadroom(telegramServiceStub as any); + + expect(deuroRepoStub.setHeadroomAlertState).not.toHaveBeenCalled(); + expect(deuroRepoStub.clearHeadroomAlertState).not.toHaveBeenCalled(); + }); + + it('clears the marker on a confirmed-delivered recovery', async () => { + // Headroom back above the floor → level null + stored WARNING marker → resolve action. + deuroRepoStub.getState.mockResolvedValue(makeState({ reserveEquity: MINIMUM_EQUITY_WEI + 50_000n * WEI })); + deuroRepoStub.getHeadroomAlertState.mockResolvedValue({ + alertedAt: 1_700_000_000n, + level: 'WARNING', + }); + telegramServiceStub.sendAlert.mockResolvedValue(true); + + await service.checkEquityHeadroom(telegramServiceStub as any); + + expect(deuroRepoStub.clearHeadroomAlertState).toHaveBeenCalledTimes(1); + expect(deuroRepoStub.setHeadroomAlertState).not.toHaveBeenCalled(); + }); + + it('leaves a recovered marker in place when the all-clear delivery fails', async () => { + // All-clear failed: marker stays so the operator is not treated as "already recovered". + deuroRepoStub.getState.mockResolvedValue(makeState({ reserveEquity: MINIMUM_EQUITY_WEI + 50_000n * WEI })); + deuroRepoStub.getHeadroomAlertState.mockResolvedValue({ + alertedAt: 1_700_000_000n, + level: 'WARNING', + }); + telegramServiceStub.sendAlert.mockResolvedValue(false); + + await service.checkEquityHeadroom(telegramServiceStub as any); + + expect(deuroRepoStub.clearHeadroomAlertState).not.toHaveBeenCalled(); + expect(deuroRepoStub.setHeadroomAlertState).not.toHaveBeenCalled(); + }); + + it('headers match the alert level', async () => { + // WARNING delivery: icon is not the critical symbol; header names the tier. + deuroRepoStub.getState.mockResolvedValue(makeState({ reserveEquity: MINIMUM_EQUITY_WEI + 1_000n * WEI })); + deuroRepoStub.getHeadroomAlertState.mockResolvedValue({ alertedAt: null, level: null }); + telegramServiceStub.sendAlert.mockResolvedValue(true); + + await service.checkEquityHeadroom(telegramServiceStub as any); + + expect(telegramServiceStub.sendAlert).toHaveBeenCalledTimes(1); + const [warnIcon, warnHeader] = telegramServiceStub.sendAlert.mock.calls[0]; + expect(warnIcon).not.toBe('🚨'); + expect(warnHeader).toContain('WARNING'); + + // Recovery delivery: header names the all-clear. + deuroRepoStub.getState.mockResolvedValue(makeState({ reserveEquity: MINIMUM_EQUITY_WEI + 50_000n * WEI })); + deuroRepoStub.getHeadroomAlertState.mockResolvedValue({ + alertedAt: 1_700_000_000n, + level: 'WARNING', + }); + telegramServiceStub.sendAlert.mockClear(); + telegramServiceStub.sendAlert.mockResolvedValue(true); + + await service.checkEquityHeadroom(telegramServiceStub as any); + + expect(telegramServiceStub.sendAlert).toHaveBeenCalledTimes(1); + const [, recoverHeader] = telegramServiceStub.sendAlert.mock.calls[0]; + expect(recoverHeader).toContain('RECOVERED'); + }); + + it('escalation WARNING→CRITICAL bypasses the repeat window', async () => { + // Escalation must ignore the repeat window: a deterioration (WARNING → CRITICAL) must not + // be swallowed by dedup just because a softer alert was sent recently. + deuroRepoStub.getState.mockResolvedValue(makeState({ reserveEquity: MINIMUM_EQUITY_WEI })); + deuroRepoStub.getHeadroomAlertState.mockResolvedValue({ + alertedAt: BigInt(Math.floor(Date.now() / 1000) - 60), + level: 'WARNING', + }); + telegramServiceStub.sendAlert.mockResolvedValue(true); + + await service.checkEquityHeadroom(telegramServiceStub as any); + + expect(telegramServiceStub.sendAlert).toHaveBeenCalledTimes(1); + const [icon, header, message] = telegramServiceStub.sendAlert.mock.calls[0]; + expect(icon).toBe('🚨'); + expect(header).toContain('CRITICAL'); + expect(message).toContain('Escalated from WARNING'); + expect(deuroRepoStub.setHeadroomAlertState).toHaveBeenCalledTimes(1); + expect(deuroRepoStub.setHeadroomAlertState.mock.calls[0][1]).toBe('CRITICAL'); + }); + + it('reminder fires again once the repeat window has elapsed', async () => { + // Headroom 1_000 dEURO < floor 5_000 but > 0 → still WARNING; marker older than the window. + deuroRepoStub.getState.mockResolvedValue(makeState({ reserveEquity: MINIMUM_EQUITY_WEI + 1_000n * WEI })); + deuroRepoStub.getHeadroomAlertState.mockResolvedValue({ + alertedAt: BigInt(Math.floor(Date.now() / 1000) - (REPEAT_ALERT_HOURS + 1) * 3600), + level: 'WARNING', + }); + telegramServiceStub.sendAlert.mockResolvedValue(true); + + await service.checkEquityHeadroom(telegramServiceStub as any); + + expect(telegramServiceStub.sendAlert).toHaveBeenCalledTimes(1); + const [, , message] = telegramServiceStub.sendAlert.mock.calls[0]; + expect(message).toContain('Reminder'); + expect(deuroRepoStub.setHeadroomAlertState).toHaveBeenCalledTimes(1); + expect(deuroRepoStub.setHeadroomAlertState.mock.calls[0][1]).toBe('WARNING'); + }); + + it('no persisted state on the very first run', async () => { + // Null state: early return before any sample/marker/Telegram work. + deuroRepoStub.getState.mockResolvedValue(null); + + await service.checkEquityHeadroom(telegramServiceStub as any); + + expect(telegramServiceStub.sendAlert).not.toHaveBeenCalled(); + expect(deuroRepoStub.getHeadroomAlertState).not.toHaveBeenCalled(); + expect(deuroRepoStub.setHeadroomAlertState).not.toHaveBeenCalled(); + expect(deuroRepoStub.clearHeadroomAlertState).not.toHaveBeenCalled(); + expect(deuroRepoStub.findEquitySamplesSince).not.toHaveBeenCalled(); + }); +}); diff --git a/src/monitoringV2/deuro.service.ts b/src/monitoringV2/deuro.service.ts index 6cf3b0f..53ead5d 100644 --- a/src/monitoringV2/deuro.service.ts +++ b/src/monitoringV2/deuro.service.ts @@ -8,6 +8,23 @@ import { ethers } from 'ethers'; import { DecentralizedEUROABI, EquityABI, DEPSWrapperABI, SavingsGatewayV2ABI, SavingsV3ABI, ADDRESS } from '@deuro/eurocoin'; import { EventsRepository } from './prisma/repositories/events.repository'; import { PositionRepository } from './prisma/repositories/position.repository'; +import { TelegramService } from './telegram.service'; +import { + MINIMUM_EQUITY_WEI, + TREND_WINDOW_HOURS, + REPEAT_ALERT_HOURS, + EQUITY_SAMPLE_RETENTION_DAYS, + computeHeadroomWei, + formatDeuro, + computeTrend, + evaluateHeadroom, + parseHeadroomLevel, + decideAction, + HeadroomAction, + HeadroomAssessment, + HeadroomTrend, + HeadroomLevel, +} from './equity-headroom.logic'; @Injectable() export class DeuroService { @@ -141,6 +158,191 @@ export class DeuroService { // Persist to database await this.deuroRepo.upsertState(state); + // deuro_state is a single upserted row and keeps no history — persist one sample per cycle + // so the headroom watcher has a series to project the trend from. + await this.deuroRepo.insertEquitySample({ + blockNumber: state.blockNumber, + reserveEquity: state.reserveEquity, + timestamp: state.timestamp, + }); + const cutoff = new Date(state.timestamp.getTime() - EQUITY_SAMPLE_RETENTION_DAYS * 24 * 60 * 60 * 1000); + const pruned = await this.deuroRepo.pruneEquitySamples(cutoff); + if (pruned > 0) this.logger.debug(`Pruned ${pruned} equity samples older than ${cutoff.toISOString()}`); this.logger.log('Successfully synced dEURO state'); } + + /** + * Pages when protocol equity headroom approaches or breaches MINIMUM_EQUITY. + * + * Below that floor, Equity._calculateShares takes its bootstrap branch (flat 10_000_000 nDEPS + * for a single deposit, amount-independent) — but only for a deposit that lifts equity back to + * at least MINIMUM_EQUITY in the same transaction; otherwise Equity._invest reverts first with + * InsufficientEquity — and Equity.restructureCapTable becomes callable (burns nDEPS of every + * address passed to it). Both paths are evaluated per transaction — alert before the floor is + * crossed so operators can top up reserve equity in time. + * + * telegramService is passed in (not DI) to avoid a circular Nest module dependency, matching + * the position watchers. The dedup/escalation marker is written only after confirmed delivery. + */ + async checkEquityHeadroom(telegramService: TelegramService): Promise { + const state = await this.deuroRepo.getState(); + if (!state) { + this.logger.warn('No dEURO state persisted yet — skipping equity headroom check'); + return; + } + + const nowMs = Date.now(); + const headroomWei = computeHeadroomWei(state.reserveEquity); + // This query only bounds how much is loaded from the DB (wall-clock cutoff) — it does not by + // itself enforce the trend window. computeTrend re-filters relative to the newest sample. In + // normal operation — no sample timestamp in the future relative to Date.now() — computeTrend's + // cutoff is earlier than or equal to this query's, so its filter drops nothing the query already + // returned. If the clock jumps backward (NTP, VM restore, wrong DB clock) and the newest sample + // sits after Date.now(), computeTrend's own window becomes the later bound and may drop samples + // this query delivered — which is the intended semantics, not a bug. The window semantics live + // in computeTrend, not here. After a longer outage (no new samples for a while) the newest loaded + // sample is older than "now" — then this wall-clock query is the tighter bound, not computeTrend's + // newest-sample window, so the regression runs over less than TREND_WINDOW_HOURS of wall-clock + // time: fewer/older data only, not a wrong calculation. + const samples = await this.deuroRepo.findEquitySamplesSince(new Date(nowMs - TREND_WINDOW_HOURS * 60 * 60 * 1000)); + const trend = computeTrend(samples); + const warningFloorWei = BigInt(this.config.equityHeadroomWarningDeuro) * 10n ** 18n; + const assessment = evaluateHeadroom({ + headroomWei, + trend, + warningFloorWei, + projectionHorizonHours: this.config.equityHeadroomProjectionHours, + }); + const stored = await this.deuroRepo.getHeadroomAlertState(); + const nowSeconds = BigInt(Math.floor(nowMs / 1000)); + const action = decideAction({ + level: assessment.level, + storedLevel: parseHeadroomLevel(stored.level), + storedAtSeconds: stored.alertedAt, + nowSeconds, + repeatAfterSeconds: BigInt(REPEAT_ALERT_HOURS * 60 * 60), + }); + + if (action.kind === 'none') return; + + const message = + action.kind === 'resolve' + ? this.buildHeadroomResolveMessage(state, headroomWei, warningFloorWei) + : this.buildHeadroomAlertMessage(action, state, headroomWei, warningFloorWei, assessment, trend, nowMs); + + let icon: string; + let header: string; + if (action.kind === 'resolve') { + icon = '✅'; + header = 'EQUITY HEADROOM RECOVERED'; + } else if (action.level === HeadroomLevel.WARNING) { + icon = '⚠️'; + header = 'EQUITY HEADROOM WARNING'; + } else if (action.level === HeadroomLevel.CRITICAL) { + icon = '🚨'; + header = 'EQUITY HEADROOM CRITICAL'; + } else { + throw new Error(`unknown headroom level: ${action.level}`); + } + + const delivered = await telegramService.sendAlert(icon, header, message); + if (!delivered) { + // Marker stays unchanged on failed delivery — including for the all-clear. The marker means + // "operator was alerted and has not received an all-clear yet". If the resolve message never + // arrives, the operator still believes the incident is open, so a relapse into the same + // state is correctly not worth a fresh page — the next cycle retries delivery instead. + this.logger.warn('Equity headroom alert delivery failed — marker unchanged, next cycle will retry'); + return; + } + + if (action.kind === 'resolve') { + await this.deuroRepo.clearHeadroomAlertState(); + this.logger.log(`Equity headroom recovered — alert cleared (headroom=${formatDeuro(headroomWei)} dEURO)`); + return; + } + + await this.deuroRepo.setHeadroomAlertState(nowSeconds, action.persistLevel); + const alertKind = action.escalation ? 'escalation' : action.reminder ? 'reminder' : 'new'; + this.logger.warn(`Equity headroom ${action.level} alert sent (${alertKind}, headroom=${formatDeuro(headroomWei)} dEURO)`); + } + + private buildHeadroomAlertMessage( + action: Extract, + state: DeuroState, + headroomWei: bigint, + warningFloorWei: bigint, + assessment: HeadroomAssessment, + trend: HeadroomTrend, + nowMs: number + ): string { + const equityAddress = ADDRESS[this.config.blockchainId].equity; + const { escalation, reminder } = action; + + let trendLine: string; + if (trend.usable) { + if (trend.slopeDeuroPerHour === null) { + throw new Error('usable headroom trend has null slope'); + } + const slope = trend.slopeDeuroPerHour.toFixed(2); + const perDay = (trend.slopeDeuroPerHour * 24).toFixed(2); + trendLine = `${slope} dEURO/h (${perDay} dEURO/day) over ${trend.sampleCount} samples / ${trend.spanHours.toFixed(1)} h`; + } else { + trendLine = `not usable yet (${trend.sampleCount} samples over ${trend.spanHours.toFixed(1)} h)`; + } + + let projectionLine: string; + if (assessment.hoursToDepletion !== null) { + const depleteAt = new Date(nowMs + assessment.hoursToDepletion * 3600 * 1000).toUTCString(); + projectionLine = `${assessment.hoursToDepletion.toFixed(1)} h (${depleteAt})`; + } else { + projectionLine = 'no depletion projected at the current trend'; + } + + const triggers = assessment.reasons.map((reason) => `• ${reason}`).join('\n'); + + // When reserve balance is at/below minterReserve, equity() is clamped at 0 and the true + // shortfall is invisible in headroom alone — surface the deficit so operators fund enough. + const reserveDeficitLine = + state.reserveTotal < state.reserveMinter + ? `Reserve deficit: *${formatDeuro(state.reserveMinter - state.reserveTotal)} dEURO* — \`equity()\` is ` + + `clamped at zero; the headroom only starts moving once \`balanceOf(reserve)\` exceeds \`minterReserve()\` — ` + + `this amount only covers the gap up to equality\n` + : ''; + + return ( + `${escalation ? 'Escalated from WARNING.\n\n' : reminder ? 'Reminder — still unresolved.\n\n' : ''}` + + `Equity: *${formatDeuro(state.reserveEquity)} dEURO* (\`MINIMUM_EQUITY\`: ${formatDeuro(MINIMUM_EQUITY_WEI)})\n` + + `Headroom: *${formatDeuro(headroomWei)} dEURO* (warning floor: ${formatDeuro(warningFloorWei)})\n` + + reserveDeficitLine + + `Trend: ${trendLine}\n` + + `Projection: ${projectionLine}\n` + + `Block: ${state.blockNumber}\n\n` + + `Triggers:\n` + + `${triggers}\n\n` + + `Below \`MINIMUM_EQUITY\` two contract paths open, both evaluated per transaction:\n` + + `• \`Equity._calculateShares\` takes its bootstrap branch — a flat 10,000,000 nDEPS for a single deposit, ` + + `regardless of size (current nDEPS supply: ${formatDeuro(state.equityShares)}) — but only for a deposit large ` + + `enough to lift equity back to at least \`MINIMUM_EQUITY\` in the same transaction; \`Equity._invest\` reverts ` + + `first with \`InsufficientEquity\` otherwise\n` + + `• \`Equity.restructureCapTable\` becomes callable and burns the nDEPS of every address passed to it — ` + + `beyond that gate only the 2% vote quorum stands in the way\n\n` + + `Mitigation: \`equity()\` is \`balanceOf(reserve) - minterReserve()\`, clamped at zero, and \`reserve()\` is the Equity ` + + `contract itself. A plain dEURO transfer to the Equity contract needs neither a share mint nor a governance ` + + `vote, but it only raises the headroom 1:1 once \`balanceOf(reserve)\` exceeds \`minterReserve()\` — an ` + + `existing reserve deficit has to be covered first.\n` + + `Equity contract: \`${equityAddress}\`\n\n` + + `[Etherscan](https://etherscan.io/address/${equityAddress})` + ); + } + + private buildHeadroomResolveMessage(state: DeuroState, headroomWei: bigint, warningFloorWei: bigint): string { + return ( + `Protocol equity headroom recovered\n\n` + + `Equity: *${formatDeuro(state.reserveEquity)} dEURO*\n` + + `Headroom: *${formatDeuro(headroomWei)} dEURO* (warning floor: ${formatDeuro(warningFloorWei)})\n` + + `Block: ${state.blockNumber}\n\n` + + `The headroom alert is cleared and re-armed — a renewed dip alerts again instead of being ` + + `deduplicated against the previous one.` + ); + } } diff --git a/src/monitoringV2/equity-headroom.logic.spec.ts b/src/monitoringV2/equity-headroom.logic.spec.ts new file mode 100644 index 0000000..559fb6a --- /dev/null +++ b/src/monitoringV2/equity-headroom.logic.spec.ts @@ -0,0 +1,747 @@ +import { + MINIMUM_EQUITY_WEI, + TREND_WINDOW_HOURS, + TREND_MIN_SAMPLES, + TREND_MIN_SPAN_HOURS, + HeadroomLevel, + EquitySample, + HeadroomTrend, + computeHeadroomWei, + toDeuro, + formatDeuro, + computeTrend, + evaluateHeadroom, + parseHeadroomLevel, + decideAction, +} from './equity-headroom.logic'; + +const MS_PER_MINUTE = 60_000; +const START_MS = 1_700_000_000_000; +const NOW_SECONDS = 1_700_000_000n; +const REPEAT_AFTER = 24n * 3600n; // mirrors REPEAT_ALERT_HOURS + +/** Convert a dEURO float into 18-decimal wei without floating-point wei truncation surprises. */ +function deuroToWei(deuro: number): bigint { + return BigInt(Math.round(deuro * 1e6)) * 10n ** 12n; +} + +/** + * Synthetic headroom series: reserveEquity = MINIMUM_EQUITY + headroom, with headroom evolving + * linearly from `startHeadroomDeuro` at `slopePerHour` dEURO/h, one sample every `stepMinutes`. + */ +function makeSamples(startHeadroomDeuro: number, slopePerHour: number, count: number, stepMinutes: number): EquitySample[] { + const samples: EquitySample[] = []; + for (let i = 0; i < count; i++) { + const hours = (i * stepMinutes) / 60; + const headroomDeuro = startHeadroomDeuro + slopePerHour * hours; + samples.push({ + timestampMs: START_MS + i * stepMinutes * MS_PER_MINUTE, + reserveEquity: MINIMUM_EQUITY_WEI + deuroToWei(headroomDeuro), + }); + } + return samples; +} + +/** Flat, usable trend (enough samples + span, near-zero slope) for evaluateHeadroom isolation. */ +function flatUsableTrend(): HeadroomTrend { + return { + usable: true, + sampleCount: TREND_MIN_SAMPLES, + spanHours: TREND_MIN_SPAN_HOURS, + slopeDeuroPerHour: 0, + }; +} + +function unusableTrend(): HeadroomTrend { + return { + usable: false, + sampleCount: 2, + spanHours: 0.5, + slopeDeuroPerHour: null, + }; +} + +function fallingTrend(slopeDeuroPerHour: number): HeadroomTrend { + return { + usable: true, + sampleCount: 12, + spanHours: 12, + slopeDeuroPerHour, + }; +} + +// --------------------------------------------------------------------------- +// computeHeadroomWei / toDeuro / formatDeuro +// --------------------------------------------------------------------------- + +describe('computeHeadroomWei', () => { + it('subtracts MINIMUM_EQUITY and allows a negative result', () => { + expect(computeHeadroomWei(MINIMUM_EQUITY_WEI + deuroToWei(500))).toBe(deuroToWei(500)); + expect(computeHeadroomWei(MINIMUM_EQUITY_WEI)).toBe(0n); + expect(computeHeadroomWei(MINIMUM_EQUITY_WEI - deuroToWei(1))).toBe(-deuroToWei(1)); + }); +}); + +describe('toDeuro', () => { + it('divides wei by 1e18 for regression math', () => { + expect(toDeuro(deuroToWei(1234.5))).toBeCloseTo(1234.5, 5); + }); +}); + +describe('formatDeuro', () => { + it('formats with two decimals, thousands separators, and a leading minus for negatives', () => { + expect(formatDeuro(deuroToWei(1234.56))).toBe('1,234.56'); + expect(formatDeuro(-deuroToWei(1234.56))).toBe('-1,234.56'); + expect(formatDeuro(0n)).toBe('0.00'); + }); + + it('does not render a signed zero for sub-cent negative wei', () => { + // 1 wei truncates to 0 cents — must print unsigned 0.00, not -0.00. + expect(formatDeuro(-1n)).toBe('0.00'); + // 1e16 wei = 0.01 dEURO — still large enough to keep the minus after truncation. + expect(formatDeuro(-(10n ** 16n))).toBe('-0.01'); + }); +}); + +// --------------------------------------------------------------------------- +// computeTrend +// --------------------------------------------------------------------------- + +describe('computeTrend', () => { + it('marks the series unusable (slope null) when there are fewer than TREND_MIN_SAMPLES', () => { + const samples = makeSamples(3000, -10, TREND_MIN_SAMPLES - 1, 60); + const trend = computeTrend(samples); + expect(trend.usable).toBe(false); + expect(trend.slopeDeuroPerHour).toBeNull(); + expect(trend.sampleCount).toBe(TREND_MIN_SAMPLES - 1); + }); + + it('marks the series unusable when span is shorter than TREND_MIN_SPAN_HOURS despite enough samples', () => { + // 6 samples over 60 minutes total (step 12 min) => span = 1 h < 2 h. + const samples = makeSamples(3000, -10, TREND_MIN_SAMPLES, 12); + const trend = computeTrend(samples); + expect(trend.spanHours).toBeLessThan(TREND_MIN_SPAN_HOURS); + expect(trend.usable).toBe(false); + expect(trend.slopeDeuroPerHour).toBeNull(); + }); + + it('identical timestamps are caught by the min-span guard, not the zero-variance branch', () => { + // All timestamps equal => spanHours 0, caught by the TREND_MIN_SPAN_HOURS guard above before + // the zero x-variance branch is ever reached. Must refuse projection with slope null, not NaN. + const samples: EquitySample[] = Array.from({ length: TREND_MIN_SAMPLES }, (_, i) => ({ + timestampMs: START_MS, + reserveEquity: MINIMUM_EQUITY_WEI + deuroToWei(3000 - i), + })); + const trend = computeTrend(samples); + expect(trend.usable).toBe(false); + expect(trend.slopeDeuroPerHour).toBeNull(); + expect(Number.isFinite(trend.slopeDeuroPerHour as unknown as number)).toBe(false); + }); + + it('recovers the slope of an exactly linear falling series', () => { + const slope = -11; + // 13 samples, 1h apart => span 12 h, well above thresholds. + const samples = makeSamples(3200, slope, 13, 60); + const trend = computeTrend(samples); + expect(trend.usable).toBe(true); + expect(trend.slopeDeuroPerHour).not.toBeNull(); + expect(trend.slopeDeuroPerHour as number).toBeCloseTo(slope, 5); + }); + + it('reports a positive slope for a rising series', () => { + const samples = makeSamples(1000, 5, 13, 60); + const trend = computeTrend(samples); + expect(trend.usable).toBe(true); + expect(trend.slopeDeuroPerHour as number).toBeGreaterThan(0); + expect(trend.slopeDeuroPerHour as number).toBeCloseTo(5, 5); + }); + + it('returns the same trend for unsorted input as for sorted input', () => { + const sorted = makeSamples(2500, -3, 12, 30); + const shuffled = [ + sorted[5], + sorted[0], + sorted[11], + sorted[2], + sorted[8], + sorted[1], + sorted[9], + sorted[3], + sorted[7], + sorted[4], + sorted[10], + sorted[6], + ]; + const tSorted = computeTrend(sorted); + const tShuffled = computeTrend(shuffled); + expect(tShuffled).toEqual(tSorted); + }); + + it.each([ + ['NaN', NaN], + ['Infinity', Infinity], + ['-Infinity', -Infinity], + ])('marks the series unusable when a sample has timestamp %s', (_label, badTimestamp) => { + // Enough samples and span for a usable trend — only the non-finite timestamp should disqualify it. + const samples = makeSamples(3000, -10, TREND_MIN_SAMPLES, 60); + samples[2] = { ...samples[2], timestampMs: badTimestamp }; + const trend = computeTrend(samples); + expect(trend.usable).toBe(false); + expect(trend.slopeDeuroPerHour).toBeNull(); + }); + + it('marks an empty series unusable with zero count and span', () => { + const trend = computeTrend([]); + expect(trend.usable).toBe(false); + expect(trend.spanHours).toBe(0); + expect(trend.slopeDeuroPerHour).toBeNull(); + expect(trend.sampleCount).toBe(0); + }); + + it('marks a single-sample series unusable with span 0', () => { + const trend = computeTrend(makeSamples(3000, 0, 1, 60)); + expect(trend.usable).toBe(false); + expect(trend.spanHours).toBe(0); + expect(trend.slopeDeuroPerHour).toBeNull(); + expect(trend.sampleCount).toBe(1); + }); + + it('enforces TREND_WINDOW_HOURS relative to the newest sample', () => { + // Young series falls steeply; without the window an old low point would flip the OLS slope positive. + const young = makeSamples(3000, -20, 12, 60); + const old: EquitySample = { + timestampMs: young[0].timestampMs - (TREND_WINDOW_HOURS + 48) * 3_600_000, + reserveEquity: MINIMUM_EQUITY_WEI + deuroToWei(100), + }; + const trend = computeTrend([old, ...young]); + const youngOnly = computeTrend(young); + expect(trend.sampleCount).toBe(young.length); + expect(trend.slopeDeuroPerHour).not.toBeNull(); + expect(trend.slopeDeuroPerHour as number).toBeLessThan(0); + expect(trend.slopeDeuroPerHour as number).toBeCloseTo(youngOnly.slopeDeuroPerHour as number, 5); + }); + + it('includes a sample exactly at the window boundary and drops one that is 1 ms older', () => { + // Filter is `timestampMs >= newest - TREND_WINDOW_HOURS`, i.e. inclusive at the boundary. + const windowMs = TREND_WINDOW_HOURS * 3_600_000; + const newestMs = START_MS + 1_000 * 3_600_000; + const newest: EquitySample = { timestampMs: newestMs, reserveEquity: MINIMUM_EQUITY_WEI + deuroToWei(100) }; + const middle: EquitySample = { timestampMs: newestMs - windowMs / 2, reserveEquity: MINIMUM_EQUITY_WEI + deuroToWei(150) }; + const atBoundary: EquitySample = { timestampMs: newestMs - windowMs, reserveEquity: MINIMUM_EQUITY_WEI + deuroToWei(200) }; + + const included = computeTrend([atBoundary, middle, newest]); + expect(included.sampleCount).toBe(3); + + const oneMsOlder: EquitySample = { ...atBoundary, timestampMs: atBoundary.timestampMs - 1 }; + const excluded = computeTrend([oneMsOlder, middle, newest]); + expect(excluded.sampleCount).toBe(2); + }); + + it('becomes unusable only after filtering — the raw input alone would clear both thresholds', () => { + const newestMs = START_MS + 1_000 * 3_600_000; + const atHoursAgo = (hoursAgo: number, headroomDeuro: number): EquitySample => ({ + timestampMs: newestMs - hoursAgo * 3_600_000, + reserveEquity: MINIMUM_EQUITY_WEI + deuroToWei(headroomDeuro), + }); + // Old cluster: 10 samples, 30 h to 93 h before the newest sample — all older than TREND_WINDOW_HOURS + // and dropped by the window filter. Together with the near cluster they push the raw sample + // count and span well past TREND_MIN_SAMPLES / TREND_MIN_SPAN_HOURS. + const oldCluster: EquitySample[] = Array.from({ length: 10 }, (_, i) => atHoursAgo(30 + i * 7, 500 + i)); + // Near cluster: 4 samples within the last 1.5 h — all inside the window, but too few/too short + // on their own to be usable. + const nearCluster: EquitySample[] = [atHoursAgo(1.5, 100), atHoursAgo(1, 90), atHoursAgo(0.5, 80), atHoursAgo(0, 70)]; + const samples = [...oldCluster, ...nearCluster]; + + // Sanity: unfiltered the series already clears both thresholds. + expect(samples.length).toBeGreaterThanOrEqual(TREND_MIN_SAMPLES); + const timestamps = samples.map((s) => s.timestampMs); + const rawSpanHours = (Math.max(...timestamps) - Math.min(...timestamps)) / 3_600_000; + expect(rawSpanHours).toBeGreaterThanOrEqual(TREND_MIN_SPAN_HOURS); + + const trend = computeTrend(samples); + expect(trend.usable).toBe(false); + expect(trend.sampleCount).toBe(nearCluster.length); + expect(trend.spanHours).toBeCloseTo(1.5, 5); + }); + + it('marks the series unusable when the slope is non-finite despite finite timestamps', () => { + // Absurd equity makes toDeuro yield Infinity; OLS then produces a non-finite slope. + const samples = makeSamples(3000, -10, 12, 60).map((s) => ({ + ...s, + reserveEquity: 10n ** 400n, + })); + const trend = computeTrend(samples); + expect(trend.usable).toBe(false); + expect(trend.slopeDeuroPerHour).toBeNull(); + }); + + it('recovers the OLS slope of an irregular, non-linear series', () => { + // Hand-checked OLS: x = hours since first sample, y = headroom dEURO. + // points: (0,100), (1,50), (2,45), (3,40), (6,35), (10,30) + // meanX = 11/3, meanY = 50 + // Σ(x−x̄)(y−ȳ) = -330, Σ(x−x̄)² = 624/9 + // slope = -330 / (624/9) = -2970/624 = -495/104 ≈ -4.759615 + // (naive first-to-last two-point slope is (30−100)/10 = -7 — must not pass as equal) + const points: Array<{ hours: number; headroom: number }> = [ + { hours: 0, headroom: 100 }, + { hours: 1, headroom: 50 }, + { hours: 2, headroom: 45 }, + { hours: 3, headroom: 40 }, + { hours: 6, headroom: 35 }, + { hours: 10, headroom: 30 }, + ]; + const samples: EquitySample[] = points.map((p) => ({ + timestampMs: START_MS + p.hours * 3_600_000, + reserveEquity: MINIMUM_EQUITY_WEI + deuroToWei(p.headroom), + })); + const trend = computeTrend(samples); + expect(trend.usable).toBe(true); + expect(trend.slopeDeuroPerHour as number).toBeCloseTo(-495 / 104, 5); + }); +}); + +// --------------------------------------------------------------------------- +// evaluateHeadroom +// --------------------------------------------------------------------------- + +describe('evaluateHeadroom', () => { + const HORIZON = 72; + + it('returns CRITICAL when headroom is exactly zero', () => { + const a = evaluateHeadroom({ + headroomWei: 0n, + trend: flatUsableTrend(), + warningFloorWei: deuroToWei(5000), + projectionHorizonHours: HORIZON, + }); + expect(a.level).toBe(HeadroomLevel.CRITICAL); + expect(a.hoursToDepletion).toBe(0); + expect(a.reasons.some((r) => r.includes('MINIMUM_EQUITY'))).toBe(true); + }); + + it('returns CRITICAL when headroom is negative', () => { + const a = evaluateHeadroom({ + headroomWei: -deuroToWei(50), + trend: flatUsableTrend(), + warningFloorWei: deuroToWei(5000), + projectionHorizonHours: HORIZON, + }); + expect(a.level).toBe(HeadroomLevel.CRITICAL); + expect(a.hoursToDepletion).toBe(0); + expect(a.reasons.some((r) => r.includes('MINIMUM_EQUITY'))).toBe(true); + }); + + it('returns null with empty reasons when headroom is above the floor and the series is flat', () => { + const a = evaluateHeadroom({ + headroomWei: deuroToWei(6000), + trend: flatUsableTrend(), + warningFloorWei: deuroToWei(5000), + projectionHorizonHours: HORIZON, + }); + expect(a.level).toBeNull(); + expect(a.reasons).toEqual([]); + expect(a.hoursToDepletion).toBeNull(); + }); + + it('returns WARNING when headroom is below the floor on a flat series', () => { + const a = evaluateHeadroom({ + headroomWei: deuroToWei(3000), + trend: flatUsableTrend(), + warningFloorWei: deuroToWei(5000), + projectionHorizonHours: HORIZON, + }); + expect(a.level).toBe(HeadroomLevel.WARNING); + expect(a.reasons.some((r) => r.includes('warning floor'))).toBe(true); + expect(a.hoursToDepletion).toBeNull(); + }); + + it('returns CRITICAL when headroom is above the floor but the projection hits zero inside the horizon', () => { + // 1000 dEURO headroom, −50 dEURO/h => 20 h to depletion (< 72). + const a = evaluateHeadroom({ + headroomWei: deuroToWei(1000), + trend: fallingTrend(-50), + warningFloorWei: deuroToWei(500), + projectionHorizonHours: HORIZON, + }); + expect(a.level).toBe(HeadroomLevel.CRITICAL); + expect(a.hoursToDepletion).not.toBeNull(); + expect(a.hoursToDepletion as number).toBeCloseTo(20, 5); + expect(a.reasons.some((r) => r.includes('projected to deplete'))).toBe(true); + expect(a.reasons.some((r) => r.includes('dEURO/h'))).toBe(true); + expect(a.reasons.some((r) => r.includes('dEURO/day'))).toBe(true); + }); + + it('returns null when headroom is above the floor but the fall is slow (projection beyond the horizon)', () => { + // 5000 dEURO headroom, −10 dEURO/h => 500 h to depletion (> 72). + const a = evaluateHeadroom({ + headroomWei: deuroToWei(5000), + trend: fallingTrend(-10), + warningFloorWei: deuroToWei(1000), + projectionHorizonHours: HORIZON, + }); + expect(a.level).toBeNull(); + expect(a.reasons).toEqual([]); + expect(a.hoursToDepletion as number).toBeGreaterThan(HORIZON); + }); + + it('never fires the projection CRITICAL when the trend is unusable', () => { + // Headroom 100 > floor 50; unusable trend must not invent a depletion projection. + const a = evaluateHeadroom({ + headroomWei: deuroToWei(100), + trend: unusableTrend(), + warningFloorWei: deuroToWei(50), + projectionHorizonHours: HORIZON, + }); + expect(a.level).toBeNull(); + expect(a.hoursToDepletion).toBeNull(); + expect(a.reasons).toEqual([]); + }); + + it('with warningFloorWei=0n stays silent on soft headroom but still CRITICAL when headroom <= 0', () => { + const soft = evaluateHeadroom({ + headroomWei: deuroToWei(100), + trend: flatUsableTrend(), + warningFloorWei: 0n, + projectionHorizonHours: HORIZON, + }); + expect(soft.level).toBeNull(); + expect(soft.reasons).toEqual([]); + + const breached = evaluateHeadroom({ + headroomWei: 0n, + trend: flatUsableTrend(), + warningFloorWei: 0n, + projectionHorizonHours: HORIZON, + }); + expect(breached.level).toBe(HeadroomLevel.CRITICAL); + }); + + it('with warningFloorWei=0n and negative headroom stays CRITICAL without a warning-floor reason', () => { + const a = evaluateHeadroom({ + headroomWei: -deuroToWei(50), + trend: flatUsableTrend(), + warningFloorWei: 0n, + projectionHorizonHours: HORIZON, + }); + expect(a.level).toBe(HeadroomLevel.CRITICAL); + expect(a.reasons.some((r) => r.includes('warning floor'))).toBe(false); + }); + + it('when headroom is exactly zero, states the boundary and that gated paths open one wei below', () => { + const a = evaluateHeadroom({ + headroomWei: 0n, + trend: flatUsableTrend(), + warningFloorWei: deuroToWei(5000), + projectionHorizonHours: HORIZON, + }); + expect(a.level).toBe(HeadroomLevel.CRITICAL); + expect(a.reasons.some((r) => r.includes('has reached') && r.includes('one wei below'))).toBe(true); + }); + + it('classifies the measured 2026-07-28 mainnet state as WARNING with depletion beyond 72 h', () => { + // equity ≈ 4_199.99 dEURO => headroom ≈ 3_199.99; floor 5_000 headroom; drift ≈ −11 dEURO/h + // hoursToDepletion ≈ 3199.99/11 ≈ 291 h >> 72. + const equityWei = deuroToWei(4199.99); + const headroomWei = computeHeadroomWei(equityWei); + const a = evaluateHeadroom({ + headroomWei, + trend: fallingTrend(-11), + warningFloorWei: deuroToWei(5000), + projectionHorizonHours: HORIZON, + }); + expect(a.level).toBe(HeadroomLevel.WARNING); + expect(a.hoursToDepletion).not.toBeNull(); + expect(a.hoursToDepletion as number).toBeGreaterThan(HORIZON); + expect(a.reasons.some((r) => r.includes('warning floor'))).toBe(true); + expect(a.reasons.some((r) => r.includes('projected to deplete'))).toBe(false); + }); + + it('returns null when headroom equals the warning floor exactly (strict < comparison)', () => { + const floor = deuroToWei(5000); + const a = evaluateHeadroom({ + headroomWei: floor, + trend: flatUsableTrend(), + warningFloorWei: floor, + projectionHorizonHours: HORIZON, + }); + expect(a.level).toBeNull(); + expect(a.reasons).toEqual([]); + }); + + it('returns CRITICAL when hoursToDepletion equals the projection horizon exactly (<= comparison)', () => { + // slope −10 dEURO/h × 72 h = 720 dEURO headroom → hoursToDepletion exactly 72. + const a = evaluateHeadroom({ + headroomWei: deuroToWei(720), + trend: { usable: true, sampleCount: 6, spanHours: 5, slopeDeuroPerHour: -10 }, + warningFloorWei: deuroToWei(100), + projectionHorizonHours: 72, + }); + expect(a.level).toBe(HeadroomLevel.CRITICAL); + expect(a.hoursToDepletion as number).toBeCloseTo(72, 5); + expect(a.reasons.some((r) => r.includes('projected to deplete'))).toBe(true); + }); + + it('with warningFloorWei=0n still fires projection CRITICAL on positive falling headroom', () => { + // warningFloorWei=0n mutes only the WARNING tier; projection CRITICAL must still fire. + const a = evaluateHeadroom({ + headroomWei: deuroToWei(1000), + trend: fallingTrend(-50), + warningFloorWei: 0n, + projectionHorizonHours: HORIZON, + }); + expect(a.level).toBe(HeadroomLevel.CRITICAL); + expect(a.hoursToDepletion as number).toBeCloseTo(20, 5); + expect(a.reasons.some((r) => r.includes('projected to deplete'))).toBe(true); + }); + + it('does not emit a projected-depletion reason when headroom is already breached', () => { + const a = evaluateHeadroom({ + headroomWei: -deuroToWei(50), + trend: fallingTrend(-10), + warningFloorWei: deuroToWei(5000), + projectionHorizonHours: HORIZON, + }); + expect(a.level).toBe(HeadroomLevel.CRITICAL); + expect(a.hoursToDepletion).toBe(0); + expect(a.reasons.some((r) => r.includes('projected to deplete'))).toBe(false); + }); + + it('does not emit a projected-depletion reason when headroom is exactly zero, even with a falling trend', () => { + // Guards trigger (2)'s `headroomWei > 0n` condition against an accidental `>= 0n` widening. + const a = evaluateHeadroom({ + headroomWei: 0n, + trend: fallingTrend(-10), + warningFloorWei: deuroToWei(5000), + projectionHorizonHours: HORIZON, + }); + expect(a.level).toBe(HeadroomLevel.CRITICAL); + expect(a.hoursToDepletion).toBe(0); + expect(a.reasons.some((r) => r.includes('projected to deplete'))).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// parseHeadroomLevel +// --------------------------------------------------------------------------- + +describe('parseHeadroomLevel', () => { + it('maps null to null', () => { + expect(parseHeadroomLevel(null)).toBeNull(); + }); + + it('maps both valid enum strings', () => { + expect(parseHeadroomLevel('WARNING')).toBe(HeadroomLevel.WARNING); + expect(parseHeadroomLevel('CRITICAL')).toBe(HeadroomLevel.CRITICAL); + }); + + it('throws on an unknown value (no silent fallback to null)', () => { + expect(() => parseHeadroomLevel('OK')).toThrow(/OK/); + expect(() => parseHeadroomLevel('warning')).toThrow(/warning/); + expect(() => parseHeadroomLevel('')).toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// decideAction +// --------------------------------------------------------------------------- + +describe('decideAction', () => { + it('throws when storedLevel is set but storedAtSeconds is null', () => { + expect(() => + decideAction({ + level: HeadroomLevel.WARNING, + storedLevel: HeadroomLevel.WARNING, + storedAtSeconds: null, + nowSeconds: NOW_SECONDS, + repeatAfterSeconds: REPEAT_AFTER, + }) + ).toThrow(/inconsistent headroom marker/); + }); + + it.each([ + ['0n', 0n], + ['-1n', -1n], + ])('throws when repeatAfterSeconds is not positive (%s)', (_label, repeatAfterSeconds) => { + expect(() => + decideAction({ + level: HeadroomLevel.WARNING, + storedLevel: HeadroomLevel.WARNING, + storedAtSeconds: NOW_SECONDS - 100n, + nowSeconds: NOW_SECONDS, + repeatAfterSeconds, + }) + ).toThrow(/repeatAfterSeconds/); + }); + + it('throws when storedAtSeconds is set but storedLevel is null', () => { + expect(() => + decideAction({ + level: HeadroomLevel.WARNING, + storedLevel: null, + storedAtSeconds: NOW_SECONDS, + nowSeconds: NOW_SECONDS, + repeatAfterSeconds: REPEAT_AFTER, + }) + ).toThrow(/inconsistent headroom marker/); + }); + + it('returns none when both live and stored levels are null (still OK)', () => { + expect( + decideAction({ + level: null, + storedLevel: null, + storedAtSeconds: null, + nowSeconds: NOW_SECONDS, + repeatAfterSeconds: REPEAT_AFTER, + }) + ).toEqual({ kind: 'none' }); + }); + + it('returns resolve when live level is OK but a marker is still stored', () => { + expect( + decideAction({ + level: null, + storedLevel: HeadroomLevel.WARNING, + storedAtSeconds: NOW_SECONDS - 100n, + nowSeconds: NOW_SECONDS, + repeatAfterSeconds: REPEAT_AFTER, + }) + ).toEqual({ kind: 'resolve' }); + }); + + it('returns a fresh alert when a level appears with no stored marker', () => { + expect( + decideAction({ + level: HeadroomLevel.WARNING, + storedLevel: null, + storedAtSeconds: null, + nowSeconds: NOW_SECONDS, + repeatAfterSeconds: REPEAT_AFTER, + }) + ).toEqual({ + kind: 'alert', + level: HeadroomLevel.WARNING, + persistLevel: HeadroomLevel.WARNING, + escalation: false, + reminder: false, + }); + }); + + it('escalates WARNING→CRITICAL immediately, ignoring the repeat window', () => { + // storedAt is "just now" — a normal reminder would be suppressed, but escalation must fire. + expect( + decideAction({ + level: HeadroomLevel.CRITICAL, + storedLevel: HeadroomLevel.WARNING, + storedAtSeconds: NOW_SECONDS, + nowSeconds: NOW_SECONDS, + repeatAfterSeconds: REPEAT_AFTER, + }) + ).toEqual({ + kind: 'alert', + level: HeadroomLevel.CRITICAL, + persistLevel: HeadroomLevel.CRITICAL, + escalation: true, + reminder: false, + }); + }); + + it('returns none while the repeat window has not elapsed for an already-reported level', () => { + expect( + decideAction({ + level: HeadroomLevel.WARNING, + storedLevel: HeadroomLevel.WARNING, + storedAtSeconds: NOW_SECONDS - 100n, + nowSeconds: NOW_SECONDS, + repeatAfterSeconds: REPEAT_AFTER, + }) + ).toEqual({ kind: 'none' }); + }); + + it('returns a reminder alert once the repeat window has elapsed', () => { + expect( + decideAction({ + level: HeadroomLevel.WARNING, + storedLevel: HeadroomLevel.WARNING, + storedAtSeconds: NOW_SECONDS - REPEAT_AFTER, + nowSeconds: NOW_SECONDS, + repeatAfterSeconds: REPEAT_AFTER, + }) + ).toEqual({ + kind: 'alert', + level: HeadroomLevel.WARNING, + persistLevel: HeadroomLevel.WARNING, + escalation: false, + reminder: true, + }); + }); + + it('flutter-guard: on reminder after CRITICAL→WARNING demotion, persistLevel stays CRITICAL', () => { + expect( + decideAction({ + level: HeadroomLevel.WARNING, + storedLevel: HeadroomLevel.CRITICAL, + storedAtSeconds: NOW_SECONDS - REPEAT_AFTER, + nowSeconds: NOW_SECONDS, + repeatAfterSeconds: REPEAT_AFTER, + }) + ).toEqual({ + kind: 'alert', + level: HeadroomLevel.WARNING, + persistLevel: HeadroomLevel.CRITICAL, + escalation: false, + reminder: true, + }); + }); + + it('suppresses a CRITICAL relapse shortly after a WARNING reminder — the accepted price of the flutter-guard', () => { + // t0: CRITICAL fires fresh and is persisted. + const first = decideAction({ + level: HeadroomLevel.CRITICAL, + storedLevel: null, + storedAtSeconds: null, + nowSeconds: NOW_SECONDS, + repeatAfterSeconds: REPEAT_AFTER, + }); + expect(first).toEqual({ + kind: 'alert', + level: HeadroomLevel.CRITICAL, + persistLevel: HeadroomLevel.CRITICAL, + escalation: false, + reminder: false, + }); + + // The repeat window elapses; the live level has since dropped to WARNING. A reminder fires + // and — via the flutter-guard — keeps persistLevel at CRITICAL rather than demoting it. + const reminderAt = NOW_SECONDS + REPEAT_AFTER; + const second = decideAction({ + level: HeadroomLevel.WARNING, + storedLevel: HeadroomLevel.CRITICAL, + storedAtSeconds: NOW_SECONDS, + nowSeconds: reminderAt, + repeatAfterSeconds: REPEAT_AFTER, + }); + expect(second).toEqual({ + kind: 'alert', + level: HeadroomLevel.WARNING, + persistLevel: HeadroomLevel.CRITICAL, + escalation: false, + reminder: true, + }); + + // Shortly after, the live level relapses back to CRITICAL. Because storedLevel is already + // CRITICAL (not WARNING), this does not hit the WARNING→CRITICAL escalation branch, and the + // repeat window has not elapsed since the reminder — so it is swallowed as `none`. This is the + // deliberate trade-off named in decideAction's doc comment: the flutter-guard that keeps a + // CRITICAL→WARNING dip from re-arming a fresh escalation also means a genuine relapse to + // CRITICAL right after a reminder does not page again until the window elapses. + const relapse = decideAction({ + level: HeadroomLevel.CRITICAL, + storedLevel: HeadroomLevel.CRITICAL, + storedAtSeconds: reminderAt, + nowSeconds: reminderAt + 100n, + repeatAfterSeconds: REPEAT_AFTER, + }); + expect(relapse).toEqual({ kind: 'none' }); + }); +}); diff --git a/src/monitoringV2/equity-headroom.logic.ts b/src/monitoringV2/equity-headroom.logic.ts new file mode 100644 index 0000000..34d1e56 --- /dev/null +++ b/src/monitoringV2/equity-headroom.logic.ts @@ -0,0 +1,344 @@ +// Pure equity-headroom assessment: how far reserve equity sits above Equity.sol's MINIMUM_EQUITY, +// whether the trend projects a breach, and which alert action (if any) the service should take. +// +// Why this matters: once deuro.equity() drops strictly below MINIMUM_EQUITY, Equity.restructureCapTable +// becomes callable (wipes nDEPS of the passed addresses; beyond that only the 2% vote quorum protects +// the cap table), and Equity._calculateShares takes its bootstrap branch only for a deposit that lifts +// equity back to at least MINIMUM_EQUITY in the same step — otherwise Equity._invest reverts earlier +// with InsufficientEquity. This watcher already alerts at the boundary (headroom exactly 0) as a +// safety margin — one step before those gated paths open. +// +// Framework-free on purpose: no NestJS, Prisma, Date.now(), or telegram — every clock and +// threshold is an input so the module is unit-testable in isolation. Persistence and delivery +// live in a separate service layer that consumes these pure decisions. + +/** Equity.sol: `uint256 private constant MINIMUM_EQUITY = 1_000 * ONE_DEC18`. PRIVATE — there is no + * on-chain getter (an eth_call to MINIMUM_EQUITY() reverts), so the value has to live here. */ +export const MINIMUM_EQUITY_WEI = 1_000n * 10n ** 18n; + +/** Lookback of the trend regression. */ +export const TREND_WINDOW_HOURS = 24; +/** Below this many samples, or this short a span, the series is too thin to project from. */ +export const TREND_MIN_SAMPLES = 6; +export const TREND_MIN_SPAN_HOURS = 2; +/** While a level stays unresolved, repeat the alert at most this often. */ +export const REPEAT_ALERT_HOURS = 24; +/** Retention of the equity_samples table. */ +export const EQUITY_SAMPLE_RETENTION_DAYS = 30; + +/** An active alert level. OK is represented by `null`, mirroring the nullable marker column. */ +export enum HeadroomLevel { + WARNING = 'WARNING', + CRITICAL = 'CRITICAL', +} + +export interface EquitySample { + timestampMs: number; + reserveEquity: bigint; +} + +export interface HeadroomTrend { + /** false when the series is too thin/short to project from — never project on an unusable trend. */ + usable: boolean; + sampleCount: number; + spanHours: number; + /** dEURO per hour, negative when falling. null when unusable. */ + slopeDeuroPerHour: number | null; +} + +export interface HeadroomAssessment { + level: HeadroomLevel | null; + /** Human-readable trigger descriptions, in trigger order. Empty when level is null. */ + reasons: string[]; + /** Hours until headroom reaches zero at the current slope. 0 when headroom is already <= 0 + * (regardless of slope or trend usability); null when not falling or unusable. */ + hoursToDepletion: number | null; +} + +export type HeadroomAction = + | { kind: 'none' } + | { kind: 'alert'; level: HeadroomLevel; persistLevel: HeadroomLevel; escalation: boolean; reminder: boolean } + | { kind: 'resolve' }; + +const MS_PER_HOUR = 3_600_000; +const WEI_PER_CENT = 10n ** 16n; + +/** Distance of reserve equity above MINIMUM_EQUITY. May be negative when equity is already at/below the floor. */ +export function computeHeadroomWei(reserveEquity: bigint): bigint { + return reserveEquity - MINIMUM_EQUITY_WEI; +} + +/** Convert wei to a dEURO float for regression/projection only — not for display. */ +export function toDeuro(wei: bigint): number { + return Number(wei) / 1e18; +} + +/** + * Display format with two fraction digits and thousands separators. Cent truncation is exact in + * BigInt, but the final `Number(cents) / 100` step is only precise for protocol-scale magnitudes + * (not every conceivable size above ~2^53 cents). Negatives render as `-1,234.56`. + */ +export function formatDeuro(wei: bigint): string { + const negative = wei < 0n; + const absWei = negative ? -wei : wei; + const cents = absWei / WEI_PER_CENT; + const formatted = (Number(cents) / 100).toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); + // Only keep the sign when truncation left a non-zero amount — else `-1n` would print as `-0.00`. + if (negative && cents !== 0n) return `-${formatted}`; + return formatted; +} + +/** + * Ordinary-least-squares slope of headroom (dEURO) vs time (hours). Projects on headroom — not + * raw equity — so the unit matches hours-to-depletion; MINIMUM_EQUITY is constant so the slope + * equals the equity slope either way. + * + * Only samples within TREND_WINDOW_HOURS of the NEWEST sample are used for the regression — older + * samples are dropped first. The returned sampleCount/spanHours describe this filtered window, not + * the raw input array — except on the early non-finite-timestamp path, which returns before any + * window is formed and reports the raw sorted length with spanHours 0. + */ +export function computeTrend(samples: EquitySample[]): HeadroomTrend { + // Defensive copy+sort: callers may hand samples in any order (DB without ORDER BY, merge of windows). + const sorted = samples.slice().sort((a, b) => a.timestampMs - b.timestampMs); + + // Non-finite timestamps would propagate through the span/regression math below (Infinity takes + // part in ordering comparisons normally, e.g. Infinity > 5 — unlike NaN, it would not simply + // vanish there); catch them explicitly here via Number.isFinite instead. + if (sorted.some((s) => !Number.isFinite(s.timestampMs))) { + return { usable: false, sampleCount: sorted.length, spanHours: 0, slopeDeuroPerHour: null }; + } + + // Window width is part of the slope's meaning; a caller's DB lookback is efficiency, not a correctness guarantee. + const windowed = + sorted.length === 0 + ? sorted + : sorted.filter((s) => s.timestampMs >= sorted[sorted.length - 1].timestampMs - TREND_WINDOW_HOURS * MS_PER_HOUR); + + const sampleCount = windowed.length; + const spanHours = sampleCount < 2 ? 0 : (windowed[sampleCount - 1].timestampMs - windowed[0].timestampMs) / MS_PER_HOUR; + + if (sampleCount < TREND_MIN_SAMPLES || spanHours < TREND_MIN_SPAN_HOURS) { + return { usable: false, sampleCount, spanHours, slopeDeuroPerHour: null }; + } + + const t0 = windowed[0].timestampMs; + const xs: number[] = []; + const ys: number[] = []; + for (const s of windowed) { + xs.push((s.timestampMs - t0) / MS_PER_HOUR); + ys.push(toDeuro(computeHeadroomWei(s.reserveEquity))); + } + + let sumX = 0; + let sumY = 0; + for (let i = 0; i < sampleCount; i++) { + sumX += xs[i]; + sumY += ys[i]; + } + const meanX = sumX / sampleCount; + const meanY = sumY / sampleCount; + + let covXY = 0; + let varX = 0; + for (let i = 0; i < sampleCount; i++) { + const dx = xs[i] - meanX; + const dy = ys[i] - meanY; + covXY += dx * dy; + varX += dx * dx; + } + + // Zero x-variance (identical timestamps) would divide by zero and yield NaN — refuse to project. + // Unreachable today: identical timestamps force spanHours to 0, which the TREND_MIN_SPAN_HOURS + // guard above already returns on. Kept as a direct safeguard on the division itself, not because + // this branch is reachable in practice. + if (varX === 0) { + return { usable: false, sampleCount, spanHours, slopeDeuroPerHour: null }; + } + + const slopeDeuroPerHour = covXY / varX; + // Same NaN trap as above: if the slope itself is non-finite, refuse rather than mark usable with NaN. + if (!Number.isFinite(slopeDeuroPerHour)) { + return { usable: false, sampleCount, spanHours, slopeDeuroPerHour: null }; + } + + return { + usable: true, + sampleCount, + spanHours, + slopeDeuroPerHour, + }; +} + +/** + * Classify current headroom + trend into WARNING / CRITICAL / OK. + * + * CRITICAL fires for an already-breached floor or a projection that hits zero within the horizon. + * WARNING is a soft level-only floor (silenced when warningFloorWei is 0n) and never overrides CRITICAL. + */ +export function evaluateHeadroom(input: { + headroomWei: bigint; + trend: HeadroomTrend; + warningFloorWei: bigint; + projectionHorizonHours: number; +}): HeadroomAssessment { + const { headroomWei, trend, warningFloorWei, projectionHorizonHours } = input; + + let hoursToDepletion: number | null = null; + if (headroomWei <= 0n) { + hoursToDepletion = 0; + } else if (trend.usable && trend.slopeDeuroPerHour !== null && trend.slopeDeuroPerHour < 0) { + hoursToDepletion = toDeuro(headroomWei) / -trend.slopeDeuroPerHour; + } + + const reasons: string[] = []; + let critical = false; + let warning = false; + + // (1) Contract gates use strict `< MINIMUM_EQUITY`; at headroom exactly 0 no path is open yet. + // Alert here anyway as a safety margin — one wei lower and both gated paths are live. + if (headroomWei <= 0n) { + critical = true; + if (headroomWei === 0n) { + reasons.push(`headroom is 0.00 dEURO — equity has reached \`MINIMUM_EQUITY\`; the gated paths open one wei below`); + } else { + reasons.push( + `headroom is ${formatDeuro(headroomWei)} dEURO — equity is below \`MINIMUM_EQUITY\`; \`restructureCapTable\` is ` + + `callable now, and the bootstrap branch additionally applies to any deposit that lifts equity back to at ` + + `least \`MINIMUM_EQUITY\`` + ); + } + } + + // (2) Falling fast enough that still-positive headroom hits zero inside the configured horizon. + // Require headroomWei > 0 so an already-breached floor (hoursToDepletion = 0 via trigger (1)) + // does not also emit a contradictory "projected to deplete in 0.0 h" reason. + if (headroomWei > 0n && hoursToDepletion !== null && hoursToDepletion <= projectionHorizonHours) { + critical = true; + if (trend.usable && trend.slopeDeuroPerHour !== null && trend.slopeDeuroPerHour < 0) { + const slope = trend.slopeDeuroPerHour; + const perDay = slope * 24; + reasons.push( + `headroom projected to deplete in ${hoursToDepletion.toFixed(1)} h at ${slope.toFixed(2)} dEURO/h (${perDay.toFixed(2)} dEURO/day)` + ); + } + } + + // (3) Soft floor — operator-configured headroom cushion. warningFloorWei=0n mutes this tier only. + // Require warningFloorWei > 0 so a muted floor never tags negative headroom as "below 0.00". + if (warningFloorWei > 0n && headroomWei < warningFloorWei) { + warning = true; + reasons.push(`headroom is ${formatDeuro(headroomWei)} dEURO — below warning floor of ${formatDeuro(warningFloorWei)} dEURO`); + } + + if (critical) { + return { level: HeadroomLevel.CRITICAL, reasons, hoursToDepletion }; + } + if (warning) { + return { level: HeadroomLevel.WARNING, reasons, hoursToDepletion }; + } + return { level: null, reasons: [], hoursToDepletion }; +} + +/** Parse a persisted marker string. Unknown values throw — never silently map garbage to OK. */ +export function parseHeadroomLevel(raw: string | null): HeadroomLevel | null { + if (raw === null) return null; + if (raw === HeadroomLevel.WARNING) return HeadroomLevel.WARNING; + if (raw === HeadroomLevel.CRITICAL) return HeadroomLevel.CRITICAL; + throw new Error(`unknown headroom level: ${raw}`); +} + +/** + * Dedup / escalation / re-arm machine for the nullable headroom marker. + * + * Escalation (WARNING → CRITICAL) bypasses the repeat window so the first deterioration out of a + * stored WARNING marker is never swallowed by a recent softer alert. Once a reminder has already + * raised persistLevel to CRITICAL (flutter-guard), a later relapse to CRITICAL no longer matches + * this escalation branch (storedLevel is CRITICAL, not WARNING) and is instead suppressed until the + * repeat window elapses like any other repeat — the accepted price of the flutter-guard below. + * persistLevel keeps the higher of stored and current on reminders so a CRITICAL→WARNING flutter + * does not re-arm a fresh escalation when CRITICAL returns — only a true resolve (recovery above + * the floor) clears the marker. + */ +export function decideAction(input: { + level: HeadroomLevel | null; + storedLevel: HeadroomLevel | null; + storedAtSeconds: bigint | null; + nowSeconds: bigint; + repeatAfterSeconds: bigint; +}): HeadroomAction { + const { level, storedLevel, storedAtSeconds, nowSeconds, repeatAfterSeconds } = input; + + if (repeatAfterSeconds <= 0n) { + throw new Error(`repeatAfterSeconds must be positive, got ${repeatAfterSeconds.toString()}`); + } + + // Exactly one of the marker halves set is a corrupted row — fail loud, never guess. + if ((storedLevel === null) !== (storedAtSeconds === null)) { + throw new Error( + `inconsistent headroom marker: storedLevel=${String(storedLevel)}, storedAtSeconds=${ + storedAtSeconds === null ? 'null' : storedAtSeconds.toString() + }` + ); + } + + if (level === null) { + if (storedLevel === null) return { kind: 'none' }; + return { kind: 'resolve' }; + } + + // Live level is active. + if (storedLevel === null) { + return { + kind: 'alert', + level, + persistLevel: level, + escalation: false, + reminder: false, + }; + } + + // Escalation ignores the repeat window — a WARNING→CRITICAL step always yields an alert action; + // actual delivery/paging is the service layer's job (marker only after confirmed Telegram delivery). + if (level === HeadroomLevel.CRITICAL && storedLevel === HeadroomLevel.WARNING) { + return { + kind: 'alert', + level: HeadroomLevel.CRITICAL, + persistLevel: HeadroomLevel.CRITICAL, + escalation: true, + reminder: false, + }; + } + + // Invariant: storedAtSeconds is set whenever storedLevel is (checked above) — this branch is + // actually unreachable, the XOR check above already throws on this exact combination. Not + // needed for type narrowing either (tsconfig has strictNullChecks: false); kept only as an + // honest fail-loud safeguard, not because it can fire. + if (storedAtSeconds === null) { + throw new Error(`inconsistent headroom marker: storedLevel=${storedLevel}, storedAtSeconds=null`); + } + + if (nowSeconds - storedAtSeconds >= repeatAfterSeconds) { + // Flutter-guard: never demote the persisted level on a reminder. Only resolve clears it. + return { + kind: 'alert', + level, + persistLevel: higherLevel(storedLevel, level), + escalation: false, + reminder: true, + }; + } + + return { kind: 'none' }; +} + +/** CRITICAL outranks WARNING; used so a demoted live level cannot lower the stored marker. */ +function higherLevel(a: HeadroomLevel, b: HeadroomLevel): HeadroomLevel { + if (a === HeadroomLevel.CRITICAL || b === HeadroomLevel.CRITICAL) { + return HeadroomLevel.CRITICAL; + } + return HeadroomLevel.WARNING; +} diff --git a/src/monitoringV2/monitoring.service.ts b/src/monitoringV2/monitoring.service.ts index aa64721..4e5e624 100644 --- a/src/monitoringV2/monitoring.service.ts +++ b/src/monitoringV2/monitoring.service.ts @@ -164,6 +164,12 @@ export class MonitoringService implements OnModuleInit { await this.runWatcher('checkExpiringSoon', () => this.positionService.checkExpiringSoon(this.telegramService)); await this.runWatcher('checkExpired', () => this.positionService.checkExpired(this.telegramService)); await this.runWatcher('checkExpiredInPhase2', () => this.positionService.checkExpiredInPhase2(this.telegramService)); + // Protocol-level watcher: pages on three triggers — the configured warning floor (well above + // MINIMUM_EQUITY), a trend-projected breach within the configured horizon, or equity actually + // reaching MINIMUM_EQUITY itself — one step ahead of where the gated contract paths (Equity + // bootstrap branch, restructureCapTable) actually open, since those require equity strictly + // below MINIMUM_EQUITY, not merely at it. + await this.runWatcher('checkEquityHeadroom', () => this.deuroService.checkEquityHeadroom(this.telegramService)); await this.telegramService.sendPendingAlerts(); // send pending telegram alerts // Mark full cycle as completed diff --git a/src/monitoringV2/prisma/migrations/0006_equity_headroom_alert/migration.sql b/src/monitoringV2/prisma/migrations/0006_equity_headroom_alert/migration.sql new file mode 100644 index 0000000..f6b0ca4 --- /dev/null +++ b/src/monitoringV2/prisma/migrations/0006_equity_headroom_alert/migration.sql @@ -0,0 +1,17 @@ +-- Rolling equity samples: deuro_state is a single upserted row, so the headroom watcher has no +-- history to project a trend from. One sample per monitoring cycle closes that gap. +CREATE TABLE "equity_samples" ( + "id" BIGSERIAL NOT NULL, + "block_number" BIGINT NOT NULL, + "reserve_equity" DECIMAL(78,0) NOT NULL, + "timestamp" TIMESTAMPTZ NOT NULL, + + CONSTRAINT "equity_samples_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "idx_equity_samples_timestamp" ON "equity_samples" ("timestamp" DESC); + +-- Dedup + escalation state for the equity-headroom alert (NULL = no alert active). +ALTER TABLE "deuro_state" + ADD COLUMN "equity_headroom_alerted_at" BIGINT, + ADD COLUMN "equity_headroom_alert_level" VARCHAR(10); diff --git a/src/monitoringV2/prisma/repositories/deuro.repository.ts b/src/monitoringV2/prisma/repositories/deuro.repository.ts index 792acd3..7ad0597 100644 --- a/src/monitoringV2/prisma/repositories/deuro.repository.ts +++ b/src/monitoringV2/prisma/repositories/deuro.repository.ts @@ -111,4 +111,64 @@ export class DeuroRepository { throw error; } } + + /** Append one equity sample for the current cycle. */ + async insertEquitySample(sample: { blockNumber: bigint; reserveEquity: bigint; timestamp: Date }): Promise { + await this.prisma.equitySample.create({ + data: { + blockNumber: sample.blockNumber, + reserveEquity: sample.reserveEquity.toString(), + timestamp: sample.timestamp, + }, + }); + } + + /** Drop samples older than the cutoff. Returns the number of deleted rows. */ + async pruneEquitySamples(cutoff: Date): Promise { + const result = await this.prisma.equitySample.deleteMany({ + where: { timestamp: { lt: cutoff } }, + }); + return result.count; + } + + /** Samples at or after `since`, oldest first — the input series of the trend projection. */ + async findEquitySamplesSince(since: Date): Promise> { + const rows = await this.prisma.equitySample.findMany({ + where: { timestamp: { gte: since } }, + orderBy: { timestamp: 'asc' }, + select: { timestamp: true, reserveEquity: true }, + }); + return rows.map((r) => ({ + timestampMs: r.timestamp.getTime(), + reserveEquity: BigInt(r.reserveEquity.toFixed(0)), + })); + } + + /** Persisted dedup/escalation marker of the headroom alert. */ + async getHeadroomAlertState(): Promise<{ alertedAt: bigint | null; level: string | null }> { + const state = await this.prisma.deuroState.findUnique({ + where: { id: 1 }, + select: { equityHeadroomAlertedAt: true, equityHeadroomAlertLevel: true }, + }); + + if (!state) return { alertedAt: null, level: null }; + + return { alertedAt: state.equityHeadroomAlertedAt, level: state.equityHeadroomAlertLevel }; + } + + /** Mark the headroom alert as sent at `alertedAt` with the given level. */ + async setHeadroomAlertState(alertedAt: bigint, level: string): Promise { + await this.prisma.deuroState.update({ + where: { id: 1 }, + data: { equityHeadroomAlertedAt: alertedAt, equityHeadroomAlertLevel: level }, + }); + } + + /** Clear the marker after a confirmed recovery, so a second dip alerts again. */ + async clearHeadroomAlertState(): Promise { + await this.prisma.deuroState.update({ + where: { id: 1 }, + data: { equityHeadroomAlertedAt: null, equityHeadroomAlertLevel: null }, + }); + } } diff --git a/src/monitoringV2/prisma/schema.prisma b/src/monitoringV2/prisma/schema.prisma index ee85d3b..b88a19b 100644 --- a/src/monitoringV2/prisma/schema.prisma +++ b/src/monitoringV2/prisma/schema.prisma @@ -224,9 +224,25 @@ model DeuroState { equityTradeCount24h Int @default(0) @map("equity_trade_count_24h") equityDelegations24h Int @default(0) @map("equity_delegations_24h") + // Equity-headroom alert: dedup + escalation state (see equity-headroom.logic.ts) + equityHeadroomAlertedAt BigInt? @map("equity_headroom_alerted_at") // Unix seconds + equityHeadroomAlertLevel String? @map("equity_headroom_alert_level") @db.VarChar(10) + // Metadata blockNumber BigInt @default(0) @map("block_number") timestamp DateTime @default(now()) @db.Timestamptz @@map("deuro_state") } + +// Rolling equity samples — one row per monitoring cycle. deuro_state is a single upserted row and +// therefore keeps no history; the headroom watcher needs a series to project the trend from. +model EquitySample { + id BigInt @id @default(autoincrement()) + blockNumber BigInt @map("block_number") + reserveEquity Decimal @map("reserve_equity") @db.Decimal(78, 0) + timestamp DateTime @db.Timestamptz + + @@index([timestamp(sort: Desc)], map: "idx_equity_samples_timestamp") + @@map("equity_samples") +} diff --git a/src/monitoringV2/telegram.service.ts b/src/monitoringV2/telegram.service.ts index 0231c7f..eda8569 100644 --- a/src/monitoringV2/telegram.service.ts +++ b/src/monitoringV2/telegram.service.ts @@ -176,6 +176,21 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy { return [env, chain].filter((part) => part.length > 0).join(' '); } + /** + * Send an alert with an explicit icon and header. Returns true only on confirmed delivery to at + * least one chat — callers can then decide whether to persist "alerted" state and retry on the + * next cycle otherwise. Watchers with graduated severity need this: a warning or an all-clear + * must not reach operators under a "CRITICAL ALERT" banner. + */ + async sendAlert(icon: string, header: string, message: string): Promise { + if (!this.enabled) return false; + + const formattedMessage = `${icon} ${this.envTag()} *${header}*\n\n${message}\n\n_Timestamp: ${new Date().toISOString()}_`; + const delivered = await this.broadcast(formattedMessage); + if (delivered) this.logger.log(`Alert sent via Telegram: ${header}`); + return delivered; + } + /** * Send a critical alert to every subscriber. Returns true only on confirmed delivery to * at least one chat. Returns false when telegram is disabled, no subscribers exist, or @@ -183,12 +198,7 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy { * on the next cycle. */ async sendCriticalAlert(message: string): Promise { - if (!this.enabled) return false; - - const formattedMessage = `🚨 ${this.envTag()} *CRITICAL ALERT*\n\n${message}\n\n_Timestamp: ${new Date().toISOString()}_`; - const delivered = await this.broadcast(formattedMessage); - if (delivered) this.logger.log('Critical alert sent via Telegram'); - return delivered; + return this.sendAlert('🚨', 'CRITICAL ALERT', message); } // --- Subscriber management ---------------------------------------------------------