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

Late UNDER_ATTACK whale can capture nearly all sponsor bonus when resolution happens in the same timestamp

Author Revealed upon completion

Root + Impact

Description

The protocol is designed to reward stakers based on how long their capital was exposed during the risk window. In normal operation, early stakers should earn most of the bonus, while very late UNDER_ATTACK deposits should earn little to none.

However, the contract still allows staking after riskWindowStart has already been sealed. If the pool is then resolved in the same timestamp, _bonusShare() computes globalScore == 0 and falls back to pure amount-weighted distribution.

At that point, the bonus is no longer based on risk exposure. It is split only by stake size.

This means a whale can wait until the pool is already UNDER_ATTACK, deposit a very large amount, and then capture nearly the full sponsor bonus if resolution happens in the same timestamp.

Root + Impact

The issue comes from the interaction between:

  • stake(), which still allows deposits during UNDER_ATTACK

  • _markRiskWindowStart(), which normalizes existing stake to the current timestamp

  • _bonusShare(), which falls back to amount-weighted payout when globalScore == 0

function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
...
_assertDepositsAllowed(_observePoolState()); // @> still allows deposits during UNDER_ATTACK
...
uint256 newEntry = block.timestamp;
uint256 start = riskWindowStart;
if (start != 0 && newEntry < start) newEntry = start;
// @> late entrants after risk starts still become bonus-eligible
...
}
function _markRiskWindowStart() internal {
uint256 t = block.timestamp;
if (t > expiry) t = expiry;
riskWindowStart = uint32(t);
sumStakeTime = totalEligibleStake * t;
sumStakeTimeSq = totalEligibleStake * t * t;
// @> existing stake is reset as if it all entered at t
}
function _bonusShare(address u, uint256 userEligible) internal view returns (uint256) {
...
uint256 globalScore = plus > minus ? plus - minus : 0;
if (globalScore == 0) {
// @> fallback ignores who actually bore the risk
return Math.mulDiv(userEligible, snapshotTotalBonus, snapshotTotalStaked);
}
}

Risk

Likelihood:

The pool explicitly allows deposits while the registry is UNDER_ATTACK, so no special privilege is required.

  • The moderator can resolve immediately after the attacker joins, creating the same-timestamp condition needed for the fallback path.

Impact:

A late whale can extract almost all of the sponsor-funded bonus with effectively zero observed at-risk time.

  • Honest early stakers, who actually bore the intended risk, can be diluted to a negligible share of the reward.

Proof of Concept

PoC Walkthrough

  1. Alice deposits before any risk exists.

  2. The pool enters UNDER_ATTACK, which seals riskWindowStart.

  3. The attacker then deposits a much larger amount after the risk window has already opened.

  4. The moderator resolves in the same timestamp.

  5. Because no measurable time elapsed between riskWindowStart and riskWindowEnd, the quadratic weighting collapses and globalScore == 0.

  6. The contract falls back to amount-weighted payout using snapshotTotalStaked.

  7. Since the attacker is now the overwhelming majority of total stake, they receive almost the full bonus despite not meaningfully bearing risk.

function testSameBlockLateWhaleCapturesNearlyAllBonus() external {
// Honest early staker.
_stake(alice, 100 * ONE);
_contributeBonus(carol, 100 * ONE);
// Risk starts and seals riskWindowStart.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
// No warp: attacker joins after risk starts but in the same timestamp.
_stake(attacker, 10_000 * ONE);
// Resolve in the same timestamp so riskWindowStart == riskWindowEnd.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(pool.riskWindowStart(), pool.riskWindowEnd(), "same-timestamp window required");
assertEq(pool.snapshotTotalStaked(), 10_100 * ONE, "late whale is included in fallback base");
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimSurvived();
uint256 aliceBonus = token.balanceOf(alice) - aliceBefore - 100 * ONE;
uint256 attackerBefore = token.balanceOf(attacker);
vm.prank(attacker);
pool.claimSurvived();
uint256 attackerBonus = token.balanceOf(attacker) - attackerBefore - 10_000 * ONE;
// Honest early staker gets less than 1% of the bonus.
assertLt(aliceBonus, ONE);
// Late whale captures more than 99% of the bonus.
assertGt(attackerBonus, 99 * ONE);
}

Recommended Mitigation

Prevent post-risk deposits from participating in the globalScore == 0 fallback.

The simplest fix is to stop allowing deposits once riskWindowStart != 0.

function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
...
_assertDepositsAllowed(_observePoolState());
+ if (riskWindowStart != 0) revert StakingClosed();
...
}

Support

FAQs

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

Give us feedback!