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

Blacklisted Stakers' Indefinitely Lock Pool Reserves in the Pool Contract For USDC-like tokens.

Author Revealed upon completion

Description

The factory permits its owner to allowlist any nominally standard ERC20 stake token. Standard ERC20 tokens with issuer-controlled address blacklists, such as USDC-like assets, remain compatible with the documented exclusion of only fee-on-transfer and rebasing tokens.

Both claimSurvived() and the payout branch of claimExpired() send the entire payout directly to msg.sender. If the stake-token issuer blacklists a staker after they deposit, the token transfer to that staker reverts. This reverts all accounting changes made earlier in the claim, leaving hasClaimed[staker] == false, their eligibleStake intact, and their weighted share absent from claimedBonus.

There is no forfeiture functionality that allows the recovery address to later rescuee those funds. There is also no deadline for SURVIVED or EXPIRED claims. sweepUnclaimedBonus() continues to reserve totalEligibleStake and, when a risk window was observed, the entire snapshotTotalBonus - claimedBonus. Consequently, the blacklisted position remains reserved indefinitely unless the token issuer removes the blacklist.

Impact

A staker blacklisted after depositing causes the pool reserves/bonus to stay locked in the pool permanently.

Root Cause

claimSurvived() and claimExpired() hard-code msg.sender as the payout recipient, while sweepUnclaimedBonus() treats every unclaimed position as permanently payable and provides no timeout or forfeiture path while the allowlisted token rejects that recipient.

PoC

Run" forge test --mt testPoC_BlacklistedClaimSurvivedLeavesPrincipalAndBonusIndefinitelyReserved
Create a folder PoC inside test folder and Paste the following:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract MockBlacklistableUSDC is ERC20 {
error Blacklisted(address account);
mapping(address account => bool blocked) public isBlacklisted;
constructor() ERC20("Mock USDC", "USDC") {}
function decimals() public pure override returns (uint8) {
return 6;
}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
function setBlacklisted(address account, bool blocked) external {
isBlacklisted[account] = blocked;
}
function _update(address from, address to, uint256 amount) internal override {
if (from != address(0) && isBlacklisted[from]) revert Blacklisted(from);
if (to != address(0) && isBlacklisted[to]) revert Blacklisted(to);
super._update(from, to, amount);
}
}
contract BlacklistedStakerClaimPoC is BaseConfidencePoolTest {
uint256 internal constant USDC = 1e6;
// blacklistable stake tokens indefinitely reserve failed staker claims.
function testPoC_BlacklistedClaimSurvivedLeavesPrincipalAndBonusIndefinitelyReserved() external {
(MockBlacklistableUSDC usdc, ConfidencePool usdcPool) = _deployUSDCpool();
_stakeUSDC(usdc, usdcPool, alice, 100 * USDC);
_stakeUSDC(usdc, usdcPool, bob, 100 * USDC);
_contributeUSDC(usdc, usdcPool, carol, 60 * USDC);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
usdcPool.pokeRiskWindow();
vm.warp(block.timestamp + 10 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
usdcPool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
usdc.setBlacklisted(alice, true);
vm.expectRevert(abi.encodeWithSelector(MockBlacklistableUSDC.Blacklisted.selector, alice));
vm.prank(alice);
usdcPool.claimSurvived();
// Other stakers remain able to claim; Alice's half of the bonus stays behind.
vm.prank(bob);
usdcPool.claimSurvived();
assertEq(usdc.balanceOf(bob), 130 * USDC);
assertEq(usdcPool.totalEligibleStake(), 100 * USDC);
assertEq(usdcPool.claimedBonus(), 30 * USDC);
assertEq(usdc.balanceOf(address(usdcPool)), 130 * USDC);
// The sweep reserves Alice's full
// principal plus every remaining unclaimed bonus token indefinitely.
vm.warp(block.timestamp + 10 * 365 days);
vm.expectRevert(IConfidencePool.NothingToSweep.selector);
usdcPool.sweepUnclaimedBonus();
assertEq(usdc.balanceOf(address(usdcPool)), 130 * USDC);
}
function _deployUSDCpool() internal returns (MockBlacklistableUSDC usdc, ConfidencePool usdcPool) {
usdc = new MockBlacklistableUSDC();
ConfidencePool implementation = new ConfidencePool();
usdcPool = ConfidencePool(Clones.clone(address(implementation)));
usdcPool.initialize(
agreement,
address(usdc),
address(safeHarborRegistry),
moderator,
block.timestamp + 31 days,
USDC,
recovery,
address(this),
_defaultScope()
);
}
function _stakeUSDC(MockBlacklistableUSDC usdc, ConfidencePool usdcPool, address user, uint256 amount) internal {
usdc.mint(user, amount);
vm.startPrank(user);
usdc.approve(address(usdcPool), amount);
usdcPool.stake(amount);
vm.stopPrank();
}
function _contributeUSDC(MockBlacklistableUSDC usdc, ConfidencePool usdcPool, address user, uint256 amount)
internal
{
usdc.mint(user, amount);
vm.startPrank(user);
usdc.approve(address(usdcPool), amount);
usdcPool.contributeBonus(amount);
vm.stopPrank();
}
}

Recommended Mitigation

Implement a deadline for claims after which the Protocol is allowed to sweep unclaimed reserves. Or a functionality to forfeit positions for token-blacklisted stakers.

Support

FAQs

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

Give us feedback!