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

Registry Rewind T-Inversion Steals Bonus Share in ConfidencePool.sol

Author Revealed upon completion

Registry Rewind T-Inversion Steals Bonus Share

Description

The k=2 bonus formula scores each deposit as (T − entryTime)², where T = riskWindowEnd. DESIGN.md §11 confirms the protocol treats benign registry rewinds (e.g. PRODUCTIONUNDER_ATTACK) as a real, anticipated event, and patches withdraw() against them — but the same protection was never applied to stake(). Since riskWindowEnd seals permanently on first terminal observation, a rewind can reopen deposits after T is already fixed, letting a new deposit's entryTime land after T. Because the score is squared, this doesn't fail to penalize the late deposit — it inverts the penalty into a reward larger than genuinely early stakers earn.

function stake(uint256 amount) external nonReentrant {
uint256 newEntry = block.timestamp;
uint256 start = riskWindowStart;
@> if (start != 0 && newEntry < start) newEntry = start;
@> // No ceiling check against riskWindowEnd (T)
userSumStakeTime[msg.sender] += amount * newEntry;
userSumStakeTimeSq[msg.sender] += amount * newEntry * newEntry;
}

Risk

Likelihood:

  • Triggered by any registry rewind from a terminal state back to an active-risk state — DESIGN.md §11 confirms this is already an anticipated, non-adversarial scenario, not a hypothetical.

  • Requires only a normal deposit during the reopened UNDER_ATTACK phase after the rewind.

Impact:

  • A late entrant's score can exceed an early staker's, capturing a disproportionate — potentially majority — share of the bonus pool.

  • Honest early stakers lose bonus they are rightfully owed, with no action of their own causing it.

Proof of Concept

The PoC seals riskWindowEnd at day 15, rewinds the registry back to UNDER_ATTACK, then has Alice deposit at day 25 — 10 days after T was already fixed. The hand-derived scores show Alice's overshoot (15−25)² = 100 beats Bob's legitimate (15−10)² = 25, so Alice collects 4× Bob's bonus despite entering 15 days later.

function test_RegistryRewind_TInversion_StealsBonus() external {
_stake(bob, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
uint256 S = BASE_TIMESTAMP + 10 days;
vm.warp(S);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
uint256 T = BASE_TIMESTAMP + 15 days;
vm.warp(T);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
pool.pokeRiskWindow(); // riskWindowEnd seals at T, permanently
vm.warp(BASE_TIMESTAMP + 16 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK); // rewind
vm.warp(BASE_TIMESTAMP + 25 days);
_stake(alice, 100 * ONE); // entryTime = 25, already past sealed T = 15
vm.warp(BASE_TIMESTAMP + 26 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
vm.prank(bob); pool.claimSurvived();
vm.prank(alice); pool.claimSurvived();
// Bob: (15-10)^2=25 share of 125 total -> 10e18 bonus -> 110e18 payout
// Alice: (15-25)^2=100 share of 125 total -> 40e18 bonus -> 140e18 payout
assertEq(token.balanceOf(bob), 110 * ONE, "Bob payout mismatch");
assertEq(token.balanceOf(alice), 140 * ONE, "Alice payout mismatch");
}

Recommended Mitigation

function stake(uint256 amount) external nonReentrant {
uint256 newEntry = block.timestamp;
uint256 start = riskWindowStart;
if (start != 0 && newEntry < start) newEntry = start;
+ uint256 end = riskWindowEnd;
+ if (end != 0 && newEntry > end) newEntry = end;
userSumStakeTime[msg.sender] += amount * newEntry;
userSumStakeTimeSq[msg.sender] += amount * newEntry * newEntry;
}

Capping newEntry at riskWindowEnd prevents any deposit from ever scoring an entryTime > T, so a post-rewind deposit earns a score of zero for that portion instead of an inverted reward — restoring the intended [riskWindowStart, riskWindowEnd] bound the formula assumes. An equally valid alternative is blocking deposits outright once riskWindowEnd is sealed, matching how the pool already blocks deposits during PROMOTION_REQUESTED/PRODUCTION/CORRUPTED.

Support

FAQs

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

Give us feedback!