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

Whale can extract the entire bonus pool by front-running terminal resolution with a same-block deposit

Author Revealed upon completion

Root + Impact

A whale can extract the entire bonus pool by front-running terminal resolution with a same-block deposit. The k=2 time-weighted bonus formula falls back to amount-weighted when globalScore == 0, which the whale forces by collapsing riskWindowStart == riskWindowEnd.

Description

  • The _bonusShare function computes each staker's bonus using a k=2 time-weighted formula that rewards longer at-risk duration. When globalScore == 0, it distributes proportionally to stake amount as a fallback.

  • A whale can front-run a terminal registry transition (PRODUCTION/CORRUPTED) by depositing in the same block. This makes riskWindowStart == riskWindowEnd, so every staker's (T − entry)² == 0 and globalScore == 0. The amount-weighted fallback lets the whale's dominant stake capture nearly all bonus despite zero at-risk time, extracting the entire bonus pool from legitimate long-bearing stakers.

// @> _bonusShare: globalScore == 0 triggers amount-weighted split
return Math.mulDiv(userEligible, snapshotTotalBonus, snapshotTotalStaked);
// @> _markRiskWindowEnd: no minimum window enforced when t == riskWindowStart
riskWindowEnd = uint32(t);
// riskWindowEnd can equal riskWindowStart, collapsing all scores to zero

Risk

Likelihood:

  • A terminal registry transition (PRODUCTION/CORRUPTED) becomes visible in the mempool while the pool has not yet been poked during active risk, creating the front-running opportunity

  • The whale's capital is fully redeemable after resolution, making the attack cost essentially one block of gas

Impact:

  • A whale can capture >99% of the bonus pool that was intended for long-bearing stakers

  • Long-term stakers who bore the actual at-risk time receive negligible bonus rewards, breaking the protocol's incentive model

Proof of Concept

// File: test/deepdive/DeepDivePoC.sol
function testPoC9_whaleSameBlockBonusExtraction() external {
// Alice stakes 100 ONE pre-risk and waits 20 days.
_stake(alice, 100 * ONE);
uint256 t0 = block.timestamp;
vm.warp(t0 + 20 days);
// Carol contributes 10_000 ONE to the bonus pool.
_contributeBonus(carol, 10_000 * ONE);
// Registry enters UNDER_ATTACK, but no one pokes the pool.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
// Same block: whale deposits 100_000 ONE, becoming the first active-risk observer.
address whale = makeAddr("whale");
_stake(whale, 100_000 * ONE);
// Registry transitions to PRODUCTION in the same block.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
// Moderator flags SURVIVED. riskWindowEnd == riskWindowStart == block.timestamp.
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
uint256 aliceBonus = _claim(alice) - 100 * ONE;
uint256 whaleBonus = _claim(whale) - 100_000 * ONE;
// Alice (20 days at risk, 100 stake) gets ~0.1% of the bonus.
// Whale (same-block deposit, 100k stake) gets ~99.9% of the bonus.
assertLt(aliceBonus, 100 * ONE);
assertGt(whaleBonus, 9_900 * ONE);
}

Run:

forge test --match-path test/deepdive/DeepDivePoC.sol --match-test testPoC9 -vv

Recommended Mitigation

+ // In _markRiskWindowEnd(): enforce at least one block of separation
function _markRiskWindowEnd() internal {
uint256 t = block.timestamp;
if (t > expiry) t = expiry;
+ if (riskWindowStart != 0 && t <= riskWindowStart) {
+ t = riskWindowStart + 1;
+ }
riskWindowEnd = uint32(t);
emit RiskWindowEnded(t);
}

Alternatively, in _bonusShare when globalScore == 0 and riskWindowStart == riskWindowEnd, distribute bonus only to pre-risk stakers or burn to recovery instead of amount-weighting.

Support

FAQs

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

Give us feedback!