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

Missing Address Validation in `setRecoveryAddress` and `initialize` Allows Recovery Address to Equal Pool Address

Author Revealed upon completion

Impact

If recoveryAddress is configured to be the pool's own address (address(this)), calls to claimCorrupted() and sweepUnclaimedCorrupted() will self-transfer tokens — the pool's token balance remains unchanged after each sweep. Since both functions are designed to be repeat-callable (to recover post-resolution donations), each invocation emits a successful event but achieves no actual fund movement. The CORRUPTED sweep path becomes an unbounded gas-burning loop until the owner resets recoveryAddress to a valid external address.

While the owner can recover by calling setRecoveryAddress again, the current validation only rejects address(0), omitting the equally invalid address(this) case. This deviates from standard Solidity best practices for address parameter validation.

Severity: LOW
Likelihood: LOW — requires the pool owner to explicitly set the recovery address to the pool itself. This could occur through operational error, especially when the pool address is known in advance (deterministic CREATE2 clone address).

Scope

Description

Normal behavior: When the pool resolves as CORRUPTED, claimCorrupted() transfers the pool's entire token balance to recoveryAddress. For bad-faith CORRUPTED, this sweeps all funds (stake + bonus) to the recovery destination. sweepUnclaimedCorrupted() does the same after the attacker bounty window expires.

Bug: The setRecoveryAddress function validates newRecoveryAddress != address(0) but does not validate newRecoveryAddress != address(this). If set to the pool itself:

  1. claimCorrupted() line 423 executes stakeToken.safeTransfer(address(this), toSweep) — a self-transfer

  2. The pool's token balance remains exactly the same

  3. A ClaimCorrupted event is emitted, creating a false record of successful sweep

  4. Because claimCorrupted is repeat-callable by design (for post-resolution donation recovery), the self-transfer can be triggered indefinitely, each time burning the caller's gas with zero effect

Root cause: Missing address(this) check in both setRecoveryAddress() and initialize():

// src/ConfidencePool.sol lines 611-618
function setRecoveryAddress(address newRecoveryAddress) external onlyOwner {
if (newRecoveryAddress == address(0)) revert InvalidRecoveryAddress();
// @> MISSING: if (newRecoveryAddress == address(this)) revert InvalidRecoveryAddress();
address oldRecoveryAddress = recoveryAddress;
recoveryAddress = newRecoveryAddress;
emit RecoveryAddressUpdated(oldRecoveryAddress, newRecoveryAddress);
}

And in initialize() at line 195:

if (recoveryAddress_ == address(0)) revert InvalidRecoveryAddress();
// @> MISSING: if (recoveryAddress_ == address(this)) revert InvalidRecoveryAddress();

Risk

Likelihood: LOW

  • The pool owner (sponsor) must explicitly call setRecoveryAddress(address(pool)) or pass the pool address during pool creation

  • The owner has no economic incentive to do this — it only harms their own pool

  • However, the pool address is deterministic (CREATE2 via Clones.cloneDeterministic with salt keccak256(abi.encode(agreement, poolIndex))), making it predictable before deployment

Impact: LOW

  • CORRUPTED-path funds become temporarily trapped in a self-transfer loop

  • Gas is wasted by callers of claimCorrupted() / sweepUnclaimedCorrupted()

  • Misleading ClaimCorrupted / UnclaimedCorruptedSwept events are emitted

  • Recovery is simple: owner calls setRecoveryAddress(validAddress) to restore normal operation

Proof of Concept

The following Foundry test demonstrates the self-transfer loop and recovery, Create in /test/unit/PoC_RecoveryAddressSelfAssignment.t.sol:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract PoC_RecoveryAddressSelfAssignment is BaseConfidencePoolTest {
function test_SelfAssignment_Causes_SelfTransfer_Loop() public {
// 1. Stake
_stake(alice, 100 * ONE);
// 2. Registry: UNDER_ATTACK -> CORRUPTED (with observed risk window)
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
pool.pokeRiskWindow();
// 3. Moderator flags bad-faith CORRUPTED
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
// 4. Owner sets recoveryAddress to pool itself (MISSING VALIDATION)
pool.setRecoveryAddress(address(pool));
assertEq(pool.recoveryAddress(), address(pool));
uint256 poolBalance = token.balanceOf(address(pool));
assertGt(poolBalance, 0, "pool should have tokens");
// 5. claimCorrupted: self-transfer, balance unchanged
pool.claimCorrupted();
assertEq(
token.balanceOf(address(pool)),
poolBalance,
"BUG: balance unchanged after claimCorrupted (self-transfer)"
);
// 6. Repeatable: infinite self-transfer loop
pool.claimCorrupted();
assertEq(
token.balanceOf(address(pool)),
poolBalance,
"BUG: second call also self-transfers, funds stuck"
);
// 7. RECOVERY: owner fixes recoveryAddress
pool.setRecoveryAddress(recovery);
uint256 trappedBalance = token.balanceOf(address(pool));
pool.claimCorrupted();
assertEq(token.balanceOf(address(pool)), 0, "funds fully swept after fix");
assertEq(token.balanceOf(recovery), trappedBalance, "recovery received all funds");
}
}

Run:

forge test --match-test test_SelfAssignment_Causes_SelfTransfer_Loop -vvv

Recommended Mitigation

Add address(this) check in both locations:

// setRecoveryAddress (line 612)
- if (newRecoveryAddress == address(0)) revert InvalidRecoveryAddress();
+ if (newRecoveryAddress == address(0) || newRecoveryAddress == address(this))
+ revert InvalidRecoveryAddress();
// initialize (line 195)
- if (recoveryAddress_ == address(0)) revert InvalidRecoveryAddress();
+ if (recoveryAddress_ == address(0) || recoveryAddress_ == address(this))
+ revert InvalidRecoveryAddress();

This follows the same defensive pattern already used for address(0) validation and is consistent with standard Solidity best practices for setter functions that control fund destinations.

Support

FAQs

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

Give us feedback!