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

Large stakes can overflow the expanded bonus score and brick survivor claims

Author Revealed upon completion

Summary

The k=2 bonus formula expands scores with absolute Unix timestamps, so a sufficiently large but valid ERC20 stake can overflow T * T * stake during _bonusShare. Once a SURVIVED or EXPIRED pool with a bonus is resolved in this state, staker claims revert and principal plus bonus remain stuck.

Description

stake accepts any received ERC20 amount above minStake and adds it to totalEligibleStake without an upper bound. When the risk window opens, the pool first rewrites the global weighted sums by multiplying the total stake by the absolute timestamp at src/ConfidencePool.sol:814. At resolution, it snapshots those values and later computes the global bonus denominator by multiplying the absolute timestamp T twice by snapshotTotalStaked. These operations can overflow even when the actual elapsed-time score (T - entryTime)^2 * stake would fit, because the implementation expands the quadratic around Unix timestamps instead of first working in bounded deltas.

uint256 plus = T * T * snapshotTotalStaked + snapshotSumStakeTimeSq;
uint256 minus = 2 * T * snapshotSumStakeTime;
uint256 globalScore = plus > minus ? plus - minus : 0;

The same pattern is used for each claimant's numerator at src/ConfidencePool.sol:704, while claimSurvived and claimExpired both call _bonusShare before transferring principal. With current Unix timestamps around 1.75e9, any snapshot stake above roughly type(uint256).max / T^2 can trigger the claim-time overflow. A still larger stake can also make _markRiskWindowStart revert, preventing the pool from recording an observed risk window and causing the bonus to be treated as unearned. These thresholds are enormous, but they are valid ERC20 base-unit amounts and are not rejected by the pool.

Severity: Low

This is an edge-case liveness and accounting bug requiring an unusually large standard ERC20 balance and a nonzero bonus pool. The impact is concrete, however: a malicious or accidental oversized stake can permanently prevent SURVIVED/EXPIRED stakers from claiming both principal and bonus.

Impact

If a large staker enters before the risk window and the pool later resolves SURVIVED or EXPIRED with any bonus, every claim that reaches _bonusShare can revert on arithmetic overflow. Honest stakers cannot withdraw after resolution, cannot claim principal through claimSurvived or claimExpired, and sweepUnclaimedBonus does not repair the broken claim path.

Proof of Concept

The following Foundry test was run against the repository using the Safe Harbor interfaces exported to /tmp/bcsh-audit:

forge test \
-R forge-std/=lib/forge-std/src/ \
-R @openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/ \
-R @openzeppelin/contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/contracts/ \
-R @battlechain/=/tmp/bcsh-audit/src/ \
-R src/types/=/tmp/bcsh-audit/src/types/ \
--match-path test/unit/ConfidencePool.bonusOverflow.poc.t.sol -vvv

PoC test:

function testLargeStakeOverflowsExpandedBonusFormulaAndBlocksClaims() external {
uint256 startTs = vm.getBlockTimestamp() + 1 days;
uint256 endTs = startTs + 1 days;
uint256 whaleAmount = type(uint256).max / (endTs * endTs) + 1;
assertLe(whaleAmount + ONE, type(uint256).max / (startTs * startTs));
_stake(alice, ONE);
_stake(bob, whaleAmount);
vm.warp(startTs);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
vm.warp(endTs);
_contributeBonus(carol, ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
vm.prank(alice);
vm.expectRevert();
pool.claimSurvived();
}

The test passed: staking and resolution succeeded, but Alice's claim reverted when _bonusShare evaluated the overflow-prone expanded denominator.

Mitigation

Avoid expanding the score around absolute timestamps. Store or snapshot stake-weighted deltas from riskWindowStart, or compute scores from bounded differences such as (T - effectiveEntry) so the multiplication uses elapsed risk-window duration rather than Unix time. Also consider enforcing a maximum stake/snapshot bound that guarantees all score calculations fit for the configured expiry horizon.

Support

FAQs

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

Give us feedback!