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

Permissionless activity determines whether a rejected attack request permanently finalizes pool scope

Author Revealed upon completion

Root + Impact

Description

ATTACK_REQUESTED is reversible: rejection returns the agreement to NOT_DEPLOYED. Nevertheless, whether this temporary request permanently finalizes the pool’s scope depends solely on incidental permissionless activity:

  • With no pool interaction, rejection leaves scopeLocked == false, allowing the owner to change scope.

  • A cheap one unit bonus contribution, possible due to lack of minimum bonus contribution amount, during the same request sets scopeLocked == true forever.

Thus, two pools undergoing the same registry lifecycle reach different configuration states because an arbitrary user chose whether to interact. If ATTACK_REQUESTED should finalize scope, the no interaction path incorrectly preserves owner control. If rejection should restore pre request mutability, the contribution path incorrectly freezes it. In either interpretation, an untrusted user should not decide the finality policy.

function contributeBonus(uint256 amount) external nonReentrant whenPoolNotPaused {
if (amount == 0) revert InvalidAmount();
// No minimum bonus contribution is enforced.
// @> A one unit permissionless contribution observes ATTACK_REQUESTED.
_assertDepositsAllowed(_observePoolState());
// ...
}
function _observePoolState()
internal
returns (IAttackRegistry.ContractState state)
{
state = _getAgreementState();
if (
!scopeLocked
&& state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
) {
// @> ATTACK_REQUESTED sets the irreversible lock even though
// the request may subsequently be rejected.
scopeLocked = true;
emit ScopeLocked(block.timestamp);
}
}
function setPoolScope(address[] calldata accounts) external onlyOwner {
_observePoolState();
// @> Remains true after rejection returns the agreement to NOT_DEPLOYED.
if (scopeLocked) revert ScopePostLockImmutable();
_replaceScope(accounts);
}

Risk

Likelihood: Medium

  • This occurs when an unlocked pool enters ATTACK_REQUESTED and the request is subsequently rejected.

  • Any address can trigger the permanent lock through one permissionless transaction costing one token unit plus gas.

Impact: Low

  • The pool owner permanently loses the ability to align the pool with a corrected agreement scope.

  • The stale pool may need to be abandoned and replaced, disrupting existing pool configuration and coverage. This causes some annoyance for the pool owner, who may not be the agreement owner anymore.

Proof of Concept

Run it with `forge test --match-path test/unit/ConfidencePool.scopeLockGrief.t.sol -vv`

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract ConfidencePoolScopeLockGriefTest is BaseConfidencePoolTest {
address internal constant CORRECTED_SCOPE_ACCOUNT = address(0xBEEF);
function testOneUnitBonusPermanentlyLocksScopeDuringRejectedAttackRequest()
external
{
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.ATTACK_REQUESTED
);
token.mint(alice, 1);
vm.startPrank(alice);
token.approve(address(pool), 1);
pool.contributeBonus(1);
vm.stopPrank();
assertEq(pool.totalBonus(), 1);
assertEq(pool.totalEligibleStake(), 0);
assertTrue(pool.scopeLocked());
// Models rejectAttackRequest(), which returns the agreement
// to NOT_DEPLOYED.
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.NOT_DEPLOYED
);
// The agreement scope is corrected before requesting again.
agreementContract.setContractInScope(
CORRECTED_SCOPE_ACCOUNT,
true
);
address[] memory correctedScope = new address[](1);
correctedScope[0] = CORRECTED_SCOPE_ACCOUNT;
vm.expectRevert(
IConfidencePool.ScopePostLockImmutable.selector
);
pool.setPoolScope(correctedScope);
}
}

Recommended Mitigation

Treat ATTACK_REQUESTED as a temporary scope freeze rather than an irreversible lock. Permanently lock scope only after the request is approved or the agreement reaches a terminal state.

if (
!scopeLocked
&& state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
+ && state != IAttackRegistry.ContractState.ATTACK_REQUESTED
) {
scopeLocked = true;
emit ScopeLocked(block.timestamp);
}

Separately prevent scope updates while a request is pending:

function setPoolScope(address[] calldata accounts) external onlyOwner {
- _observePoolState();
+ IAttackRegistry.ContractState state = _observePoolState();
+ if (state == IAttackRegistry.ContractState.ATTACK_REQUESTED) {
+ revert ScopeUpdateTemporarilyDisabled();
+ }
if (scopeLocked) revert ScopePostLockImmutable();
_replaceScope(accounts);
}

Support

FAQs

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

Give us feedback!