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

Sponsor Can Grief Stakers by Changing recoveryAddress to Non-Retrievable Address

Author Revealed upon completion

Description

Normal Behavior

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.

Specific Issue

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:

· A contract without receive/fallback functions
· A burn address (0x...DEAD)
· The pool's own address (address(this))

Once claimCorrupted() is called, funds are sent to that address and no rescue or emergencyWithdraw function exists in the contract to recover them.

Root Cause

// src/ConfidencePool.sol:611-620
function setRecoveryAddress(address newRecoveryAddress) external onlyOwner {
if (newRecoveryAddress == address(0)) revert InvalidRecoveryAddress(); // @> ONLY checks address(0)
address oldRecoveryAddress = recoveryAddress;
recoveryAddress = newRecoveryAddress;
emit RecoveryAddressUpdated(oldRecoveryAddress, newRecoveryAddress);
}

Missing safeguards:

· No check whether newRecoveryAddress can receive tokens
· No time-lock giving stakers a window to exit
· No locking mechanism after the first stake (unlike expiry and scope)


Design Inconsistency

The contract applies one-way locking mechanisms to other critical parameters to protect stakers, but neglects recoveryAddress:

Parameter Safety Mechanism Design Rationale (DESIGN.md)
expiry Permanently locked after first stake (expiryLocked) Section 10: "protects staker reliance: once anyone has deposited... sponsor cannot move it"
Pool scope Permanently locked after registry leaves pre-attack staging (scopeLocked) Section 8: "stakers are exposed to exactly what they signed up for at deposit time"
riskWindowStart One-way latch, cannot be reopened once sealed Section 9: "upstream registry rewind cannot re-open it"
withdraw Permanently disabled after UNDER_ATTACK Section 9: "permanently disabled from UNDER_ATTACK onward"
recoveryAddress No protection whatsoever Section 10: Only describes its function, no justification for why it is not locked

The code comment at line 55 states:

// Config (set at init; `expiry`/`recoveryAddress`/scope mutable per their own gates).

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).

The DESIGN.md Section 10 states:

"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.

// Root cause in the codebase with @> marks to highlight the relevant section

Risk

Likelihood: LOW

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: MEDIUM

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.




Proof of Concept

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://github.com/CodeHawks-Contests/2026-07-bc-confidence-pools.git
cd 2026-07-bc-confidence-pools
```
2. Create the mock file test/mocks/MockNonReceivingContract.sol:
```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
contract MockNonReceivingContract {
// No receive(), no fallback()
// SafeERC20 transfers to this contract will fail
}
```
3. Create the test file test/unit/GriefingRecoveryAddress.t.sol with the following content:
```solidity
// 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 {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();
}
/// @notice Scenario 1: Sponsor changes recoveryAddress to a contract without receive/fallback
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");
}
/// @notice Scenario 2: Sponsor changes recoveryAddress to a burn address
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");
}
/// @notice Scenario 3: Sponsor changes recoveryAddress to pool address itself
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.

Recommended Mitigation

- Recommendation
1. Implement a time-lock on recoveryAddress changes (e.g., 7 days) with a clearly emitted event, giving stakers a window to exit before the change takes effect.
2. Alternatively, lock recoveryAddress after the first stake (like expiry), so stakers can verify it upon entry and need not worry about sudden changes.
3. At minimum, add validation that newRecoveryAddress is an EOA or a contract with a token withdrawal mechanism.
***
References
· src/ConfidencePool.sol:611-620 - setRecoveryAddress()
· src/ConfidencePool.sol:408-430 - claimCorrupted()
· docs/DESIGN.md Section 10 - Sponsor trust surface
· docs/DESIGN.md Section 8 - Scope lock rationale
· docs/DESIGN.md Section 9 - Withdraw lifecycle
<br />

Support

FAQs

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

Give us feedback!