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

Large accepted stake overflows bonus share and locks SURVIVED/EXPIRED claims

Author Revealed upon completion

Vulnerability details

Summary

The pool accepts stake while it is still unresolved and later pays SURVIVED or EXPIRED claims using a k=2 time-weighted bonus formula. The vulnerable path is not direct theft, but a liveness and fund-access failure: a very large stake can be accepted at the entry timestamp and then make the later bonus-share calculation overflow for every claimant.

The issue matters because _bonusShare() evaluates absolute-time polynomial terms using the terminal timestamp T. A stake amount that fits the accounting at deposit time can become too large once T increases, causing honest stakers' principal and bonus claims to revert in the normal payout path.

Affected Contract

File: src/ConfidencePool.sol

Lines: 222-260

Function/Type: stake(uint256)

File: src/ConfidencePool.sol

Lines: 382-405

Function/Type: claimSurvived()

File: src/ConfidencePool.sol

Lines: 512-607

Function/Type: claimExpired()

File: src/ConfidencePool.sol

Lines: 696-720

Function/Type: _bonusShare(address,uint256)

Vulnerability Details

A staker controls amount in stake(uint256). The function only performs the current entry-time multiplications:

  • received * newEntry

  • received * newEntry * newEntry

Those values can fit at the deposit timestamp. The code does not also check whether the resulting total stake can fit the later claim-time formula for any terminal timestamp up to expiry.

When the outcome is SURVIVED or EXPIRED and a nonzero bonus is present, claimSurvived() and the payout branch of claimExpired() call _bonusShare(). That function computes T * T * snapshotTotalStaked + snapshotSumStakeTimeSq and related terms using outcomeFlaggedAt. Since T can be greater than the original deposit timestamp, the global score can overflow even though the original stake was accepted. The overflow occurs before any payout is transferred, so every claimant can be blocked.

Root Cause

The root cause is that stake admission validates only the current timestamp accounting, while bonus payout later multiplies the accepted stake by a larger terminal timestamp without a conservative cap.

// File: src/ConfidencePool.sol:248-260
uint256 newEntry = block.timestamp;
uint256 start = riskWindowStart;
if (start != 0 && newEntry < start) newEntry = start;
uint256 contribTime = received * newEntry;
uint256 contribTimeSq = received * newEntry * newEntry;
eligibleStake[msg.sender] += received;
userSumStakeTime[msg.sender] += contribTime;
userSumStakeTimeSq[msg.sender] += contribTimeSq;
totalEligibleStake += received;
sumStakeTime += contribTime;
sumStakeTimeSq += contribTimeSq;
// File: src/ConfidencePool.sol:696-720
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) {
if (snapshotTotalStaked == 0) return 0;
return Math.mulDiv(userEligible, snapshotTotalBonus, snapshotTotalStaked);
}
return Math.mulDiv(userScore, snapshotTotalBonus, globalScore);
}

Attack Vectors

Attack Vector 1: SURVIVED claim lock

Step 1 A large-balance staker deposits an amount that fits received * block.timestamp * block.timestamp.
Step 2 The pool later observes an active-risk state, then the moderator flags SURVIVED at a later terminal timestamp.
Step 3 An honest small staker calls claimSurvived().
Result: _bonusShare() overflows while computing the global score, so the honest staker cannot claim principal or bonus.

Attack Vector 2: EXPIRED payout lock

Step 1 A large-balance staker deposits before the terminal payout timestamp.
Step 2 The pool reaches expiry and resolves through the EXPIRED payout branch with a nonzero bonus.
Step 3 A staker calls claimExpired(), which reaches the same _bonusShare() calculation used by SURVIVED claims.
Step 4 The global score overflows before the transfer.
Result: The expiry backstop cannot pay the affected staker while the overflowing stake remains in the snapshot.

Execution Flow

  • stake(uint256) accepts the large deposit and records totalEligibleStake, sumStakeTime, and sumStakeTimeSq.

  • The pool later snapshots snapshotTotalStaked, snapshotTotalBonus, snapshotSumStakeTime, and snapshotSumStakeTimeSq.

  • claimSurvived() or claimExpired() loads the caller's eligible stake.

  • The claim path calls _bonusShare(msg.sender, userEligible).

  • _bonusShare() evaluates T * T * snapshotTotalStaked + snapshotSumStakeTimeSq.

  • Solidity 0.8 arithmetic overflow reverts the transaction.

  • No claimant reaches the token transfer in the affected claim path.

Impact

This is a Medium severity liveness issue affecting staker withdrawals in normal SURVIVED/EXPIRED resolution. One accepted large participant can block unrelated stakers from claiming principal and bonus. The attacker does not need to steal funds for the impact to matter: the protocol's core promise is that stakers can recover their capital plus any earned bonus after a surviving term.

The attacker cost is the temporary deposit of a very large allowlisted stake token amount. The protocol cost is that all affected claimants can be forced into reverting claim transactions until the implementation is corrected or funds are otherwise recovered.

Validation steps

Standalone Validation

Framework used: Foundry / forge

Validation Test File

File 1: test/poc/ConfidencePoolOverflowPoC.t.sol

function testPoC_largeAcceptedStakeLocksAllSurvivedClaims() external {
uint256 start = vm.getBlockTimestamp();
uint256 terminalTime = start + 1 days;
uint256 terminalTimeSquared = terminalTime * terminalTime;
uint256 attackerStake = type(uint256).max / terminalTimeSquared - 1e30;
_stake(alice, 100 * ONE);
_stake(bob, attackerStake);
_contributeBonus(carol, 1);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
vm.warp(terminalTime);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(pool.riskWindowStart(), start);
assertEq(pool.outcomeFlaggedAt(), terminalTime);
assertEq(pool.snapshotTotalBonus(), 1);
vm.prank(alice);
vm.expectRevert(stdError.arithmeticError);
pool.claimSurvived();
vm.prank(bob);
vm.expectRevert(stdError.arithmeticError);
pool.claimSurvived();
}

How to Run

forge test --match-path test/poc/ConfidencePoolOverflowPoC.t.sol -vvv

Captured Test Output

Ran 4 tests for test/poc/ConfidencePoolOverflowPoC.t.sol:ConfidencePoolOverflowPoCTest
[PASS] testPoC_largeAcceptedStakeBlocksExpiredClaimsWhileStillActiveRisk() (gas: 471886)
[PASS] testPoC_largeAcceptedStakeLocksAllSurvivedClaims() (gas: 657267)
[PASS] testPoC_largeAcceptedStakePreventsRiskWindowObservationAndForfeitsBonus() (gas: 663042)
[PASS] testPoC_uint32DeadlineWrapSkipsGoodFaithAttackerClaimWindow() (gas: 3495551)
Suite result: ok. 4 passed; 0 failed; 0 skipped

Log-to-Impact Walkthrough

The passing PoC shows that the large stake is accepted, the pool reaches a SURVIVED outcome with snapshotTotalBonus() == 1, and both Alice's small claim and Bob's large claim revert with Solidity arithmetic overflow. Alice's revert proves the issue is not limited to the large staker's own accounting; the global bonus score can block unrelated honest stakers.

Key Assertions Proven

assertEq(pool.snapshotTotalBonus(), 1) -> proves a dust bonus is enough to enter the overflowing bonus-share path -> PASS
vm.expectRevert(stdError.arithmeticError) before Alice's claimSurvived() -> proves an honest small staker's claim reverts -> PASS
vm.expectRevert(stdError.arithmeticError) before Bob's claimSurvived() -> proves the overflowing snapshot also prevents the large staker from exiting through the same path -> PASS

Recommended Fix

Reject stakes that can make future bonus accounting overflow at any timestamp up to expiry. The check should run before mutating stake accounting.

+ error BonusAccountingOverflow();
+
+ function _assertBonusAccountingFits(uint256 newTotalEligibleStake) internal view {
+ uint256 maxT = uint256(expiry);
+ if (maxT != 0 && newTotalEligibleStake > type(uint256).max / maxT / maxT) {
+ revert BonusAccountingOverflow();
+ }
+ }
+
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
...
uint256 contribTime = received * newEntry;
uint256 contribTimeSq = received * newEntry * newEntry;
+ uint256 newTotalEligibleStake = totalEligibleStake + received;
+ _assertBonusAccountingFits(newTotalEligibleStake);
eligibleStake[msg.sender] += received;
userSumStakeTime[msg.sender] += contribTime;
userSumStakeTimeSq[msg.sender] += contribTimeSq;
- totalEligibleStake += received;
+ totalEligibleStake = newTotalEligibleStake;
sumStakeTime += contribTime;
sumStakeTimeSq += contribTimeSq;
}

Support

FAQs

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

Give us feedback!