FoundrySolidityLayer 2
7.25 ETH
Submission Details
Severity: low
Valid

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

Lead Judging Commences

inallhonesty Lead Judge 2 days ago
Submission Judgement Published
Validated
Assigned finding tags:

Missing riskWindowEnd rewind-latch in _assertDepositsAllowed lets late stakes/bonus theft via k=2 quadratic scoring

Impact - Medium Bonus pool only, principal always returns under SURVIVED/EXPIRED, but the quadratic advantage is unbounded and a tiny late stake can take nearly all of it. Likelihood - Low It needs a DAO registry migration to land inside the window where a pool is unresolved with riskWindowEnd already sealed, and the attacker still has to be watching. No attacker action can create the precondition.

Support

FAQs

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

Give us feedback!