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

Blacklist-Capable ERC20 Tokens Can Permanently Lock Recipient Entitlements with No Alternate Payout Path

Author Revealed upon completion

Root + Impact

Description

The protocol supports standard ERC20 stake tokens and explicitly excludes only fee-on-transfer and rebasing behavior. Blacklist-capable ERC20 tokens such as USDC are not fee-on-transfer or rebasing tokens, but they can revert transfers to or from sanctioned addresses.

All staker exit paths transfer funds only to msg.sender. There is no withdrawTo(address), claimSurvivedTo(address), or claimExpiredTo(address) variant that lets a staker route their entitlement to a non-blacklisted recipient. If a staker is blacklisted after depositing, their withdrawal or claim reverts at the token transfer and their principal remains recorded in eligibleStake.

The same no-alternate-recipient root also applies to the good-faith CORRUPTED bounty path. claimAttackerBounty() requires msg.sender == attacker and pays the bounty to attacker directly. If the named good-faith attacker is blacklisted by the stake token, the bounty transfer reverts and the attacker cannot claim to a clean recipient during the claim window.

The withdrawal path in ConfidencePool.sol::withdraw transfers only to msg.sender, so a token-level recipient blacklist makes the exit revert instead of letting the staker route the same entitlement to another address.

The SURVIVED claim path in ConfidencePool.sol::claimSurvived has the same recipient restriction: the staker's principal plus bonus payout is sent only to msg.sender.

The good-faith bounty path in ConfidencePool.sol::claimAttackerBounty also pays only the named attacker. The caller must be attacker, and the transfer recipient is the same attacker address, so a blacklisted attacker cannot claim to a clean recipient.

The sweep reserve in ConfidencePool.sol::sweepUnclaimedBonus keeps reserving for the unclaimable staker because the reverted claim leaves that staker's principal in totalEligibleStake.

Because the reverted staker claim leaves eligibleStake, totalEligibleStake, and hasClaimed unchanged, the staker cannot exit and the pool continues reserving the stuck principal. With an observed risk window, the sweep reserve can also continue reserving the unclaimed bonus portion.

Because the reverted bounty claim rolls back bountyClaimed, corruptedReserve, and claimsStarted, the named attacker also cannot claim any part of the bounty to another address. The only eventual movement is the post-deadline sweepUnclaimedCorrupted() path to recoveryAddress, which does not help the entitled good-faith attacker.

Risk

Likelihood: Low

  • The factory owner must allowlist a blacklist-capable ERC20 token that is otherwise standard and not fee-on-transfer or rebasing (e.g. USDC/USDT).

  • A staker address or named good-faith attacker must then be blacklisted independently of the pool's own logic while still having a pool entitlement.

Impact: Low

  • The blacklisted staker cannot withdraw or claim to an alternate address, so their principal and any bonus entitlement remain stuck in the pool.

  • A blacklisted good-faith attacker cannot receive the bounty or route it to another recipient, so their entitlement remains in the pool until it can be swept to recoveryAddress after the claim window.

  • The stuck position remains counted in totalEligibleStake, which prevents normal bonus sweeping from releasing the reserved amount.

Proof of Concept

The PoC file included with this report is:

BlacklistTokenStakerLockupPoC.t.sol

To run it in a fresh contest checkout:

  1. Copy BlacklistTokenStakerLockupPoC.t.sol into the repository's test/ directory.

  2. Run:

forge test --match-contract BlacklistTokenStakerLockupPoC -vv

Expected result:

2 passed, 0 failed

The PoC demonstrates:

  • A blacklist-capable ERC20, two stakers, a SURVIVED outcome, one staker blacklisted before claim, and the blacklisted staker's principal plus bonus remaining reserved in the pool after their claim reverts.

  • A good-faith CORRUPTED outcome where the named attacker is blacklisted before claiming and cannot route the bounty to an alternate recipient.

Inline PoC source (BlacklistTokenStakerLockupPoC.t.sol):

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test} from "forge-std/Test.sol";
import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.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";
/// @notice Minimal blacklist-capable ERC-20 that mimics USDC's blacklist behavior.
/// Standard ERC-20 with no transfer fees and no rebasing -- passes the factory allowlist.
contract BlacklistableERC20 is ERC20 {
mapping(address => bool) public isBlacklisted;
constructor() ERC20("Blacklistable", "BLK") {}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
function blacklist(address account) external {
isBlacklisted[account] = true;
}
function _update(address from, address to, uint256 value) internal override {
require(!isBlacklisted[from], "sender blacklisted");
require(!isBlacklisted[to], "recipient blacklisted");
super._update(from, to, value);
}
}
/// @title POC: Blacklisted entitlement recipients permanently locked
/// @notice Demonstrates that when a stakeToken has blacklist functionality (like USDC),
/// a blacklisted staker or good-faith attacker cannot route their entitlement to an
/// alternate recipient, leaving funds locked until a later sweep path exists.
contract BlacklistTokenStakerLockupPoC is Test {
uint256 internal constant ONE = 1e18;
uint256 internal constant BASE_TIMESTAMP = 1_750_000_000;
address internal constant SCOPE_ACCOUNT = address(0xC0FFEE);
BlacklistableERC20 internal token;
MockAttackRegistry internal attackRegistry;
MockSafeHarborRegistry internal safeHarborRegistry;
MockAgreement internal agreementContract;
ConfidencePool internal pool;
address internal moderator = makeAddr("moderator");
address internal recovery = makeAddr("recovery");
address internal alice = makeAddr("alice"); // will be blacklisted
address internal bob = makeAddr("bob"); // normal staker
address internal attacker = makeAddr("attacker"); // will be blacklisted
function setUp() public {
vm.warp(BASE_TIMESTAMP);
token = new BlacklistableERC20();
attackRegistry = new MockAttackRegistry();
safeHarborRegistry = new MockSafeHarborRegistry();
agreementContract = new MockAgreement(address(this));
agreementContract.setContractInScope(SCOPE_ACCOUNT, true);
safeHarborRegistry.setAttackRegistry(address(attackRegistry));
safeHarborRegistry.setAgreementValid(address(agreementContract), true);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
ConfidencePool implementation = new ConfidencePool();
pool = ConfidencePool(Clones.clone(address(implementation)));
address[] memory scope = new address[](1);
scope[0] = SCOPE_ACCOUNT;
pool.initialize(
address(agreementContract),
address(token),
address(safeHarborRegistry),
moderator,
block.timestamp + 31 days,
ONE,
recovery,
address(this),
scope
);
}
function testBlacklistedStakerFundsLocked() public {
// --- Setup: Alice and Bob both stake ---
uint256 aliceStake = 100 * ONE;
uint256 bobStake = 50 * ONE;
_stake(alice, aliceStake);
_stake(bob, bobStake);
// Sponsor contributes bonus
token.mint(address(this), 30 * ONE);
token.approve(address(pool), 30 * ONE);
pool.contributeBonus(30 * ONE);
// --- Move through risk window and resolve as SURVIVED ---
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
vm.warp(block.timestamp + 7 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
// --- Alice gets blacklisted by the token issuer (e.g., OFAC sanctions) ---
token.blacklist(alice);
// --- Bob can claim normally ---
vm.prank(bob);
pool.claimSurvived();
assertGt(token.balanceOf(bob), bobStake, "Bob received stake + bonus");
// --- Alice CANNOT claim -- transfer reverts ---
vm.prank(alice);
vm.expectRevert("recipient blacklisted");
pool.claimSurvived();
// --- Alice's funds are stuck: eligibleStake is still non-zero ---
assertEq(pool.eligibleStake(alice), aliceStake, "Alice's stake is still recorded");
assertEq(pool.hasClaimed(alice), false, "Alice has not claimed");
// --- sweepUnclaimedBonus reserves Alice's share, locking it permanently ---
uint256 poolBalance = token.balanceOf(address(pool));
assertGt(poolBalance, 0, "Pool still holds Alice's funds");
// The sweep will reserve Alice's principal + bonus share,
// so only dust (if any) is sweepable
uint256 totalEligible = pool.totalEligibleStake();
assertEq(totalEligible, aliceStake, "Alice's stake inflates totalEligibleStake");
// Attempt sweep -- should revert with NothingToSweep because all remaining
// balance is reserved for Alice's unclaimed entitlement
vm.expectRevert(abi.encodeWithSignature("NothingToSweep()"));
pool.sweepUnclaimedBonus();
// Alice's funds are permanently locked with no recovery mechanism
assertEq(token.balanceOf(alice), 0, "Alice received nothing");
assertGt(token.balanceOf(address(pool)), aliceStake, "Pool holds Alice's principal + bonus forever");
}
function testBlacklistedGoodFaithAttackerBountyLocked() public {
uint256 stakeAmount = 100 * ONE;
uint256 bonusAmount = 25 * ONE;
_stake(bob, stakeAmount);
token.mint(address(this), bonusAmount);
token.approve(address(pool), bonusAmount);
pool.contributeBonus(bonusAmount);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
uint256 entitlement = stakeAmount + bonusAmount;
assertEq(pool.bountyEntitlement(), entitlement, "Full pool is owed to attacker");
token.blacklist(attacker);
vm.prank(attacker);
vm.expectRevert("recipient blacklisted");
pool.claimAttackerBounty();
assertEq(pool.bountyClaimed(), 0, "No bounty was claimed");
assertEq(token.balanceOf(attacker), 0, "Attacker received nothing");
assertEq(token.balanceOf(address(pool)), entitlement, "Pool still holds the full bounty");
}
function _stake(address user, uint256 amount) internal {
token.mint(user, amount);
vm.startPrank(user);
token.approve(address(pool), amount);
pool.stake(amount);
vm.stopPrank();
}
}

Recommended Mitigation

Add alternate-recipient exit functions so the entitlement owner can route withdrawals, claims, and good-faith bounties to a non-blacklisted address.

+ function claimSurvivedTo(address recipient) external nonReentrant {
+ if (recipient == address(0)) revert ZeroAddress();
+ _claimSurvivedTo(msg.sender, recipient);
+ }
+
- function claimSurvived() external nonReentrant {
+ function claimSurvived() external nonReentrant {
+ _claimSurvivedTo(msg.sender, msg.sender);
+ }
+
+ function _claimSurvivedTo(address account, address recipient) internal {
if (outcome != PoolStates.Outcome.SURVIVED) revert OutcomeNotSet();
- if (hasClaimed[msg.sender]) revert InvalidAmount();
+ if (hasClaimed[account]) revert InvalidAmount();
...
- stakeToken.safeTransfer(msg.sender, payout);
+ stakeToken.safeTransfer(recipient, payout);
}

Apply the same pattern to withdraw(), claimExpired(), and claimAttackerBounty().

Support

FAQs

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

Give us feedback!