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

`StakingClosed` Error Used for Bonus Contribution Rejection

Author Revealed upon completion

Root + Impact

Description

When contributeBonus() is rejected because the registry is in a blocked state (PROMOTION_REQUESTED, PRODUCTION, or CORRUPTED) or because block.timestamp >= expiry, it reverts with StakingClosed. Contributing a bonus is not "staking" — it is a donation that carries no claim rights. The StakingClosed error name implies the caller was trying to stake, which is misleading for integrators and off-chain error handling.

// ConfidencePool.sol — contributeBonus() line 269
function contributeBonus(uint256 amount) external nonReentrant whenPoolNotPaused {
// ...
@> if (block.timestamp >= expiry) revert StakingClosed(); // <-- bonus, not stake
@> _assertDepositsAllowed(_observePoolState()); // <-- reverts StakingClosed
// ...
}

Risk

Likelihood: High — any bonus contribution attempt during PROMOTION_REQUESTED/PRODUCTION/CORRUPTED or after expiry triggers this path.

Impact: Low — no funds lost; off-chain error handling may incorrectly categorize bonus rejections as staking rejections.

Proof of Concept

File: L4-StakingClosed-Bonus.poc.t.sol

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
/// @notice PoC for L-4: `StakingClosed` error used for bonus contribution rejection
contract L4_StakingClosed_Bonus_POC is BaseConfidencePoolTest {
function testPOC_L4_stakingClosedForBonus_promotionRequested() external {
token.mint(alice, ONE);
vm.startPrank(alice);
token.approve(address(pool), ONE);
// Block bonus in PROMOTION_REQUESTED
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PROMOTION_REQUESTED);
vm.expectRevert(IConfidencePool.StakingClosed.selector);
pool.contributeBonus(ONE);
vm.stopPrank();
}
function testPOC_L4_stakingClosedForBonus_expired() external {
token.mint(alice, ONE);
vm.startPrank(alice);
token.approve(address(pool), ONE);
// Block bonus after expiry — also uses StakingClosed
vm.warp(pool.expiry());
vm.expectRevert(IConfidencePool.StakingClosed.selector);
pool.contributeBonus(ONE);
vm.stopPrank();
}
function testPOC_L4_bonusUsesStakingClosed_notDepositsClosed() external {
// Both stake and contributeBonus route through _assertDepositsAllowed
// which reverts StakingClosed for PROMOTION_REQUESTED/PRODUCTION/CORRUPTED
// and through the expiry check which also reverts StakingClosed
// "StakingClosed" is misleading for "bonus contribution blocked"
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
token.mint(alice, ONE);
vm.startPrank(alice);
token.approve(address(pool), ONE);
vm.expectRevert(IConfidencePool.StakingClosed.selector);
pool.stake(ONE); // StakingClosed for stake — correct
vm.expectRevert(IConfidencePool.StakingClosed.selector);
pool.contributeBonus(ONE); // StakingClosed for bonus — misleading
vm.stopPrank();
}
}

Run: forge test --match-path 'L4-StakingClosed-Bonus.poc.t.sol' -vv

Recommended Mitigation

Rename StakingClosed to DepositsClosed in _assertDepositsAllowed:

- revert StakingClosed();
+ revert DepositsClosed();

Support

FAQs

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

Give us feedback!