Skip to content

Release: develop -> main - #85

Open
github-actions[bot] wants to merge 1 commit into
mainfrom
develop
Open

Release: develop -> main#85
github-actions[bot] wants to merge 1 commit into
mainfrom
develop

Conversation

@github-actions

Copy link
Copy Markdown

Automatic Release PR

This PR was automatically created after changes were pushed to develop.

Commits: 1 new commit(s)

Checklist

  • Review all changes
  • Verify CI passes
  • Approve and merge when ready for production

* 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant