Skip to content

fix: don't flag the NO fee cap on rounding alone - #321

Merged
DiRaiks merged 1 commit into
fix/no-fee-metricsfrom
fix/no-fee-metrics-fixes
Sep 16, 2026
Merged

DiRaiks merged 1 commit into
fix/no-fee-metricsfrom
fix/no-fee-metrics-fixes

Conversation

@DiRaiks

@DiRaiks DiRaiks commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Description

Follow-up to the gross-rewards cap: capped fires on integer rounding alone, so the
warning names periods where nothing happened.

rawDelta is a difference of two calcNoEarnings, each a settledGrowth term plus a
calcAccruedFeeOffChain term — four truncating divisions against the cap's one. Each
loses under 1 wei, so a period that respects the bound exactly can still read up to
4 wei above it:

rawDelta = floor(A + B) − floor(A)     cap = floor(B)

These differ by 1 whenever the dropped fractional parts carry — roughly half the time.

Measured on mainnet vault 0xd402937b3Ff3c187f727C1146a9E846275E9F711 (3800 ETH, no
exemptions in the window), metrics read statistic-by-reports 20:

  • node operator rewards differed from develop on 5 of 19 periods, by exactly 1 wei
    (28.08, 02.09, 04.09, 07.09, 11.09)
  • all five printed "a fee exemption, a settledGrowth correction, or an unguaranteed
    deposit raised settledGrowth above the vault growth"
    — none of which occurred
  • gross on those days was an ordinary 0.27–0.39 ETH and the fee was exactly 5% of it

A quarter of the rows flagged as exemptions buries the one row that is one.

Fix

-const fee = bigIntMax(bigIntMin(rawDelta, cap), 0n);
-return { fee, rawDelta, cap, capped: rawDelta > cap };
+const capped = rawDelta - cap > CAP_ROUNDING_SLACK_WEI;   // 4n
+const fee = bigIntMax(capped ? cap : rawDelta, 0n);
+return { fee, rawDelta, cap, capped };

Why 4 wei. Five truncations, each losing strictly under 1 wei, so a period honouring
the bound can overshoot by at most 4. It's a proven ceiling, not a number fitted to the
observation (which was 1). Going higher starts swallowing real hits.

Why rawDelta wins inside the slack. Tightening only the flag would leave
min(rawDelta, cap) silently dropping that 1 wei, so nodeOperatorRewards would still
diverge from develop on periods where nothing happened — vaults-api stores these
values and compares them exactly. Now a normal period is bit-identical to the pre-cap
result and the economic bound applies in full only once it actually binds.

Why the ternary replaces the branch-free form. The flag and the clamp need different
thresholds: the flag needs cap + slack, the clamp needs a clean cap — otherwise the
regression case would report 4 wei instead of 0. cap in the returned breakdown stays
the pure economic bound, unslacked, so callers and tests still see a meaningful number.

Description corrections

Three statements in #320 don't survive a check against the contracts; corrected in the
block comment, the docs and the test comment:

  1. Mid-period feeRate change was described as "a bounded under-statement rather
    than an unbounded over-statement"
    . Direction is wrong. setFeeRate calls
    disburseFee() before applying the new rate (and requires a fresh report), so
    settledGrowth becomes the growth of the last fresh report and the entire period
    accrues at the new rate. Above the watermark the cap makes the result exact; below
    it — the case pinned by the existing test, where the true fee is 0 and 2 ETH is
    reported — it is a bounded over-statement.

  2. "The cap is derived from the report leaves — a source independent of the two
    Dashboard snapshots."
    feeRate in the cap comes from those same snapshots; only
    gross is leaf-derived. Worth noting too that inOutDelta isn't in the Merkle leaf
    (vault, totalValue, cumulativeLidoFees, liabilityShares, maxLiabilityShares, slashingReserve) — the contract derives it from RefSlotCache, and the CLI reads it
    from the IPFS file's extraValues, covered by the CID hash only.

  3. The list of settledGrowth-raising paths was missing one. _stopFeeAccrual()
    parks settledGrowth at type(int104).max (~1.01e13 ETH) and is reached from
    Dashboard.voluntaryDisconnect and Dashboard.transferVaultOwnership — ordinary
    owner operations. Uncapped, a disconnecting vault would report a node operator fee
    around 1e12 ETH and a net APR near −1e17%. No occurrences across all 24 mainnet
    vaults in the last 55 days, but both call sites are live.

Also documented: a normal disbursement cannot breach the cap, because _disburseFee
settles at the growth of a reported value and those reports are the same grid the metrics
sample. That's what makes the bound safe rather than merely conservative.

Testing notes

yarn test — 508 passed / 48 files. yarn lint, yarn build clean.

New case, does not flag a healthy period whose raw delta only rounds past the cap:
builds a period whose fractional parts carry and pins rawDelta === cap + 1,
capped === false, fee === rawDelta. Fails without the slack.

End-to-end against mainnet, archive RPC:

vault before after
0xd402…f711 (healthy) warning on 5 of 19 periods, fee 1 wei below develop no warnings, values bit-identical to develop
0x2773…cfcb3 (real exemption) 1 of 24, 28.08 → 0 ETH / −0.0246% unchanged — still caught

rawDelta truncates 4 divisions against the cap's 1, so it lands up to
4 wei above cap on periods where nothing happened — 5 of 19 on mainnet
0xd402...f711. Compare past a 4 wei slack and keep rawDelta inside it,
so untouched periods stay bit-exact.

Also corrects the mid-period feeRate note (bounded over-statement, not
under-), the cap's provenance, and adds _stopFeeAccrual to the list of
settledGrowth-raising paths.
@DiRaiks
DiRaiks requested a review from a team as a code owner September 15, 2026 12:56
@DiRaiks
DiRaiks merged commit 74cb2cb into fix/no-fee-metrics Sep 16, 2026
1 check passed
@DiRaiks
DiRaiks deleted the fix/no-fee-metrics-fixes branch September 16, 2026 07:38

This branch was successfully deployed

1 active deployment
tests — 37a774e7 Deployed Sep 15, 2026 by DiRaiks via check-all #720
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.

2 participants