Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 8 additions & 0 deletions src/config/config.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
105 changes: 105 additions & 0 deletions src/config/monitoring.config.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
}
});
});
34 changes: 34 additions & 0 deletions src/config/monitoring.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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}`);
Expand Down
Loading
Loading