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

Unbounded stake size lets a late depositor capture a disproportionate share of the bonus pool, defeating the `k=2` time decay mechanism

Author Revealed upon completion

Description

Normal behavior: _bonusShare is intended to distribute totalBonus among stakers using a k=2 time weighted formula, so that a deposit's reward scales with amount × (T − entry)² , rewarding stakers in proportion to both how much capital they committed and how long they held it at risk. DESIGN.md and the function's own natspec state this is specifically designed so that a large, late entering depositor is "crushed to ~zero" relative to smaller, long duration stakers, since (T − entry)² shrinks sharply as entry time approaches the resolution timestamp T.

The issue: the crushing term (T − entry)² is bounded, its maximum value is fixed by the pool's total risk-window duration D² = (riskWindowEnd − riskWindowStart)², and it shrinks toward 0 as entry → T. But it is multiplied by eligibleStake[u], a value with no upper bound anywhere in the contract. Because one factor in the product is bounded and the other is unbounded, any fixed exponent on the time term can always be outweighed by scaling the stake term. A depositor who stakes late can deterministically compute, from fully public onchain state (riskWindowStart, sumStakeTime, sumStakeTimeSq, totalEligibleStake are all public), the exact stake size required to capture any target percentage of the bonus pool, and deposit that amount before resolution.

function _bonusShare(address u, uint256 userEligible) internal view returns (uint256) {
if (snapshotTotalBonus == 0) return 0;
// No observable risk → no bonus (see contract natspec).
if (riskWindowStart == 0) return 0;
uint256 T = outcomeFlaggedAt;
@> // userScore factors as: eligibleStake[u] × (T − entry_u)²
@> // (T − entry_u)² is BOUNDED — its ceiling is fixed by the pool's risk-window duration.
@> // eligibleStake[u] is UNBOUNDED — no cap exists anywhere on individual or total stake.
@> // A sufficiently large `userEligible` therefore always wins against a small `(T − entry)`,
@> // regardless of how small the exponent term is driven toward zero.
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) {
// No time elapsed in the risk window for anyone → fallback to amount-weighted.
if (snapshotTotalStaked == 0) return 0;
return Math.mulDiv(userEligible, snapshotTotalBonus, snapshotTotalStaked);
}
// mulDiv handles the final multiply-then-divide via 512-bit intermediates, so a very
// large `snapshotTotalBonus` cannot push the numerator over uint256 before division.
return Math.mulDiv(userScore, snapshotTotalBonus, globalScore);
}

Because _assertDepositsAllowed explicitly permits stake() throughout the UNDER_ATTACK state, a deliberate design choice defended in docs/DESIGN.md on the assumption that the k=2 term alone makes such deposits "near-zero-reward", this formula gap directly falsifies the safety property that deposit window is relying on to remain open.

Risk

Likelihood:

  • riskWindowStart, sumStakeTime, sumStakeTimeSq, and totalEligibleStake are all public state, so the exact stake size required to hit any target bonus share is computable off-chain with no privileged information, at any point before resolution.


The attack requires only a single ordinary stake() call with sufficient capital.

Impact:

  • Legitimate long duration stakers, the exact cohort the bonus mechanism is designed to reward, have their bonus share diluted in direct proportion to the attacker's capture.

  • The contract's own stated threat model guarantee, a late insider "should get vanishingly little bonus vs. a small but long-bearing staker") is falsified.

Proof of Concept

  • Add this test to test/unit/ConfidencePool.k2Bonus.t.sol

  • Run forge test --mt testLargeLateStakeCapturesDisproportionateBonusShare -vvvv

function testLargeLateStakeCapturesDisproportionateBonusShare() external {
// Small staker bears risk for the entire window.
_stake(alice, 100 * ONE);
_enterRisk();
vm.warp(vm.getBlockTimestamp() + 21 days);
// Attacker deposits a large, ordinary stake late in the SAME still-open UNDER_ATTACK
// window, 10 minutes before resolution. No special permission is required — this call
// path is explicitly allowed by _assertDepositsAllowed.
uint256 _bobStake = 100_000_000;
_stake(bob, _bobStake * ONE);
vm.warp(vm.getBlockTimestamp() + 10 minutes);
_contributeBonus(carol, 100 * ONE);
_flagSurvived();
uint256 aliceTotal = _claim(alice);
uint256 bobTotal = _claim(bob);
uint256 aliceBonus = aliceTotal - 100 * ONE;
uint256 bobBonus = bobTotal - _bobStake * ONE;
// Alice: 21 days of genuine risk exposure.
// Bob: 10 minutes of exposure, entered while the registry already showed UNDER_ATTACK.
//
// Result: Bob captures ~9% of the entire bonus pool (9.85e18 of 100e18 total) despite
// bearing risk for roughly 1/3,000th the duration Alice did — directly contradicting the
// "crushed to ~zero" guarantee the deposit window's safety relies on.
assertGt(bobBonus, (aliceBonus * 9) / 100); // Bob's capture exceeds 9% despite ~3,025x shorter exposure
}

Recommended Mitigation

  • Mitigation should cap the maximum stake amount

+ uint256 public maxStakePerDeposit;
+ uint256 public maxTotalEligibleStake;
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
if (amount == 0) revert InvalidAmount();
if (amount < minStake) revert BelowMinStake();
+ if (amount > maxStakePerDeposit) revert AboveMaxStake();
+ if (totalEligibleStake + amount > maxTotalEligibleStake) revert PoolCapExceeded();
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
...
}

Support

FAQs

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

Give us feedback!