Description
ConfidencePool inherits OpenZeppelin's Ownable2Step but never overrides renounceOwnership(). If the sponsor renounces ownership and then the token issuer blacklists the recoveryAddress (normal behavior for USDC/USDT and similar stablecoins), all fund-sweeping paths (claimCorrupted, sweepUnclaimedCorrupted, sweepUnclaimedBonus) revert permanently. There is no owner left to call setRecoveryAddress() to redirect the sweep, leaving stakers' funds permanently locked in the pool.
contract ConfidencePool is Initializable, Ownable2Step, ReentrancyGuard, Pausable, IConfidencePool {
...
Risk
Likelihood: Low. It needs two independent events: the sponsor renouncing ownership, and recoveryAddress later being blacklisted. Neither is guaranteed, but both are realistic: sponsors renounce ownership to appear more decentralized, and blacklist-capable stablecoins (USDC/USDT-class) are common stake tokens. Once both are true, the trap is permanent and triggers on every future sweep with no further action needed.
Impact: High. Every future CORRUPTED-path payout and unclaimed-bonus sweep reverts forever, with no on-chain recovery path. This can freeze the entire pool balance — stakers' principal and bonus funds — not just a small amount.
Proof of Concept
To demonstrate this bug, add MockBlockableERC20.sol in test/mocks/ — a token contract that reverts transfers to blacklisted addresses, modeling the behavior of tokens like USDC/USDT when their issuer blocks an address. Then add testClaimCorruptedLocksForeverAfterRenounceThenRecoveryBlocked() in test/unit/ConfidencePool.t.sol to confirm the bug.
MockBlockableERC20.sol:
pragma solidity 0.8.26;
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract MockBlockableERC20 is ERC20 {
error AddressBlocked(address account);
mapping(address account => bool blocked) public isBlocked;
constructor() ERC20("Mock Blockable", "MBLK") {}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
function setBlocked(address account, bool blocked) external {
isBlocked[account] = blocked;
}
function _update(address from, address to, uint256 value) internal virtual override {
if (isBlocked[to]) revert AddressBlocked(to);
super._update(from, to, value);
}
}
Test function: testClaimCorruptedLocksForeverAfterRenounceThenRecoveryBlocked
import {MockBlockableERC20} from "test/mocks/MockBlockableERC20.sol";
function testClaimCorruptedLocksForeverAfterRenounceThenRecoveryBlocked() external {
MockBlockableERC20 blockableToken = new MockBlockableERC20();
ConfidencePool implementation = new ConfidencePool();
ConfidencePoolFactory factoryImpl = new ConfidencePoolFactory();
ERC1967Proxy proxy = new ERC1967Proxy(
address(factoryImpl),
abi.encodeCall(
ConfidencePoolFactory.initialize, (address(safeHarborRegistry), address(implementation), moderator)
)
);
ConfidencePoolFactory factory = ConfidencePoolFactory(address(proxy));
factory.setStakeTokenAllowed(address(blockableToken), true);
ConfidencePool riskyPool = ConfidencePool(
factory.createPool(
agreement, address(blockableToken), block.timestamp + 31 days, ONE, recovery, _defaultScope()
)
);
blockableToken.mint(alice, ONE);
vm.startPrank(alice);
blockableToken.approve(address(riskyPool), ONE);
riskyPool.stake(ONE);
vm.stopPrank();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
riskyPool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
riskyPool.renounceOwnership();
assertEq(riskyPool.owner(), address(0));
blockableToken.setBlocked(recovery, true);
vm.expectRevert(abi.encodeWithSelector(MockBlockableERC20.AddressBlocked.selector, recovery));
riskyPool.claimCorrupted();
assertEq(blockableToken.balanceOf(address(riskyPool)), ONE);
}
Run test with: forge test --match-test testClaimCorruptedLocksForeverAfterRenounceThenRecoveryBlocked -vvv
Result: [PASS] — confirming claimCorrupted() reverts forever and the staked funds stay trapped in the pool with no recovery path.
Recommended Mitigation
Add the error to src/interfaces/IConfidencePool.sol:
error RiskWindowNotReached();
+ error OwnershipRenunciationDisabled();
Then add the override to src/ConfidencePool.sol:
_unpause();
}
+ function renounceOwnership() public pure override {
+ revert OwnershipRenunciationDisabled();
+ }
+