FoundrySolidityLayer 2
7.25 ETH
Submission Details
Impact: medium
Likelihood: medium

Deposits made shortly after `riskWindowStart` opens are not actually "crushed to ~zero" bonus weight, letting an opportunistic including self-sealing depositor capture bonus share meant for genuinely pre-risk stakers

Author Revealed upon completion

Root + Impact

Description

Normal behavior: docs/DESIGN.md §3 justifies allowing deposits during UNDER_ATTACK (unlike PROMOTION_REQUESTED/PRODUCTION/CORRUPTED, which are blocked) on the premise that such a depositor's bonus share is "crushed to ~zero by the k=2 weighting, entry is floored at riskWindowStart ≈ now, so (T − entry)² ≈ 0." This is meant to remove any incentive to game a late deposit once risk is publicly visible.
The issue is that the "≈ zero" claim silently assumes T (resolution) is close behind riskWindowStart (the moment risk is first observed). Nothing in the code enforces that gap, and docs/DESIGN.md §1 explicitly states the opposite in general: UNDER_ATTACK is "the agreement's... normal operating mode," not a brief pre-terminal stretch. A new deposit's entryTime is simply max(block.timestamp, riskWindowStart) it is never floored toward T, only toward the start of the risk window. If UNDER_ATTACK runs for any meaningful duration before resolving, a depositor who enters right as the window opens (whether by being the transaction that itself seals riskWindowStart, or simply by depositing moments after someone else seals it) gets entry ≈ riskWindowStart, which can be just as far from T as a genuinely pre-risk depositor i.e. full, not crushed, k=2 weight. Meanwhile every truly pre-risk depositor already in the pool gets floored up to that same riskWindowStart via _clampUserSums, losing their real head start. The result is a transfer of bonus weight from genuinely early stakers to an opportunistic entrant who committed capital only after risk was already known.

function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
...
// @> _assertDepositsAllowed(_observePoolState()); // can itself seal riskWindowStart = now
...
_clampUserSums(msg.sender);
// Pre-risk deposits use wall clock and get promoted to riskWindowStart later via
// `_clampUserSums`; post-risk deposits go in at wall clock (already ≥ riskWindowStart).
uint256 newEntry = block.timestamp;
uint256 start = riskWindowStart;
// @> if (start != 0 && newEntry < start) newEntry = start; // only floors UP toward `start`, never toward T
uint256 contribTime = received * newEntry;
uint256 contribTimeSq = received * newEntry * newEntry;
...
}
/// @dev `UNDER_ATTACK` is intentionally NOT blocked while `PROMOTION_REQUESTED` is. Both are
/// active-risk (attackable, still corruptible); the asymmetry is about deposit *timing*, not
// @> /// safety — UNDER_ATTACK deposits earn ~zero k=2 bonus and self-lock (no trap), whereas
/// PROMOTION_REQUESTED is the closing-window stretch where a late join would be gameable.
function _assertDepositsAllowed(IAttackRegistry.ContractState state) private pure { ... }
docs/DESIGN.md §1:
"...UNDER_ATTACK and PROMOTION_REQUESTED... mean the in-scope contracts are legally attackable
@> by whitehats: the agreement is live and exposed, which is its normal operating mode."
docs/DESIGN.md §3:
"UNDER_ATTACK: a depositor takes on real, visible, voluntary risk. Their bonus share is crushed
@> to ~zero by the k=2 weighting — entry is floored at riskWindowStart ≈ now, so (T − entry)² ≈ 0"

Risk

Likelihood:

Occurs on every pool where the UNDER_ATTACK phase is not resolved (to PRODUCTION/CORRUPTED) within a short time of first being observed on-chain plausible under the protocol's own stated model, since §1 describes UNDER_ATTACK as the agreement's normal, ongoing operating mode rather than a brief terminal stretch.
Triggers the moment the risk window is sealed (by anyone: a self-sealing depositor, pokeRiskWindow(), or any other pool interaction) while the pool still has significant time remaining before resolution — the earlier in the pool's life UNDER_ATTACK begins relative to T, the larger the captured weight.

Impact:

Bonus share is redistributed away from genuinely pre-risk stakers (who bore risk for the longest, un-hedged, and are the intended beneficiaries of the k=2 weighting) toward an opportunistic entrant who committed capital only after risk was already publicly observable on-chain undermining the stated purpose of both the k=2 formula and the UNDER_ATTACK-deposits-allowed carve-out.
Contradicts the specific, quantified accepted-behavior claim in docs/DESIGN.md §3 ("crushed to ~zero"), so the deposit-gating design decision is not actually delivering the property it was justified by.

Proof of Concept

Setup: bonus pool = 1000 tokens, T (resolution) = 10_000 (relative units), riskWindowStart = 0 initially.
1. Alice stakes 100 at t = 100 (long before any risk is observed). Pre-risk deposit, unfloored for now.
2. Registry transitions to UNDER_ATTACK at some later point; nobody interacts with the pool for a
while (per docs/DESIGN.md §1, UNDER_ATTACK is the agreement's normal operating mode — it can
persist for a long time before resolving).
3. At t = 500, Eve — monitoring the registry off-chain — calls stake(900).
-> _observePoolState() sees UNDER_ATTACK for the first time -> _markRiskWindowStart() seals
riskWindowStart = 500, and eagerly resets sumStakeTime/sumStakeTimeSq treating ALL currently
eligible stake (Alice's 100) as entering at t = 500.
-> Eve's own newEntry = max(500, 500) = 500 -- identical treatment to Alice's floored entry,
despite Eve depositing only after risk was already known and Alice's true entry being at t=100.
4. Pool resolves SURVIVED at T = 10_000 (riskWindowEnd = 10_000).
-> Alice's score: (10_000 - 500)^2 * 100 = 902,500,000
-> Eve's score: (10_000 - 500)^2 * 900 = 8,122,500,000
-> globalScore = 902,500,000 + 8,122,500,000 = 9,025,000,000
-> Alice's bonus share = 1000 * 902,500,000 / 9,025,000,000 ≈ 100.0 tokens
-> Eve's bonus share = 1000 * 8,122,500,000 / 9,025,000,000 ≈ 900.0 tokens
Contrast with the documented expectation: had riskWindowStart instead sealed at the TRUE onset of
UNDER_ATTACK (say t = 490, i.e. a vigilant party poked almost immediately) BEFORE Eve ever
deposited, and deposits during UNDER_ATTACK were actually crushed toward T as §3 claims, Eve's
900 tokens should earn negligible weight relative to Alice's true multi-hundred-unit head start —
instead Eve, entering after risk was already public, captures 9x Alice's bonus share simply by
being the (or a) first mover once the window opens, not by having borne any risk before it did.

Recommended Mitigation

Block UNDER_ATTACK deposits, matching the existing PROMOTION_REQUESTED precedent:

function _assertDepositsAllowed(IAttackRegistry.ContractState state) private pure {
if (
state == IAttackRegistry.ContractState.PROMOTION_REQUESTED
+ || state == IAttackRegistry.ContractState.UNDER_ATTACK
|| state == IAttackRegistry.ContractState.PRODUCTION
|| state == IAttackRegistry.ContractState.CORRUPTED
) {
revert DepositsDisabledDuringRisk();
}
}

pokeRiskWindow() remains available for anyone to seal the window without also being able to profit from the seal. This is the simplest fix but reverses §3's explicit design choice to welcome voluntary risk capital during UNDER_ATTACK.

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!