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

Missing registry-state commitment lets a pending pre-risk stake become immediately non-withdrawable

Author Revealed upon completion

Missing registry-state commitment lets a pending pre-risk stake become immediately non-withdrawable

Description

A user staking during a pre-risk registry state expects the withdrawal escape hatch to remain available until risk actually begins. A user deliberately entering during UNDER_ATTACK instead accepts immediate lock-up and negligible late-entry bonus weight.

stake() does not let the caller distinguish those intents. It reads the registry only at execution and deliberately accepts UNDER_ATTACK. A transaction submitted while the registry is ATTACK_REQUESTED can therefore execute after approval moves the agreement to UNDER_ATTACK; the same call opens riskWindowStart, transfers principal, and permanently disables withdrawal.

function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
// @> No expected registry state or active-risk consent is supplied.
_assertDepositsAllowed(_observePoolState());
// ...
stakeToken.safeTransferFrom(msg.sender, address(this), amount);
}
function _assertDepositsAllowed(IAttackRegistry.ContractState state) private pure {
if (
state == IAttackRegistry.ContractState.PROMOTION_REQUESTED
|| state == IAttackRegistry.ContractState.PRODUCTION
|| state == IAttackRegistry.ContractState.CORRUPTED
) {
revert StakingClosed();
}
// @> UNDER_ATTACK intentionally passes.
}
// withdraw()
if (riskWindowStart != 0 || /* live state is not pre-risk */) {
revert WithdrawsDisabled();
}

Risk

Likelihood:

  • Occurs when a stake remains pending while the registry moderator approves an ATTACK_REQUESTED agreement into UNDER_ATTACK.

  • Requires transaction ordering that places the normal registry transition before the pending stake.

Impact:

  • Principal intended to remain withdrawable is converted into locked risk capital without execution-time consent.

  • The staker must wait for moderator resolution or pool expiry while receiving negligible bonus weight as a late entrant.

Proof of Concept

Setup: append the function below to test/unit/ConfidencePool.t.sol (same ConfidencePoolTest harness / BaseConfidencePoolTest helpers already used by the suite), then run:

forge test --match-test testPendingStakeCrossingUnderAttackDisablesWithdraw -vv
  1. The registry is set to ATTACK_REQUESTED, where deposits and withdrawals are both allowed.

  2. Alice mints and approves 100 * ONE intending a pre-risk stake that remains withdrawable.

  3. Before her pending stake executes, the registry moves to UNDER_ATTACK.

  4. Alice's stake() still succeeds and seals riskWindowStart in the same call.

  5. Alice's immediate withdraw() reverts with WithdrawsDisabled, locking principal she never consented to lock at execution time.

// PoC: stake() has no expected-state commitment. A deposit submitted while the registry is
// ATTACK_REQUESTED can execute after UNDER_ATTACK, open the risk window, and immediately
// disable withdrawal without the caller's consent.
function testPendingStakeCrossingUnderAttackDisablesWithdraw() external {
attackRegistry.setAgreementState(IAttackRegistry.ContractState.ATTACK_REQUESTED);
uint256 stakeAmount = 100 * ONE;
token.mint(alice, stakeAmount);
vm.prank(alice);
token.approve(address(pool), stakeAmount);
// Registry moves to UNDER_ATTACK before the pending stake lands.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.prank(alice);
pool.stake(stakeAmount);
assertEq(pool.eligibleStake(alice), stakeAmount);
assertGt(pool.riskWindowStart(), 0, "stake itself opened the risk window");
vm.prank(alice);
vm.expectRevert(IConfidencePool.WithdrawsDisabled.selector);
pool.withdraw();
}

Recommended Mitigation

Require explicit consent for an active-risk deposit and support a user deadline.

- function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
+ function stake(uint256 amount, bool allowUnderAttack)
+ external
+ nonReentrant
+ whenPoolNotPaused
+ {
+ IAttackRegistry.ContractState state = _observePoolState();
+ if (
+ state == IAttackRegistry.ContractState.UNDER_ATTACK
+ && !allowUnderAttack
+ ) revert UnexpectedPoolState();
- _assertDepositsAllowed(_observePoolState());
+ _assertDepositsAllowed(state);
// existing stake logic
}

Support

FAQs

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

Give us feedback!