## Description
All three CORRUPTED-path disbursements push tokens to `recoveryAddress` via `safeTransfer` with no
try/catch and no pull-based fallback:
- `claimCorrupted` → `recoveryAddress` (`ConfidencePool.sol:423`)
- `sweepUnclaimedCorrupted` → `recoveryAddress` (`468`)
- `sweepUnclaimedBonus` → `recoveryAddress` (`506`)
If the stake token blacklists `recoveryAddress` (e.g. USDC/USDT issuer/OFAC action) or
`recoveryAddress` is a contract that reverts on receipt, these calls revert and the funds are stuck
**until the owner retargets** via `setRecoveryAddress` (`611-618`, owner-only, no timelock, callable
any time).
## 3. Impact
- CORRUPTED disbursement can be temporarily bricked; funds are **not permanently lost** because the
owner can call `setRecoveryAddress` to a clean address and re-run the sweep.
- Only **sponsor-destined** funds are affected (bad-faith full sweep, good-faith residual/dust,
or SURVIVED/EXPIRED excess). Staker principal (pull) is unaffected.
- Requires either external token blacklist of the recovery address or owner misconfiguration.
## 2. Root cause (code)
```solidity
stakeToken.safeTransfer(recoveryAddress, toSweep);
stakeToken.safeTransfer(recoveryAddress, amount);
stakeToken.safeTransfer(recoveryAddress, amount);
function setRecoveryAddress(address newRecoveryAddress) external onlyOwner {
if (newRecoveryAddress == address(0)) revert InvalidRecoveryAddress();
recoveryAddress = newRecoveryAddress;
...
}
```
Because `recoveryAddress` is owner-mutable at any time, this is the canonical **configurable-recipient DoS where the victim is also the remediator**: funds are not permanently lost, but disbursement is wedged until an active, honest owner calls `setRecoveryAddress` to a clean address and re-runs the sweep. If the owner is unavailable, the funds sit trapped.
Note the asymmetry that bounds the blast radius: **staker payouts are per-staker pull to `msg.sender`** (`claimSurvived:403`, `claimExpired:601`, `claimAttackerBounty:449`) and are therefore isolated. Only **sponsor-destined** funds (the bad-faith full sweep, good-faith residual/dust, or SURVIVED/EXPIRED excess) route through the shared `recoveryAddress` and can be bricked.
**Root cause (GitHub permalinks):**
- `claimCorrupted` push to `recoveryAddress`: https:
- `sweepUnclaimedCorrupted` push: https:
- `sweepUnclaimedBonus` push: https:
- `setRecoveryAddress` (owner-only remediation, no timelock): https:
```solidity
pragma solidity 0.8.26;
import {Test} from "forge-std/Test.sol";
import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {MockAttackRegistry} from "test/mocks/MockAttackRegistry.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
import {MockAgreement} from "test/mocks/MockAgreement.sol";
contract BlacklistERC20_HS3 is ERC20 {
mapping(address => bool) public blocked;
constructor() ERC20("BL", "BL") {}
function mint(address to, uint256 a) external { _mint(to, a); }
function setBlocked(address a, bool b) external { blocked[a] = b; }
function _update(address from, address to, uint256 v) internal override {
require(!blocked[from] && !blocked[to], "BLACKLISTED");
super._update(from, to, v);
}
}
contract PoCHS3_CorruptedSweepRecoveryBlacklist is Test {
uint256 constant ONE = 1e18;
uint256 constant BASE_TIMESTAMP = 1_750_000_000;
BlacklistERC20_HS3 token;
MockAttackRegistry attackRegistry;
MockSafeHarborRegistry safeHarborRegistry;
MockAgreement agreementContract;
ConfidencePool pool;
address agreement;
address moderator = makeAddr("moderator");
address recovery = makeAddr("recovery");
address alice = makeAddr("alice");
function _scope() internal returns (address[] memory a) {
a = new address[](1);
a[0] = makeAddr("scopeAccount");
}
function setUp() public {
vm.warp(BASE_TIMESTAMP);
token = new BlacklistERC20_HS3();
attackRegistry = new MockAttackRegistry();
safeHarborRegistry = new MockSafeHarborRegistry();
agreementContract = new MockAgreement(address(this));
agreement = address(agreementContract);
agreementContract.setContractInScope(makeAddr("scopeAccount"), true);
safeHarborRegistry.setAttackRegistry(address(attackRegistry));
safeHarborRegistry.setAgreementValid(agreement, true);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
ConfidencePool impl = new ConfidencePool();
pool = ConfidencePool(Clones.clone(address(impl)));
pool.initialize(
agreement, address(token), address(safeHarborRegistry), moderator,
block.timestamp + 31 days, ONE, recovery, address(this), _scope()
);
}
function _stake(address u, uint256 amt) internal {
token.mint(u, amt);
vm.startPrank(u);
token.approve(address(pool), amt);
pool.stake(amt);
vm.stopPrank();
}
function test_HS3_badFaithCorrupted_bricksWhenRecoveryBlacklisted_thenOwnerUnblocks() public {
_stake(alice, 100 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
token.setBlocked(recovery, true);
vm.expectRevert(bytes("BLACKLISTED"));
pool.claimCorrupted();
assertEq(token.balanceOf(address(pool)), 100 * ONE, "pool funds trapped while recovery blacklisted");
address recovery2 = makeAddr("recovery2");
pool.setRecoveryAddress(recovery2);
pool.claimCorrupted();
assertEq(token.balanceOf(recovery2), 100 * ONE, "unblocked only after owner retarget");
}
function test_HS3_goodFaithSweepAlsoBricks() public {
_stake(alice, 100 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
address wh = makeAddr("whitehat");
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, wh);
vm.prank(wh);
pool.claimAttackerBounty();
assertEq(token.balanceOf(wh), 100 * ONE, "attacker got full pool");
token.mint(address(pool), 5 * ONE);
token.setBlocked(recovery, true);
vm.warp(pool.corruptedClaimDeadline() + 1);
vm.expectRevert(bytes("BLACKLISTED"));
pool.sweepUnclaimedCorrupted();
}
}
```
### Test outcome (verified)
```
Ran 2 tests for test/_scpocs/PoCHS3_CorruptedSweepRecoveryBlacklist.t.sol:PoCHS3_CorruptedSweepRecoveryBlacklist
[PASS] test_HS3_badFaithCorrupted_bricksWhenRecoveryBlacklisted_thenOwnerUnblocks() (gas: 504047)
[PASS] test_HS3_goodFaithSweepAlsoBricks() (gas: 579081)
Suite result: ok. 2 passed; 0 failed; 0 skipped