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

Expanded k=2 score intermediates can overflow and permanently lock all staker principal and bonus

Author Revealed upon completion

Root Cause And Impact

stake() can accept an amount for which the stored amount * entryTime^2 moment fits in
uint256, while _bonusShare() later overflows when it adds two individually valid absolute-time
products before subtracting them:

uint256 plus = T * T * snapshotTotalStaked + snapshotSumStakeTimeSq;

The mathematical score amount * (T - entryTime)^2 may be zero or otherwise fit in uint256, but
the expanded positive intermediate can still exceed type(uint256).max. Solidity reverts before
the subtraction and before the documented globalScore == 0 fallback can execute.

After a SURVIVED resolution, every staker claim evaluates the same overflowing global
denominator. No staker can recover principal or bonus, sweepUnclaimedBonus() reserves the entire
accounted balance, and the terminal PRODUCTION registry state prevents recovery through a
CORRUPTED re-flag. The pool's accounted principal and bonus are permanently locked.

The same root cause also affects EXPIRED claims and can revert earlier in
_markRiskWindowStart(), preventing the permissionless expiry backstop from resolving a pool while
the registry remains in its normal UNDER_ATTACK state.

Vulnerability Details

The pool records absolute Unix-time moments when stake is deposited:

uint256 contribTime = received * newEntry;
uint256 contribTimeSq = received * newEntry * newEntry;

This checked multiplication only implicitly bounds an accepted deposit relative to its current
entry timestamp. When the risk window opens, the global second moment is reset using a potentially
later timestamp:

sumStakeTimeSq = totalEligibleStake * t * t;

At settlement, _bonusShare() expands the square into absolute-time terms:

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;

For an effective entry time E, an accepted total stake can satisfy:

type(uint256).max / (T^2 + E^2) < totalStake
<= type(uint256).max / E^2

The right side lets the entry-time moment fit, while the left side guarantees that the later
plus addition overflows. Each product can fit independently; it is their addition that reverts.

The documented zero-score fallback is unreachable

The issue does not require elapsed risk time. If the risk window opens and closes at the same
timestamp, the correct mathematical score is exactly zero:

amount * (T - E)^2 = amount * 0^2 = 0

docs/DESIGN.md section 7 expressly requires _bonusShare() to use an amount-weighted split when
globalScore == 0. However, the implementation first evaluates:

amount * T^2 + amount * E^2 = 2 * amount * T^2

and can overflow before line 712 checks globalScore == 0. The fallback therefore does not cover
the full accepted input domain it was designed to handle.

Reachability does not require a defective token

At the repository timestamp, the primary PoC requires approximately 1.887e58 base units. This is
economically unrealistic for a conventional 18-decimal asset, so practical likelihood depends on
the allowlisted token's unit scale. It is approximately 0.01887 whole token for a 60-decimal
asset.

This does not require fee-on-transfer behavior, rebasing, callbacks, or a malicious token. ERC20
transfers operate on raw uint256 base units, while decimals() is display metadata. A plain
OpenZeppelin ERC20 can use the relevant scale by overriding only:

function decimals() public pure override returns (uint8) {
return 60;
}

The factory validates neither decimals nor a maximum aggregate stake. Allowlisting is the normal
setup path for every supported token, and the protocol documentation excludes non-standard
transfer accounting but does not exclude a plain high-decimal ERC20. Therefore, the required
state is unusual but remains inside the declared token model.

Only one bonus base unit is needed to bypass _bonusShare()'s snapshotTotalBonus == 0 early
return. The unsafe aggregate stake may also be reached collectively rather than by one account.

Why The Funds Cannot Be Recovered

  1. claimSurvived() always calls _bonusShare() and reverts on the overflowing global score.

  2. A staker calling claimExpired() while unresolved reverts the entire resolution transaction.

  3. A non-staker can finalize EXPIRED or auto-SURVIVED through the zero-stake return, but every
    subsequent staker claim still executes _bonusShare() and reverts.

  4. Re-flagging SURVIVED snapshots the same accounting and cannot repair the arithmetic.

  5. After registry PRODUCTION, the moderator cannot re-flag the pool as CORRUPTED.

  6. sweepUnclaimedBonus() reserves
    totalEligibleStake + snapshotTotalBonus - claimedBonus. Since no claim succeeds, this equals
    the entire accounted token balance and the sweep reverts with NothingToSweep.

Post-resolution donations may be swept, but no accounted principal or bonus can leave.

Impact And Preconditions

  • Impact: complete and permanent loss of access to every staker's principal and bonus in the
    affected SURVIVED/finalized EXPIRED pool.

  • Attacker privilege: none. stake() and contributeBonus() are public protocol operations.

  • Token behavior: ordinary ERC20 transfer semantics are sufficient.

  • Economic precondition: the aggregate raw token balance must cross the unsafe arithmetic
    threshold. This is prohibitive for conventional 18-decimal assets but small in whole-token terms
    for a supported high-decimal asset.

  • Additional effect: a stake that fits at deposit time can overflow the later
    _markRiskWindowStart() reset, blocking expiry resolution while UNDER_ATTACK and causing the
    observed-risk bonus accounting to be lost if the registry later becomes terminal.

Proof Of Concept

Place the following test in test/audit/ConfidencePoolScoreOverflow.t.sol:

function test_stakeThatFitsAtEntryCanPermanentlyOverflowAtClaim() external {
uint256 entry = block.timestamp;
uint256 terminal = uint256(pool.expiry()) - 1;
uint256 fitsAtEntry = type(uint256).max / (entry * entry);
uint256 poisonTotal = type(uint256).max / (terminal * terminal + entry * entry) + 1;
uint256 aliceStake = poisonTotal / 2;
uint256 bobStake = poisonTotal - aliceStake;
assertLe(poisonTotal * entry * entry, type(uint256).max);
assertLe(poisonTotal, fitsAtEntry);
assertLe(poisonTotal * terminal * terminal, type(uint256).max);
assertGt(poisonTotal, type(uint256).max / (terminal * terminal + entry * entry));
_stake(alice, aliceStake);
_stake(bob, bobStake);
_contributeBonus(dave, 1);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
vm.warp(terminal);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
vm.prank(alice);
vm.expectRevert(stdError.arithmeticError);
pool.claimSurvived();
vm.prank(bob);
vm.expectRevert(stdError.arithmeticError);
pool.claimSurvived();
vm.expectRevert(IConfidencePool.NothingToSweep.selector);
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(address(pool)), poisonTotal + 1);
assertFalse(pool.claimsStarted());
}

Run:

forge test --match-path test/audit/ConfidencePoolScoreOverflow.t.sol -vvv

The complete regression file contains five passing tests:

[PASS] test_firstRiskObservationAtExpiryOverflowsBeforePoolCanResolve()
[PASS] test_claimExpiredFinalizesButCannotReleaseAnyStakerPrincipal()
[PASS] test_riskWindowStartOverflowPreventsBonusAccrualAndRedirectsBonus()
[PASS] test_sameTimestampWindowStillPermanentlyOverflowsClaims()
[PASS] test_stakeThatFitsAtEntryCanPermanentlyOverflowAtClaim()

These tests cover moderator SURVIVED, mechanical EXPIRED, a zero-duration risk window, failure
of the recovery sweep, and overflow during the first active-risk observation.

Recommended Mitigation

Prefer storing moments relative to riskWindowStart instead of expanding squares over absolute
Unix timestamps. This preserves substantially more arithmetic range and computes the quantity the
protocol actually needs.

As a minimal defensive fix, enforce a conservative aggregate cap after measuring the tokens
actually received and before updating any moments:

uint256 received = balanceAfter > balanceBefore ? balanceAfter - balanceBefore : 0;
if (received == 0) revert NoTokensReceived();
if (received < minStake) revert BelowMinStake();
+uint256 maxTimestamp = uint256(type(uint32).max);
+uint256 maxTotalStake = type(uint256).max / (2 * maxTimestamp * maxTimestamp);
+if (totalEligibleStake > maxTotalStake || received > maxTotalStake - totalEligibleStake) {
+ revert InvalidAmount();
+}
_clampUserSums(msg.sender);

The cap guarantees that both positive terms, their addition, and the corresponding doubled
negative term remain representable for every permitted uint32 timestamp. Any final fix should
bound every expanded intermediate, not only the final Math.mulDiv operation.

Support

FAQs

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

Give us feedback!