Description
-
The pool should seal riskWindowStart when the BattleChain registry is first observed in an active-risk state. Once the risk window is sealed, stakers are eligible for the k=2 time-weighted bonus distribution when the pool later resolves SURVIVED or EXPIRED.
-
The implementation stores k=2 bonus weights as amount * timestamp and amount * timestamp^2, but it does not cap stake-token decimals, total supply, or per-pool totalEligibleStake. A high-decimal standard ERC20 can therefore produce normal-looking whole-token balances whose base-unit amount overflows _markRiskWindowStart() when the registry enters UNDER_ATTACK or PROMOTION_REQUESTED.
This is not an accepted design choice. docs/DESIGN.md accepts the "no observable risk" zero-bonus rule only when no pool interaction observed the active-risk interval, and separately states that anyone can seal the window through pokeRiskWindow() or an ordinary pool interaction. Here, an observation attempt exists but reverts due to overflow, preventing the intended seal.
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;
...
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 _bonusShare(address u, uint256 userEligible) internal view returns (uint256) {
if (snapshotTotalBonus == 0) return 0;
if (riskWindowStart == 0) return 0;
...
}
Risk
Likelihood:
-
Low. The factory owner must allowlist a standard ERC20 whose decimals or total supply make token balances extremely large in base units. The README excludes fee-on-transfer and rebasing tokens, but it does not cap ERC20 decimals, total supply, or per-pool stake size.
-
The staker must deposit before active risk, with an amount that fits the initial stake() timestamp multiplication but makes totalEligibleStake * riskStartTimestamp^2 overflow when the registry later enters UNDER_ATTACK or PROMOTION_REQUESTED.
-
At the test suite's realistic timestamp (1,750,000,000), the overflow threshold is about 3.78e58 base units. For a 48-decimal token, that is about 37.8 billion whole tokens. The PoC uses a finite-supply, standard ERC20 with 48 decimals to avoid relying on an unbounded-mint 18-decimal mock token.
Impact:
-
Active-risk observation reverts and riskWindowStart remains zero, even though a user attempted to seal the risk window.
-
On SURVIVED or EXPIRED resolution, _bonusShare() treats the pool as having no observed risk and pays zero bonus to stakers.
-
The bonus pool is then sweepable to recoveryAddress, so stakers receive principal only and lose the risk premium they should have earned.
Under the CodeHawks matrix, Low likelihood and Medium impact classify this issue as Low severity.
Proof of Concept
Create test/audit/HighDecimalsOverflowPoC.t.sol with the following self-contained test. The token is a standard ERC20 with 48 decimals and finite constructor-minted supply. It has no transfer fee, no rebasing, no callbacks, and no external mint function.
pragma solidity 0.8.26;
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract HighDecimalsERC20 is ERC20 {
uint8 internal immutable customDecimals;
constructor(
uint8 decimals_,
address staker,
uint256 stakeAmount,
address bonusContributor,
uint256 bonusAmount
) ERC20("High Decimals Token", "HDT") {
customDecimals = decimals_;
_mint(staker, stakeAmount);
_mint(bonusContributor, bonusAmount);
}
function decimals() public view override returns (uint8) {
return customDecimals;
}
}
contract HighDecimalsOverflowPoC is BaseConfidencePoolTest {
function testHighDecimalsFiniteSupplyTokenCanDoSRiskWindowAndForfeitBonus() external {
uint256 t0 = vm.getBlockTimestamp();
uint256 t1 = t0 + 10 days;
uint256 stakeAmount = type(uint256).max / (t1 * t1) + 1;
uint8 tokenDecimals = 48;
uint256 oneHighDecimalToken = 10 ** uint256(tokenDecimals);
uint256 bonusAmount = 100 * oneHighDecimalToken;
HighDecimalsERC20 highToken =
new HighDecimalsERC20(tokenDecimals, alice, stakeAmount, carol, bonusAmount);
assertEq(highToken.decimals(), tokenDecimals);
assertLt(stakeAmount / oneHighDecimalToken, 38_000_000_000);
ConfidencePool implementation = new ConfidencePool();
ConfidencePool highPool = ConfidencePool(Clones.clone(address(implementation)));
highPool.initialize(
agreement,
address(highToken),
address(safeHarborRegistry),
moderator,
t0 + 31 days,
1,
recovery,
address(this),
_defaultScope()
);
vm.startPrank(alice);
highToken.approve(address(highPool), stakeAmount);
highPool.stake(stakeAmount);
vm.stopPrank();
vm.startPrank(carol);
highToken.approve(address(highPool), bonusAmount);
highPool.contributeBonus(bonusAmount);
vm.stopPrank();
vm.warp(t1);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.expectRevert();
highPool.pokeRiskWindow();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
highPool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(highPool.riskWindowStart(), 0, "risk window was never sealed");
uint256 aliceBefore = highToken.balanceOf(alice);
vm.prank(alice);
highPool.claimSurvived();
assertEq(highToken.balanceOf(alice) - aliceBefore, stakeAmount, "alice receives principal only");
uint256 recoveryBefore = highToken.balanceOf(recovery);
highPool.sweepUnclaimedBonus();
assertEq(highToken.balanceOf(recovery) - recoveryBefore, bonusAmount, "bonus is swept to recovery");
}
}
Run:
forge test --match-path test/audit/HighDecimalsOverflowPoC.t.sol -vv
Result:
Ran 1 test for test/audit/HighDecimalsOverflowPoC.t.sol:HighDecimalsOverflowPoC
[PASS] testHighDecimalsFiniteSupplyTokenCanDoSRiskWindowAndForfeitBonus()
Suite result: ok. 1 passed; 0 failed; 0 skipped
The concrete parameters used by the PoC are:
t0 = 1,750,000,000
t1 = 1,750,864,000
t1^2 = 3,065,524,746,496,000,000
stakeAmount = floor(type(uint256).max / t1^2) + 1
= 37,772,355,082,003,669,235,067,125,786,005,517,198,134,941,473,855,462,743,809 base units
48-decimal whole-token amount ≈ 37,772,355,082.003669 tokens
Recommended Mitigation
Reject stake tokens whose unit scale or supply can exceed the pool's k=2 accumulator headroom, and enforce the same bound when accepting stake. Since both riskWindowStart and riskWindowEnd are capped at expiry, expiry can be used as the maximum timestamp for the per-pool stake cap.
+ error AccumulatorOverflow();
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 maxEligible = type(uint256).max / uint256(expiry) / uint256(expiry);
+ if (totalEligibleStake > maxEligible || received > maxEligible - totalEligibleStake) {
+ revert AccumulatorOverflow();
+ }
...
}
function _markRiskWindowStart() internal {
uint256 t = block.timestamp;
if (t > expiry) t = expiry;
+ if (totalEligibleStake > type(uint256).max / t / t) revert AccumulatorOverflow();
riskWindowStart = uint32(t);
sumStakeTime = totalEligibleStake * t;
sumStakeTimeSq = totalEligibleStake * t * t;
emit RiskWindowStarted(t);
}
The factory allowlist should also reject tokens whose decimals() / total supply policy makes the required per-pool cap unusable for normal operation.