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

Blacklisted staker permanently loses principal with no owner recovery path

Author Revealed upon completion

Root + Impact

Root cause: claimSurvived(), claimExpired(), and withdraw() unconditionally send tokens to msg.sender with no alternative-address mechanism.

Direct impact:

  • Blacklisted staker's principal (up to the full pool value) is permanently locked inside the contract.

  • totalEligibleStake never reaches zero, so sweepUnclaimedBonus() is permanently blocked — the entire remaining bonus reserve is also unrecoverable.

No owner mitigation exists:

  • setRecoveryAddress() only redirects claimCorrupted() / sweepUnclaimedCorrupted() / sweepUnclaimedBonus() — it has no effect on individual staker claims.

  • There is no rescueStuck(), claimFor(), or equivalent admin function.

  • withdraw() is already disabled post-resolution (outcome != UNRESOLVED check), so the pre-resolution exit path is also closed.

Description

All three staker-exit functions hardcode msg.sender as the ERC-20 recipient with no fallback:

// claimSurvived() — L403
stakeToken.safeTransfer(msg.sender, payout);
// claimExpired() — L601
stakeToken.safeTransfer(msg.sender, payout);
// withdraw() — L317
stakeToken.safeTransfer(msg.sender, amount);

stakeToken is immutable after initialize(). There is no claimTo(address recipient) variant, no pull-payment registry, and no admin function that can touch eligibleStake[specificUser].

When a safeTransfer(msg.sender, ...) call reverts (blacklisted recipient), the entire transaction reverts atomically. Because Solidity's checked arithmetic makes every state mutation in the call stack roll back, eligibleStake[staker] is never decremented and totalEligibleStake is never reduced. The staker cannot retry with a different address; the protocol has no mechanism to redirect the payout.

The secondary lockup follows from the sweepUnclaimedBonus() reserve calculation:

if (totalEligibleStake != 0) {
reserved = totalEligibleStake;
if (riskWindowStart != 0) {
reserved += snapshotTotalBonus - claimedBonus;
}
}
uint256 amount = freeBalance > reserved ? freeBalance - reserved : 0;
if (amount == 0) revert NothingToSweep();

While the blacklisted staker's eligibleStake keeps totalEligibleStake > 0, reserved always equals or exceeds freeBalance, so amount == 0 and the sweep reverts permanently.

Risk

Likelihood:

The pool's token allowlist is controlled by the factory owner, meaning only tokens explicitly approved will be used. However the protocol targets multi-year operation (30-day minimum expiry, no upper bound beyond uint32 max), and the README lists no token restrictions beyond "standard ERC-20, no fee-on-transfer, no rebasing." USDC and USDT — the most common DeFi collateral tokens — both implement owner-controlled blacklists. Over a 2–3 year pool lifetime the probability of at least one participant being sanctioned or exchange-frozen is non-trivial, especially for pools with many stakers. The blacklisting event is entirely outside the protocol's control.

Impact:

A staker whose address is blacklisted by a centralized ERC-20 token (e.g. USDC, USDT) after depositing permanently loses their full principal. Every claim attempt reverts atomically, leaving eligibleStake[staker] set forever with no alternative recipient path and no admin escape hatch. As a secondary effect, while the blacklisted stake remains in the accounting the entire remaining bonus reserve is also permanently trapped — sweepUnclaimedBonus() is blocked because reserved >= freeBalance for as long as totalEligibleStake > 0.

Proof of Concept

Run with: forge test --match-path "test/exploits/ExploitBlacklistStakerFundLock.t.sol" -vvv

Logs:
Bob claimed: 1.1e20 tokens (stake + bonus)
Alice eligible stake locked: 1e20
totalEligibleStake stuck: 1e20
Pool balance (alice stake + unclaimed bonus): 1.1e20
sweepUnclaimedBonus: blocked (reserved >= freeBalance)
=== RESULT ===
Permanently locked in pool: 1.1e20 tokens
Of which alice principal: 1e20 tokens
Owner setRecoveryAddress: no effect on staker principal
sweepUnclaimedBonus: blocked while eligibleStake[alice] > 0
Suite result: ok. 1 passed; 0 failed; 0 skipped

Test file (test/exploits/ExploitBlacklistStakerFundLock.t.sol):

The test deploys a BlacklistableERC20 (mimicking USDC's blacklist), runs a two-staker SURVIVED pool through a full risk window, has Bob claim successfully, blacklists Alice, then asserts:

  1. claimSurvived() reverts with BLACKLISTED.

  2. eligibleStake[alice] remains at 100e18 — principal is never decremented.

  3. sweepUnclaimedBonus() reverts with NothingToSweep — bonus reserve is blocked.

  4. setRecoveryAddress(newAddr) + retry still reverts — no owner escape hatch.

// 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 {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ConfidencePool} from "src/ConfidencePool.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";
/// @dev ERC-20 with an owner-controlled blacklist, mimicking USDC/USDT behaviour.
contract BlacklistableERC20 is ERC20 {
mapping(address => bool) public blacklisted;
constructor() ERC20("Blacklistable", "BL") {}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
function blacklist(address account) external {
blacklisted[account] = true;
}
function _update(address from, address to, uint256 amount) internal override {
require(!blacklisted[from] && !blacklisted[to], "BLACKLISTED");
super._update(from, to, amount);
}
}
/// @title ExploitBlacklistStakerFundLock
/// @notice Demonstrates that a USDC-style blacklist permanently locks a staker's
/// principal AND traps the remaining bonus reserve, with no owner recovery path.
///
/// Root cause: all claim functions hardcode `msg.sender` as the token recipient.
/// claimSurvived() L403: stakeToken.safeTransfer(msg.sender, payout)
/// claimExpired() L601: stakeToken.safeTransfer(msg.sender, payout)
/// withdraw() L317: stakeToken.safeTransfer(msg.sender, amount)
///
/// When a transfer to msg.sender reverts (blacklisted), the ENTIRE transaction
/// reverts atomically - eligibleStake[user] is never decremented, so the
/// principal is permanently unclaimable. No alternative-recipient path exists.
///
/// Secondary effect: while eligibleStake[alice] > 0, totalEligibleStake > 0,
/// and sweepUnclaimedBonus() reserves alice's bonus share, blocking the sweep.
contract ExploitBlacklistStakerFundLock is Test {
uint256 internal constant BASE_TS = 1_750_000_000;
uint256 internal constant ONE = 1e18;
uint256 internal constant STAKE_AMT = 100 * ONE;
uint256 internal constant BONUS_AMT = 20 * ONE;
BlacklistableERC20 internal token;
MockAttackRegistry internal attackRegistry;
MockSafeHarborRegistry internal safeHarborRegistry;
MockAgreement internal agreementContract;
ConfidencePool internal pool;
address internal sponsor = makeAddr("sponsor");
address internal moderator = makeAddr("moderator");
address internal recovery = makeAddr("recovery");
address internal alice = makeAddr("alice");
address internal bob = makeAddr("bob");
address internal constant SCOPE_ACCT = address(0xC0FFEE);
function setUp() public {
vm.warp(BASE_TS);
token = new BlacklistableERC20();
attackRegistry = new MockAttackRegistry();
safeHarborRegistry = new MockSafeHarborRegistry();
agreementContract = new MockAgreement(sponsor);
agreementContract.setContractInScope(SCOPE_ACCT, true);
safeHarborRegistry.setAttackRegistry(address(attackRegistry));
safeHarborRegistry.setAgreementValid(address(agreementContract), true);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
ConfidencePool impl = new ConfidencePool();
pool = ConfidencePool(Clones.clone(address(impl)));
address[] memory scope = new address[](1);
scope[0] = SCOPE_ACCT;
vm.prank(sponsor);
pool.initialize(
address(agreementContract),
address(token),
address(safeHarborRegistry),
moderator,
block.timestamp + 60 days,
ONE,
recovery,
sponsor,
scope
);
token.mint(alice, STAKE_AMT);
token.mint(bob, STAKE_AMT);
token.mint(address(this), BONUS_AMT); // bonus contributor
}
function testExploit_BlacklistStakerPermanentFundLock() public {
// 1. Alice and Bob stake before the risk window
vm.startPrank(alice);
token.approve(address(pool), STAKE_AMT);
pool.stake(STAKE_AMT);
vm.stopPrank();
vm.startPrank(bob);
token.approve(address(pool), STAKE_AMT);
pool.stake(STAKE_AMT);
vm.stopPrank();
// Bonus contributed by the pool sponsor
token.approve(address(pool), BONUS_AMT);
pool.contributeBonus(BONUS_AMT);
// 2. Agreement goes UNDER_ATTACK seal the risk window
// riskWindowStart is set lazily: must call _observePoolState() while
// the registry is in UNDER_ATTACK. pokeRiskWindow() triggers this.
vm.warp(BASE_TS + 10 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow(); // seals riskWindowStart = BASE_TS + 10 days
assertGt(pool.riskWindowStart(), 0, "riskWindowStart must be set");
// 3. Agreement reaches PRODUCTION resolved SURVIVED
vm.warp(BASE_TS + 20 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.SURVIVED));
// 4. Bob claims successfully (stake + k=2 bonus share)
uint256 bobBefore = token.balanceOf(bob);
vm.prank(bob);
pool.claimSurvived();
uint256 bobClaimed = token.balanceOf(bob) - bobBefore;
assertGt(bobClaimed, STAKE_AMT, "Bob should receive stake + bonus");
console.log("Bob claimed: %e tokens (stake + bonus)", bobClaimed);
// 5. Token admin blacklists Alice
token.blacklist(alice);
uint256 aliceEligible = pool.eligibleStake(alice);
assertEq(aliceEligible, STAKE_AMT, "Alice still has eligibleStake before any claim");
// 6. Alice's claimSurvived() always reverts
// The transfer to msg.sender fails; the whole tx reverts atomically,
// so eligibleStake[alice] is never decremented.
vm.prank(alice);
vm.expectRevert(bytes("BLACKLISTED"));
pool.claimSurvived();
// State is UNCHANGED after the reverted claim
assertEq(pool.eligibleStake(alice), STAKE_AMT,
"eligibleStake[alice] never decremented - principal permanently locked");
assertEq(pool.totalEligibleStake(), STAKE_AMT,
"totalEligibleStake counts alice's locked stake");
console.log("Alice eligible stake locked: %e", pool.eligibleStake(alice));
console.log("totalEligibleStake stuck: %e", pool.totalEligibleStake());
// 7. Bonus reserve is also blocked
// sweepUnclaimedBonus() computes:
// reserved = totalEligibleStake (alice) + (snapshotTotalBonus - claimedBonus)
// freeBalance = alice's stake + alice's unclaimed bonus share
// amount = freeBalance - reserved = 0 NothingToSweep
uint256 poolBalance = token.balanceOf(address(pool));
console.log("Pool balance (alice stake + unclaimed bonus): %e", poolBalance);
vm.expectRevert(); // NothingToSweep
pool.sweepUnclaimedBonus();
console.log("sweepUnclaimedBonus: blocked (reserved >= freeBalance)");
// 8. Owner recovery attempts are futile
// setRecoveryAddress only affects claimCorrupted/sweepUnclaimedCorrupted,
// not individual staker claims.
address newRecovery = makeAddr("newRecovery");
vm.prank(sponsor);
pool.setRecoveryAddress(newRecovery);
// Sweep still blocked even with a new recovery address
vm.expectRevert(); // NothingToSweep
pool.sweepUnclaimedBonus();
// Alice's principal remains in the pool with no extraction path
assertGe(token.balanceOf(address(pool)), STAKE_AMT,
"Alice's 100e18 principal remains locked forever");
console.log("\n=== RESULT ===");
console.log("Permanently locked in pool: %e tokens", token.balanceOf(address(pool)));
console.log("Of which alice principal: %e tokens", pool.eligibleStake(alice));
console.log("Owner setRecoveryAddress: no effect on staker principal");
console.log("sweepUnclaimedBonus: blocked while eligibleStake[alice] > 0");
}
}

Recommended Mitigation

Add an optional alternative-recipient path so a staker can redirect their payout to an address that is not blacklisted:

// Option A — explicit beneficiary parameter
function claimSurvivedTo(address beneficiary) external nonReentrant {
require(eligibleStake[beneficiary] == 0, "beneficiary already has stake");
// ... identical claim logic, but safeTransfer(beneficiary, payout)
}

Or, for a simpler fix, add a pre-registered rescue address:

mapping(address staker => address rescue) public rescueAddress;
function setRescueAddress(address rescue) external {
rescueAddress[msg.sender] = rescue;
}

Then in each claim function, resolve the recipient as rescueAddress[msg.sender] != address(0) ? rescueAddress[msg.sender] : msg.sender before the transfer.

Either approach preserves the CEI pattern and requires no changes to the accounting logic.

Support

FAQs

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

Give us feedback!