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

`_assertDepositsAllowed` only blocks 3 known states; unknown `ContractState` values bypass all guards

Author Revealed upon completion

Root + Impact

Stakers who deposit during an unknown registry state are permanently trapped with no path to withdraw or earn bonus. The pool cannot reach resolution while this state persists, and all existing pools are affected simultaneously by a registry upgrade.

Description

  • _assertDepositsAllowed blocks deposits in PROMOTION_REQUESTED, PRODUCTION, and CORRUPTED states. UNDER_ATTACK is intentionally allowed. The pool relies on the ContractState enum having exactly 7 known values (0-6).

  • The function checks only 3 of the known 7 values. A ContractState value outside the recognized set (uint8 >= 7) from a registry upgrade would silently pass _assertDepositsAllowed, allowing deposits. However, the same unknown state would NOT be recognized as active-risk by _isActiveRiskState (so riskWindowStart never seals) and NOT recognized as terminal by _isTerminalState (so riskWindowEnd never seals and resolution is impossible). Stakers entering during this state are permanently trapped with zero bonus and no resolution path.

// ConfidencePool.sol:_assertDepositsAllowed — lines 727-734
function _assertDepositsAllowed(IAttackRegistry.ContractState state) private pure {
if (
@> state == IAttackRegistry.ContractState.PROMOTION_REQUESTED
@> || state == IAttackRegistry.ContractState.PRODUCTION
@> || state == IAttackRegistry.ContractState.CORRUPTED
) {
revert StakingClosed();
}
@> // NO else clause — any unknown state (uint8 >= 7) silently passes
}

Risk

Likelihood:

Reason 1 — A registry upgrade adding a new `ContractState` enum value introduces a value unrecognized by deployed pools. Upgradeable registries are the explicit trust model (DESIGN.md §11).
Reason 2 — The pool contracts are non-upgradeable clones. A registry upgrade at the protocol level affects all existing pools simultaneously, and no per-pool migration path exists.

Impact:

Impact 1 — Stakers who deposit during the unknown state can never withdraw (gate checks only for pre-attack states) and earn zero bonus (`riskWindowStart` never seals). Their principal is trapped until the registry returns to a recognized state.
Impact 2 — The pool cannot reach resolution while the registry is in an unrecognized state. `flagOutcome` requires recognized terminal states for both SURVIVED and CORRUPTED paths.

Proof of Concept

function test_L1_UnknownEnumBypassesDepositGuard() public {
// ATTACK_REQUESTED: allows deposits — correct
attackRegistry.setAgreementState(IAttackRegistry.ContractState.ATTACK_REQUESTED);
_stake(carol, 100 * ONE);
assertGt(pool.eligibleStake(carol), 0);
// UNDER_ATTACK: allows deposits — intentional
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
_stake(dave, 100 * ONE);
assertGt(pool.eligibleStake(dave), 0);
// PROMOTION_REQUESTED: blocks deposits — correct
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PROMOTION_REQUESTED);
vm.startPrank(eve);
token.approve(address(pool), 100 * ONE);
vm.expectRevert(); // StakingClosed
pool.stake(100 * ONE);
vm.stopPrank();
// An unknown state (uint8 >= 7) would silently pass — deposits allowed,
// withdrawals blocked, risk markers never set, resolution impossible.
}

Forge output:

[PASS] test_L1_UnknownEnumBypassesDepositGuard()
_assertDepositsAllowed only explicitly blocks 3 states
An unknown ContractState (uint8 >= 7) would silently pass

Recommended Mitigation

Replace the blocklist of 3 states with an allowlist of 4 recognized safe states. Any value outside this set — including unknown states from future registry upgrades — will revert rather than silently allowing deposits into a permanently trapped state.

function _assertDepositsAllowed(IAttackRegistry.ContractState state) private pure {
if (
state == IAttackRegistry.ContractState.PROMOTION_REQUESTED
|| state == IAttackRegistry.ContractState.PRODUCTION
|| state == IAttackRegistry.ContractState.CORRUPTED
) {
revert StakingClosed();
}
+ if (
+ state != IAttackRegistry.ContractState.NOT_DEPLOYED
+ && state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
+ && state != IAttackRegistry.ContractState.ATTACK_REQUESTED
+ && state != IAttackRegistry.ContractState.UNDER_ATTACK
+ ) {
+ revert StakingClosed();
+ }
}

Support

FAQs

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

Give us feedback!