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

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.

Updates

Lead Judging Commences

inallhonesty Lead Judge 2 days ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity
Assigned finding tags:

QA / Informational / Gas

inallhonesty Lead Judge 2 days ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity
Assigned finding tags:

QA / Informational / Gas

Support

FAQs

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

Give us feedback!