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

`stake()` has no persisted-latch guard against a registry rewind after `riskWindowEnd` is sealed, letting a late minimum staker capture most of the bonus pool

Author Revealed upon completion

Root + Impact

Description

  • Normally, once the pool observes the registry in a terminal state (PRODUCTION/CORRUPTED),
    riskWindowEnd is sealed one-way (_observePoolState / _markRiskWindowEnd,
    src/ConfidencePool.sol:796-798, 819-829) and becomes the fixed upper bound T the k=2 bonus
    formula measures every staker's (T − entryTime)² against. Deposit eligibility is supposed to be
    closed by that same terminal moment the intent is that nobody enters the bonus pool after the
    window everyone else's bonus is being measured against has already ended.

  • stake() never checks the pool's own persisted riskWindowEnd latch it only gates on the
    registry's live state via _assertDepositsAllowed, which blocks PROMOTION_REQUESTED,
    PRODUCTION, and CORRUPTED but has no idea whether a terminal state was already observed and
    sealed in the past. Whenever the live state reports anything else again a rewind staking
    silently reopens while T stays frozen at its old value. Because the k=2 score is
    amount·(T − entryTime)², a deposit entered after T still scores positive, and the score
    grows with the square of t − T: the later the deposit lands relative to the frozen T, the
    more of the bonus pool it dominates.

// 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()); // only ever looks at LIVE state
...
}
// _assertDepositsAllowed itself never consults riskWindowEnd:
function _assertDepositsAllowed(IAttackRegistry.ContractState state) private pure {
if (
state == IAttackRegistry.ContractState.PROMOTION_REQUESTED
|| state == IAttackRegistry.ContractState.PRODUCTION
|| state == IAttackRegistry.ContractState.CORRUPTED
) {
revert StakingClosed();
}
@> // no `if (riskWindowEnd != 0) revert` a rewound NEW_DEPLOYMENT/ATTACK_REQUESTED/
@> // UNDER_ATTACK live read falls straight through and deposits succeed.
}

Contrast with withdraw(), which the team did harden against this exact threat class:

// src/ConfidencePool.sol withdraw()
IAttackRegistry.ContractState state = _observePoolState();
@> // riskWindowStart is the pool's own one-way record that risk has materialised; gate on it
@> // so an upstream registry rewind cannot re-open withdrawals.
if (
riskWindowStart != 0
|| (state != NOT_DEPLOYED && state != NEW_DEPLOYMENT && state != ATTACK_REQUESTED)
) {
revert WithdrawsDisabled();
}

test/unit/ConfidencePool.riskWindow.t.sol:215-234,
testWithdrawRemainsDisabledAfterRegistryRewind, exists specifically to prove the withdraw() side
is safe it deploys a second MockAttackRegistry, repoints SafeHarborRegistry at it to simulate
"registry replacement, mis-migration" (its own words), and asserts withdraw() still reverts. There
is no equivalent test, or equivalent guard, for stake().

Risk

Likelihood:

  • Requires an upstream registry rewind after the pool has already sealed riskWindowEnd
    docs/DESIGN.md §11 treats this as a real, anticipated event (a "legitimate DAO registry
    migration"), not a contrived edge case: it's the specific reason the team rejected per-clone
    registry pinning ("would brick every existing pool on a legitimate DAO registry migration more
    likely and more severe than the risk it prevents").

  • Once a rewind is underway, exploiting it requires nothing beyond an ordinary, permissionless
    stake() call at or near the minimum stake no privileged role, no timing race against anyone
    else, and the later the staker enters relative to the frozen T, the larger their share, so the
    incentive is to simply wait as long as the rewind persists.

Impact:

  • The bonus pool real, staker/sponsor-contributed value is redistributed away from the stakers
    who actually bore the measured at-risk window to a participant who bore none of it. In the PoC
    below, a 1-token deposit entered 29 days after the sealed terminal time captures 80.8% of a
    100-token bonus pool
    , out-earning two 100-token stakers who staked for the entire risk window
    combined.

  • This directly contradicts the stated guarantee in docs/DESIGN.md:281 "No funds can be
    stolen" for exactly the rewind scenario that line is talking about. The document correctly
    proves that guarantee for withdraw() (§11, gated on riskWindowStart) but the same guarantee
    does not hold for the bonus pool via stake(), because no equivalent gate exists there.

  • Principal accounting stays solvent (no insolvency, no reentrancy, no double-spend) this is a
    fund-misallocation bug, not a fund-destruction bug, but the misallocation is severe, easy to
    execute once the precondition holds, and requires no cooperation from the moderator or sponsor.

Proof of Concept

Verified directly against this repo (not copied from an unverified source the file path a prior
writeup cited, test/unit/ConfidencePool.poc.t.sol, does not exist in this repo; this test was
written and run fresh using the existing BaseConfidencePoolTest harness). It's also checked into
the repo as a real, standalone runnable file at
test/unit/ConfidencePool.registryRewindStakeReopen.t.sol
(forge test --match-contract ConfidencePoolRegistryRewindStakeReopenTest -vv) reproduced inline
below for a self-contained record:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract RegistryRewindVerifyTest is BaseConfidencePoolTest {
function testPoC_StakingReopensAfterTerminalObservationAndStealsBonus() external {
_stake(alice, 100 * ONE);
_stake(bob, 100 * ONE);
_contributeBonus(carol, 100 * ONE);
_passThroughUnderAttack();
vm.warp(block.timestamp + 1 days);
// Registry reaches PRODUCTION; the pool observes and permanently seals riskWindowEnd.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
pool.pokeRiskWindow();
uint256 frozenTerminalTime = pool.riskWindowEnd();
// 29 days later, the registry rewinds (repointed instance / mis-migration) to a
// deposit-permitted state. riskWindowEnd does NOT move it's one-way.
vm.warp(block.timestamp + 29 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
_stake(dave, ONE); // succeeds stake() has no persisted-latch guard
assertEq(pool.riskWindowEnd(), frozenTerminalTime, "riskWindowEnd did not move");
// Registry reports PRODUCTION again; moderator flags SURVIVED as normal.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
uint256 before = token.balanceOf(dave);
vm.prank(dave);
pool.claimSurvived();
// Dave's 1-token, 29-days-late deposit captures 80.8% of the 100-token bonus pool.
assertGt(token.balanceOf(dave) - before, 80 * ONE, "dave captured >80% of the bonus");
}
}
[PASS] testPoC_StakingReopensAfterTerminalObservationAndStealsBonus()
dave's payout: 81.787704130643611911 tokens (1 principal + 80.787... bonus, out of a 100-token pool)

Recommended Mitigation

Give stake() (and contributeBonus(), for the same reason bonus contributed after the window
closes shouldn't be treated as still-open) a persisted-latch guard mirroring the one withdraw()
already has, instead of trusting live registry state alone:

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();
+ // Mirror the riskWindowStart gate on withdraw(): once the pool has sealed a terminal
+ // observation, deposits must stay closed even if the live registry later rewinds
+ // T is frozen, so new entries after it can only distort the bonus split.
+ if (riskWindowEnd != 0) revert StakingClosed();
+ _assertDepositsAllowed(state);
if (!expiryLocked) {
expiryLocked = true;
}
...

Apply the same riskWindowEnd != 0 guard to contributeBonus(). Add a regression test analogous to
testWithdrawRemainsDisabledAfterRegistryRewind observe a terminal state, rewind the registry to
every deposit-permitted state, and assert stake()/contributeBonus() still revert StakingClosed.

Support

FAQs

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

Give us feedback!