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

`renounceOwnership()` + a later-blacklisted `recoveryAddress` permanently locks all CORRUPTED-path funds

Author Revealed upon completion

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:

// SPDX-License-Identifier: MIT
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 {
// A blacklist-capable stake token (models USDC/USDT after the issuer blocks an address) —
// still a normal token at pool-creation time, so it clears the factory's allowlist.
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);
// `agreement` (from BaseConfidencePoolTest) is owned by address(this), so no vm.prank
// is needed for the factory's `IAgreement(agreement).owner() != msg.sender` check.
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));
// Sponsor renounces ownership, e.g. believing it makes the pool more "decentralized".
riskyPool.renounceOwnership();
assertEq(riskyPool.owner(), address(0));
// Only afterwards does the token issuer block recoveryAddress — normal behavior for a
// blacklist-capable token, entirely outside the pool's control.
blockableToken.setBlocked(recovery, true);
// No owner remains to redirect the sweep, so every future claim reverts forever.
vm.expectRevert(abi.encodeWithSelector(MockBlockableERC20.AddressBlocked.selector, recovery));
riskyPool.claimCorrupted();
// The stake is permanently stuck in the pool.
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();
+ }
+

Support

FAQs

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

Give us feedback!