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

Staker payouts transfer only to `msg.sender` with no `claimTo` alternative, so a token blacklist of a staker's own address self-locks that staker's claim/withdraw (self-only, no cross-user impact)

Author Revealed upon completion

## 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
---
```solidity
// src/ConfidencePool.sol
@> stakeToken.safeTransfer(msg.sender, amount); // withdraw (L317)
@> stakeToken.safeTransfer(msg.sender, payout); // claimSurvived (L403)
@> stakeToken.safeTransfer(msg.sender, payout); // claimExpired (L601)
```

## Risk
**Likelihood:**
- Requires an external token-issuer blacklist of the specific staker's address — outside the protocol's control and unrelated to any in-protocol action.
**Impact:**
- Affects **only the blacklisted staker's own** payout; no attacker gain, no shared/pooled loop, **no cross-user DoS**.
- **Not permanent**: the claim/withdraw succeeds once the token un-blacklists the address; accounting is never corrupted (clean rollback). Purely a UX/liveness residual for the affected user.

Proof of Concept

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
// ── claimSurvived: blacklisted staker reverts, state rolls back, bob unaffected ──
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); // blacklist alice -> claim reverts
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); // bob unaffected (per-staker pull)
pool.claimSurvived();
assertEq(token.balanceOf(bob), 100 ether, "bob claims unaffected");
}
// ── claimExpired: blacklisted staker reverts on the SURVIVED auto-resolution branch ──
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); // past expiry
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");
}
// ── withdraw (pre-risk): blacklisted staker reverts, bob unaffected ──
function test_withdraw_blacklistReverts() public {
_stake(alice, 100 ether);
_stake(bob, 100 ether);
token.setBlocked(alice, true); // registry still NEW_DEPLOYMENT: withdraw open
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");
}
// ── recovery: once un-blacklisted, the staker can claim (state was never corrupted) ──
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); // temporarily blacklisted -> reverts
vm.prank(alice);
vm.expectRevert(bytes("blacklisted"));
pool.claimSurvived();
token.setBlocked(alice, false); // un-blacklist -> claim now succeeds
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

## 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
}
```

Support

FAQs

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

Give us feedback!