Root + Impact
Description
Survivor/expiry claims always transfer to msg.sender, while the bounty always transfers to the stored attacker. A beneficiary rejected by the token cannot nominate a compatible recipient, so its sole payout path reverts and the corresponding principal, bonus, or bounty remains inaccessible.
Claim authorization and payment destination are coupled. This is convenient for standard ERC20s, but prevents a legitimate beneficiary from redirecting payment after its address becomes blacklisted, after a token adds receiver callbacks, or when the recorded attacker is a contract/sink that cannot safely receive the token.
SafeERC20 correctly propagates transfer failure and the EVM rolls all preceding claim accounting back. That avoids marking a failed claim as paid, but it also means every retry follows the same hardcoded recipient and fails identically. No claimTo, authorized recipient mapping, or signature-based delegated claim exists.
src/ConfidencePool.sol
function claimSurvived() external nonReentrant {
if (outcome != PoolStates.Outcome.SURVIVED) revert OutcomeNotSet();
if (hasClaimed[msg.sender]) revert InvalidAmount();
uint256 userEligible = eligibleStake[msg.sender];
if (userEligible == 0) revert InvalidAmount();
if (!claimsStarted) claimsStarted = true;
stakeToken.safeTransfer(msg.sender, payout);
emit ClaimSurvived(msg.sender, userEligible, bonusShare);
}
function claimAttackerBounty() external nonReentrant {
if (outcome != PoolStates.Outcome.CORRUPTED) revert OutcomeNotSet();
if (bountyClaimed == bountyEntitlement) revert BountyAlreadyClaimed();
if (!goodFaith) revert InvalidGoodFaithParams();
if (msg.sender != attacker) revert NotAttacker();
if (block.timestamp > corruptedClaimDeadline) revert ClaimWindowExpired();
if (payout > 0) {
corruptedReserve -= payout;
if (!claimsStarted) claimsStarted = true;
stakeToken.safeTransfer(attacker, payout);
}
emit AttackerBountyClaimed(attacker, payout, newBountyClaimed, bountyEntitlement);
}
function claimExpired() external nonReentrant {
if (block.timestamp < expiry) revert PoolNotExpired();
if (!claimsStarted) claimsStarted = true;
stakeToken.safeTransfer(msg.sender, payout);
if (outcome == PoolStates.Outcome.SURVIVED) {
emit ClaimSurvived(msg.sender, userEligible, bonusShare);
} else {
emit ClaimExpired(msg.sender, userEligible, bonusShare);
}
}
For a survivor, the failed claim leaves eligibleStake intact, and sweepUnclaimedBonus() continues reserving that principal plus its bonus entitlement. There is no claim timeout that releases or redirects the reserved amount, so a persistent recipient restriction can lock the individual entitlement permanently. A blocked good-faith attacker cannot advance bountyClaimed; recovery is also blocked by MustClaimBountyFirst until the 180-day fallback sweep becomes available.
Risk
Likelihood:
-
The issue requires an allowlisted token with blacklist/callback recipient restrictions, a later token upgrade that adds them, or a moderator mistake when recording the attacker.
-
Standard immutable ERC20 tokens transfer to arbitrary nonzero addresses without calling the recipient, so the condition is outside the factory's stated happy-path assumptions.
Impact:
-
A rejected staker can permanently lose access to its complete principal and bonus while that balance remains reserved and unsweepable inside the pool.
-
A rejected attacker cannot claim its bounty and delays recovery until the claim deadline; a sink recipient may instead accept and irrecoverably trap the payment.
Proof of Concept
Create test/audit/CP015HardcodedPayoutRecipient.t.sol with the following contents:
pragma solidity 0.8.26;
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract CP015BlacklistERC20 is ERC20 {
error RecipientBlacklisted();
mapping(address => bool) public blacklisted;
constructor() ERC20("Blacklist Token", "BLT") {}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
function setBlacklisted(address account, bool blocked) external {
blacklisted[account] = blocked;
}
function _update(address from, address to, uint256 amount) internal override {
if (blacklisted[to]) revert RecipientBlacklisted();
super._update(from, to, amount);
}
}
contract CP015HardcodedPayoutRecipientTest is BaseConfidencePoolTest {
function test_BlacklistedStakerCannotRedirectPrincipalOrBonus() external {
CP015BlacklistERC20 blacklistToken = new CP015BlacklistERC20();
ConfidencePool blacklistPool = _deployPoolWithToken(address(blacklistToken));
_stakeWithToken(blacklistPool, blacklistToken, alice, 100 * ONE);
_contributeWithToken(blacklistPool, blacklistToken, carol, 50 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
blacklistPool.pokeRiskWindow();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
blacklistPool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
blacklistToken.setBlacklisted(alice, true);
vm.prank(alice);
vm.expectRevert(CP015BlacklistERC20.RecipientBlacklisted.selector);
blacklistPool.claimSurvived();
assertFalse(blacklistPool.hasClaimed(alice));
assertEq(blacklistPool.eligibleStake(alice), 100 * ONE);
assertEq(blacklistToken.balanceOf(address(blacklistPool)), 150 * ONE);
vm.expectRevert(IConfidencePool.NothingToSweep.selector);
blacklistPool.sweepUnclaimedBonus();
}
function test_BlacklistedAttackerCannotRedirectBounty() external {
CP015BlacklistERC20 blacklistToken = new CP015BlacklistERC20();
ConfidencePool blacklistPool = _deployPoolWithToken(address(blacklistToken));
_stakeWithToken(blacklistPool, blacklistToken, alice, 100 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
blacklistPool.pokeRiskWindow();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
blacklistPool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
blacklistToken.setBlacklisted(attacker, true);
vm.prank(attacker);
vm.expectRevert(CP015BlacklistERC20.RecipientBlacklisted.selector);
blacklistPool.claimAttackerBounty();
assertEq(blacklistPool.bountyClaimed(), 0);
assertEq(blacklistToken.balanceOf(address(blacklistPool)), 100 * ONE);
vm.expectRevert(IConfidencePool.MustClaimBountyFirst.selector);
blacklistPool.claimCorrupted();
}
function _deployPoolWithToken(address tokenAddress) internal returns (ConfidencePool deployedPool) {
ConfidencePool implementation = new ConfidencePool();
deployedPool = ConfidencePool(Clones.clone(address(implementation)));
deployedPool.initialize(
agreement,
tokenAddress,
address(safeHarborRegistry),
moderator,
block.timestamp + 31 days,
ONE,
recovery,
address(this),
_defaultScope()
);
}
function _stakeWithToken(ConfidencePool target, CP015BlacklistERC20 targetToken, address user, uint256 amount)
internal
{
targetToken.mint(user, amount);
vm.startPrank(user);
targetToken.approve(address(target), amount);
target.stake(amount);
vm.stopPrank();
}
function _contributeWithToken(ConfidencePool target, CP015BlacklistERC20 targetToken, address user, uint256 amount)
internal
{
targetToken.mint(user, amount);
vm.startPrank(user);
targetToken.approve(address(target), amount);
target.contributeBonus(amount);
vm.stopPrank();
}
}
Run the PoC from the repository root:
-
Execute forge test --offline --match-path test/audit/CP015HardcodedPayoutRecipient.t.sol -vv.
-
Confirm that both tests pass: the blocked staker cannot claim or free its reserved balance, and the blocked attacker cannot advance its bounty or allow the pre-deadline corrupted recovery.
Recommended Mitigation
Decouple authorization from payment destination. The authenticated staker or attacker should be able to nominate a nonzero recipient, while all entitlement and double-claim accounting remains keyed to the beneficiary.
src/ConfidencePool.sol
-function claimSurvived() external nonReentrant {
+function claimSurvived(address recipient) external nonReentrant {
if (outcome != PoolStates.Outcome.SURVIVED) revert OutcomeNotSet();
if (hasClaimed[msg.sender]) revert InvalidAmount();
+ if (recipient == address(0) || recipient == address(this) || recipient == address(stakeToken)) {
+ revert InvalidPayoutRecipient();
+ }
uint256 userEligible = eligibleStake[msg.sender];
if (userEligible == 0) revert InvalidAmount();
// ... bonus and claim accounting unchanged ...
if (!claimsStarted) claimsStarted = true;
- stakeToken.safeTransfer(msg.sender, payout);
+ stakeToken.safeTransfer(recipient, payout);
emit ClaimSurvived(msg.sender, userEligible, bonusShare);
}
-function claimAttackerBounty() external nonReentrant {
+function claimAttackerBounty(address recipient) external nonReentrant {
if (outcome != PoolStates.Outcome.CORRUPTED) revert OutcomeNotSet();
if (bountyClaimed == bountyEntitlement) revert BountyAlreadyClaimed();
if (!goodFaith) revert InvalidGoodFaithParams();
if (msg.sender != attacker) revert NotAttacker();
+ if (recipient == address(0) || recipient == address(this) || recipient == address(stakeToken)) {
+ revert InvalidPayoutRecipient();
+ }
if (block.timestamp > corruptedClaimDeadline) revert ClaimWindowExpired();
// ... payout sizing and accounting unchanged ...
if (payout > 0) {
corruptedReserve -= payout;
if (!claimsStarted) claimsStarted = true;
- stakeToken.safeTransfer(attacker, payout);
+ stakeToken.safeTransfer(recipient, payout);
}
emit AttackerBountyClaimed(attacker, payout, newBountyClaimed, bountyEntitlement);
}
Apply the same recipient parameter to claimExpired(). For beneficiaries that cannot submit transactions themselves, add an EIP-712 authorization permitting a relayer to supply the recipient, with nonce and deadline protection.