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

Setting `recoveryAddress` to the pool itself traps or misaccounts swept funds

Author Revealed upon completion

Root + Impact

Description

The pool accepts its own address as recoveryAddress. Transfers to that address succeed for a standard ERC-20 without reducing the pool balance, while the sweep functions still finalize accounting and emit successful-sweep events.

Recovery sweeps are intended to remove tokens from the pool and deliver them to the sponsor-selected recoveryAddress. Both initialization and setRecoveryAddress() reject only the zero address, so the configured recipient can be the pool itself.

For a standard ERC-20, transferring from the pool to itself debits and credits the same account. The call succeeds, but the pool's token balance does not change. claimCorrupted() nevertheless decrements corruptedReserve, latches claimsStarted, and emits ClaimCorrupted. sweepUnclaimedCorrupted() similarly clears the corrupted reserve and bounty accounting. In the bonus path, sweepUnclaimedBonus() can decrement totalBonus and emit BonusSwept even though no tokens moved; because the balance is unchanged, the same nominal amount remains permissionlessly sweepable on later calls.

src/ConfidencePool.sol
function setRecoveryAddress(address newRecoveryAddress) external onlyOwner {
if (newRecoveryAddress == address(0)) revert InvalidRecoveryAddress(); // @> The pool itself is accepted.
address oldRecoveryAddress = recoveryAddress;
recoveryAddress = newRecoveryAddress;
emit RecoveryAddressUpdated(oldRecoveryAddress, newRecoveryAddress);
}
function claimCorrupted() external nonReentrant {
if (outcome != PoolStates.Outcome.CORRUPTED) revert OutcomeNotSet();
// ... eligibility checks omitted ...
uint256 toSweep = stakeToken.balanceOf(address(this));
if (toSweep == 0) revert NothingToSweep();
corruptedReserve = toSweep <= corruptedReserve ? corruptedReserve - toSweep : 0; // @> Accounting advances.
// ... remaining state updates omitted ...
stakeToken.safeTransfer(recoveryAddress, toSweep); // @> A transfer to address(this) changes no balance.
emit ClaimCorrupted(msg.sender, recoveryAddress, toSweep); // @> Reports success despite no movement.
}
function sweepUnclaimedBonus() external nonReentrant {
if (outcome != PoolStates.Outcome.SURVIVED && outcome != PoolStates.Outcome.EXPIRED) {
revert OutcomeNotEligibleForSweep();
}
// ... reserve calculation omitted ...
if (totalEligibleStake == 0 || riskWindowStart == 0) {
totalBonus -= amount <= totalBonus ? amount : totalBonus; // @> Accounting clears before tokens leave.
}
stakeToken.safeTransfer(recoveryAddress, amount); // @> The pool balance remains unchanged when sent to self.
emit BonusSwept(msg.sender, recoveryAddress, amount);
}

The CORRUPTED sweep functions size subsequent calls from the live pool balance, so an available owner can repair the configuration and sweep again. Until that happens, however, the entire recoverable balance remains trapped behind successful-looking state and events; it becomes permanently inaccessible if the owner is unavailable.

Risk

Likelihood:

  • The initial pool creator or current pool owner must deliberately or accidentally select the pool's address. No unprivileged caller can change recoveryAddress.

  • Once configured, any permissionless recovery sweep reaches the issue with an ordinary standards-compliant ERC-20; no malicious token behavior is required.

Impact:

  • A CORRUPTED resolution can appear settled while the full principal and bonus balance remains in the clone and the intended recovery recipient receives nothing.

  • Reserve, bounty, or bonus bookkeeping and emitted events can claim a successful sweep despite an unchanged balance, and bonus self-sweeps can be repeated permissionlessly.

  • Rotating to a valid recipient recovers the balance, but loss of owner availability turns the configuration error into a permanent lock.

Proof of Concept

Create test/audit/CP013RecoveryAddressSelfSweep.t.sol with the following contents:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract CP013RecoveryAddressSelfSweepTest is BaseConfidencePoolTest {
function test_SelfRecoveryMakesCorruptedSweepSucceedWithoutMovingFunds() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
_passThroughUnderAttack();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
pool.setRecoveryAddress(address(pool));
uint256 poolBalance = token.balanceOf(address(pool));
assertEq(poolBalance, 150 * ONE);
assertEq(pool.corruptedReserve(), poolBalance);
vm.expectEmit(true, true, false, true, address(pool));
emit IConfidencePool.ClaimCorrupted(address(this), address(pool), poolBalance);
pool.claimCorrupted();
assertEq(token.balanceOf(address(pool)), poolBalance, "the self-transfer moves no tokens");
assertEq(pool.corruptedReserve(), 0, "accounting says the full reserve was swept");
assertTrue(pool.claimsStarted(), "the outcome is latched despite no fund movement");
// The owner can repair the configuration, but until then every sweep only reports
// another successful transfer to the pool itself.
pool.setRecoveryAddress(recovery);
uint256 recoveryBefore = token.balanceOf(recovery);
pool.claimCorrupted();
assertEq(token.balanceOf(recovery) - recoveryBefore, poolBalance);
assertEq(token.balanceOf(address(pool)), 0);
}
function test_SelfRecoveryMakesBonusSweepEraseAccountingWithoutMovingFunds() external {
_contributeBonus(carol, 40 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
pool.setRecoveryAddress(address(pool));
uint256 poolBalance = token.balanceOf(address(pool));
assertEq(pool.totalBonus(), poolBalance);
vm.expectEmit(true, true, false, true, address(pool));
emit IConfidencePool.BonusSwept(bob, address(pool), poolBalance);
vm.prank(bob);
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(address(pool)), poolBalance, "the swept bonus remains in the pool");
assertEq(pool.totalBonus(), 0, "bonus accounting is cleared despite the unchanged balance");
// Since the balance did not decrease, the same nominal amount remains sweepable.
vm.prank(alice);
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(address(pool)), poolBalance);
pool.setRecoveryAddress(recovery);
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery), poolBalance);
assertEq(token.balanceOf(address(pool)), 0);
}
}

Run the PoC from the repository root:

  1. Execute forge test --offline --match-path test/audit/CP013RecoveryAddressSelfSweep.t.sol -vv.

  2. Confirm that both tests pass and the suite reports two tests passed with zero failures.

Recommended Mitigation

Reject the pool itself wherever the recovery address is accepted. The initializer check is required because the factory can pass the clone's predictable address before ownership-based setters are available.

src/ConfidencePool.sol
function initialize(
address agreement_,
// ... unchanged parameters omitted ...
address[] calldata accounts
) external initializer {
// ... unchanged validation omitted ...
- if (recoveryAddress_ == address(0)) revert InvalidRecoveryAddress();
+ if (recoveryAddress_ == address(0) || recoveryAddress_ == address(this)) {
+ revert InvalidRecoveryAddress();
+ }
if (expiry_ < block.timestamp + _MIN_EXPIRY_LEAD) revert ExpiryTooSoon();
// ... unchanged initialization omitted ...
_transferOwnership(owner_);
}
function setRecoveryAddress(address newRecoveryAddress) external onlyOwner {
- if (newRecoveryAddress == address(0)) revert InvalidRecoveryAddress();
+ if (newRecoveryAddress == address(0) || newRecoveryAddress == address(this)) {
+ revert InvalidRecoveryAddress();
+ }
address oldRecoveryAddress = recoveryAddress;
recoveryAddress = newRecoveryAddress;
emit RecoveryAddressUpdated(oldRecoveryAddress, newRecoveryAddress);
}

Support

FAQs

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

Give us feedback!