## Description
Every staker-facing payout transfers directly to `msg.sender`, with no `claimTo(address recipient)` alternative:
If the (allowlisted, standard) stake token blacklists a staker's **own** address — an external token-authority action, e.g. USDC/USDT issuer/OFAC — that staker's `withdraw` / `claimSurvived` / `claimExpired` reverts inside `safeTransfer`. Because the transfer is the final CEI step, the revert **rolls back the whole call** (no `hasClaimed`/`eligibleStake` mutation), so the staker's accounting is preserved and they can claim again once un-blacklisted.
This is the textbook **token-blacklist self-lock**: it affects only the blacklisted staker's own funds, there is no attacker profit, and — critically — because payouts are **per-staker pull**, one blacklisted staker never blocks another (contrast the shared `recoveryAddress` push path, which is a separate, higher-blast-radius issue).
**Root cause (GitHub permalinks):**
- `withdraw` transfers to `msg.sender`: https://github.com/CodeHawks-Contests/2026-07-bc-confidence-pools/blob/58e8ba4ce3f3277866e4926f3140e597f9554a1e/src/ConfidencePool.sol#L317
- `claimSurvived` transfers to `msg.sender`: https://github.com/CodeHawks-Contests/2026-07-bc-confidence-pools/blob/58e8ba4ce3f3277866e4926f3140e597f9554a1e/src/ConfidencePool.sol#L403
- `claimExpired` transfers to `msg.sender`: https://github.com/CodeHawks-Contests/2026-07-bc-confidence-pools/blob/58e8ba4ce3f3277866e4926f3140e597f9554a1e/src/ConfidencePool.sol#L601
---
The file contains a minimal blacklisting ERC20 (`BlacklistERC20` overriding `_update` to revert on a blocked `from`/`to`) and four tests covering `claimSurvived`, `claimExpired`, `withdraw`, and recovery-after-unban:
```solidity
function test_claimSurvived_blacklistReverts_stateRolledBack() public {
_stake(alice, 100 ether);
_stake(bob, 100 ether);
_passThroughUnderAttack();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
token.setBlocked(alice, true);
vm.prank(alice);
vm.expectRevert(bytes("blacklisted"));
pool.claimSurvived();
assertEq(pool.eligibleStake(alice), 100 ether, "eligibleStake rolled back");
assertFalse(pool.hasClaimed(alice), "hasClaimed rolled back");
vm.prank(bob);
pool.claimSurvived();
assertEq(token.balanceOf(bob), 100 ether, "bob claims unaffected");
}
function test_claimExpired_blacklistReverts_onExpiredSurvived() public {
_stake(alice, 100 ether);
_stake(bob, 100 ether);
_passThroughUnderAttack();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.warp(block.timestamp + 32 days);
token.setBlocked(alice, true);
vm.prank(alice);
vm.expectRevert(bytes("blacklisted"));
pool.claimExpired();
assertEq(pool.eligibleStake(alice), 100 ether, "eligibleStake rolled back after claimExpired revert");
assertFalse(pool.hasClaimed(alice), "hasClaimed rolled back after claimExpired revert");
vm.prank(bob);
pool.claimExpired();
assertGt(token.balanceOf(bob), 0, "bob claimExpired unaffected");
}
function test_withdraw_blacklistReverts() public {
_stake(alice, 100 ether);
_stake(bob, 100 ether);
token.setBlocked(alice, true);
vm.prank(alice);
vm.expectRevert(bytes("blacklisted"));
pool.withdraw();
assertEq(pool.eligibleStake(alice), 100 ether, "eligibleStake rolled back after withdraw revert");
vm.prank(bob);
pool.withdraw();
assertEq(token.balanceOf(bob), 100 ether, "bob withdraw unaffected");
}
function test_blacklistedStaker_canClaimAfterUnban() public {
_stake(alice, 100 ether);
_stake(bob, 100 ether);
_passThroughUnderAttack();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
token.setBlocked(alice, true);
vm.prank(alice);
vm.expectRevert(bytes("blacklisted"));
pool.claimSurvived();
token.setBlocked(alice, false);
vm.prank(alice);
pool.claimSurvived();
assertGt(token.balanceOf(alice), 0, "alice claims after unban");
}
```
**Outcome (as run):**
```
Ran 4 tests for HotspotHS6
[PASS] test_blacklistedStaker_canClaimAfterUnban() (gas: 578389)
[PASS] test_claimExpired_blacklistReverts_onExpiredSurvived() (gas: 680409)
[PASS] test_claimSurvived_blacklistReverts_stateRolledBack() (gas: 593494)
[PASS] test_withdraw_blacklistReverts() (gas: 425275)
Suite result: ok. 4 passed; 0 failed; 0 skipped
```
The suite proves: (1) the blacklisted staker's call reverts and rolls back cleanly, (2) a different staker (`bob`) is unaffected, (3) the staker can claim once un-blacklisted.
## Recommended Mitigation
Add an optional `claimTo(address recipient)` (and `withdrawTo`) so a blacklisted staker can direct their own funds to a clean address. This is a convenience/UX improvement, not a security fix.
```diff
- function claimSurvived() external nonReentrant {
+ function claimSurvived() external nonReentrant { _claimSurvived(msg.sender); }
+ function claimSurvivedTo(address recipient) external nonReentrant {
+ if (recipient == address(0)) revert InvalidRecoveryAddress();
+ _claimSurvived(recipient);
+ }
+ function _claimSurvived(address recipient) internal {
...
- stakeToken.safeTransfer(msg.sender, payout);
+ stakeToken.safeTransfer(recipient, payout); // credited to the caller's chosen clean address
}
```