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.
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()
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.
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.
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.
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.
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.
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.
Framework used: Foundry / forge
File 1: test/poc/ConfidencePoolOverflowPoC.t.sol
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.
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
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.
The contest is live. Earn rewards by submitting a finding.
This is your time to appeal against judgements on your submissions.
Appeals are being carefully reviewed by our judges.
The contest is complete and the rewards are being distributed.