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 ---------------------------------------------------------