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

Blacklisted Staker Principal Permanently Locked Due to Hardcoded msg.sender Transfer Recipient in claimSurvived/claimExpired

Author Revealed upon completion

Root + Impact

Description

claimSurvived and claimExpired both hardcode msg.sender as the transfer recipient. A staker calls the function, all accounting state is updated, and then safeTransfer(msg.sender, payout) fires at the very end. If the token reverts on that transfer — because the staker's address is blacklisted by the token issuer (USDC/USDT both have on-chain blacklists) — the entire transaction rolls back atomically. Every state update is undone. The staker is stuck: they can call claimSurvived forever and it will always revert at the same line. There is no alternative entry point, no admin rescue, and no way to redirect the payout to a different address.

Because the rollback leaves eligibleStake[staker] non-zero and totalEligibleStake unchanged, sweepUnclaimedBonus correctly reserves those funds and will always revert with NothingToSweep. The principal and bonus share are permanently locked in the contract.

function claimSurvived() external nonReentrant {
// ...
hasClaimed[msg.sender] = true; // state written
totalEligibleStake -= userEligible; // state written
claimedBonus += bonusShare; // state written
delete eligibleStake[msg.sender]; // state written
// ...
// @> if this reverts, every line above rolls back — staker loops forever
stakeToken.safeTransfer(msg.sender, payout);
}

Same pattern exists in claimExpired:

And sweepUnclaimedBonus will always protect the stuck funds because totalEligibleStake is never decremented:

Risk

Likelihood:

  • USDC and USDT both maintain on-chain blacklists. If either is on the factory allowlist (common in DeFi protocols), any staker who gets sanctioned by OFAC or flagged by the token issuer after staking hits this permanently.

  • Blacklisting events happen in the real world — exchange compliance actions, OFAC sanctions, and court-ordered freezes all result in addresses being blacklisted on live tokens. A staker has no control over this after they deposit.

Impact:

  • The staker permanently loses their entire principal plus their pro-rata bonus share. There is no recovery path — not for the staker, not for the pool owner, not for anyone.

  • The locked funds inflate totalEligibleStake indefinitely, which means sweepUnclaimedBonus can never sweep anything while that staker is unclaimed. Other protocol accounting that depends on totalEligibleStake reaching zero is also affected.


Recommended Mitigation

Add a recipient parameter so a staker can redirect their payout to an address that can receive the token. This is the standard "pull with destination" pattern used by protocols that support tokens with blacklists.

- function claimSurvived() external nonReentrant {
+ function claimSurvived(address recipient) external nonReentrant {
+ if (recipient == address(0)) revert InvalidRecoveryAddress();
if (outcome != PoolStates.Outcome.SURVIVED) revert OutcomeNotSet();
if (hasClaimed[msg.sender]) revert InvalidAmount();
uint256 userEligible = eligibleStake[msg.sender];
if (userEligible == 0) revert InvalidAmount();
_clampUserSums(msg.sender);
hasClaimed[msg.sender] = true;
uint256 bonusShare = _bonusShare(msg.sender, userEligible);
uint256 payout = userEligible + bonusShare;
totalEligibleStake -= userEligible;
claimedBonus += bonusShare;
delete eligibleStake[msg.sender];
delete userSumStakeTime[msg.sender];
delete userSumStakeTimeSq[msg.sender];
if (!claimsStarted) claimsStarted = true;
- stakeToken.safeTransfer(msg.sender, payout);
- emit ClaimSurvived(msg.sender, userEligible, bonusShare);
+ stakeToken.safeTransfer(recipient, payout);
+ emit ClaimSurvived(msg.sender, userEligible, bonusShare);
}

Apply the same change to claimExpired. Callers who are not blacklisted just pass msg.sender as the recipient — no behavior change for the normal path.

efccweb3@EFCCWEB3:~/2026-07-bc-confidence-pools$ forge test --match-test testBlacklistedStakerPrincipalPermanentlyStuck -vvvv
[⠊] Compiling...
[⠑] Compiling 1 files with Solc 0.8.26
[⠃] Solc 0.8.26 finished in 20.67s
Compiler run successful!
Ran 1 test for test/unit/ConfidencePool.blacklist.t.sol:USDCBlacklistStuckPrincipalTest
[PASS] testBlacklistedStakerPrincipalPermanentlyStuck() (gas: 614085)
Traces:
[753385] USDCBlacklistStuckPrincipalTest::testBlacklistedStakerPrincipalPermanentlyStuck()
├─ [48648] BlacklistableERC20::mint(alice: [0x328809Bc894f92807417D2dAD6b7C998c1aFdac6], 100000000000000000000 [1e20])
│ ├─ emit Transfer(from: 0x0000000000000000000000000000000000000000, to: alice: [0x328809Bc894f92807417D2dAD6b7C998c1aFdac6], amount: 100000000000000000000 [1e20])
│ └─ ← [Stop]
├─ [0] VM::startPrank(alice: [0x328809Bc894f92807417D2dAD6b7C998c1aFdac6])
│ └─ ← [Return]
├─ [24325] BlacklistableERC20::approve(0xa0Cb889707d426A7A386870A03bc70d1b0697598, 100000000000000000000 [1e20])
│ ├─ emit Approval(owner: alice: [0x328809Bc894f92807417D2dAD6b7C998c1aFdac6], spender: 0xa0Cb889707d426A7A386870A03bc70d1b0697598, amount: 100000000000000000000 [1e20])
│ └─ ← [Return] true
├─ [240244] 0xa0Cb889707d426A7A386870A03bc70d1b0697598::stake(100000000000000000000 [1e20])
│ ├─ [237575] ConfidencePool::stake(100000000000000000000 [1e20]) [delegatecall]
│ │ ├─ [2323] MockSafeHarborRegistry::getAttackRegistry() [staticcall]
│ │ │ └─ ← [Return] MockAttackRegistry: [0x2e234DAe75C793f67A35089C9d99245E1C58470b]
│ │ ├─ [2374] MockAttackRegistry::getAgreementState(MockAgreement: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [staticcall]
│ │ │ └─ ← [Return] 1
│ │ ├─ [2537] BlacklistableERC20::balanceOf(0xa0Cb889707d426A7A386870A03bc70d1b0697598) [staticcall]
│ │ │ └─ ← [Return] 0
│ │ ├─ [25606] BlacklistableERC20::transferFrom(alice: [0x328809Bc894f92807417D2dAD6b7C998c1aFdac6], 0xa0Cb889707d426A7A386870A03bc70d1b0697598, 100000000000000000000 [1e20])
│ │ │ ├─ emit Transfer(from: alice: [0x328809Bc894f92807417D2dAD6b7C998c1aFdac6], to: 0xa0Cb889707d426A7A386870A03bc70d1b0697598, amount: 100000000000000000000 [1e20])
│ │ │ └─ ← [Return] true
│ │ ├─ [537] BlacklistableERC20::balanceOf(0xa0Cb889707d426A7A386870A03bc70d1b0697598) [staticcall]
│ │ │ └─ ← [Return] 100000000000000000000 [1e20]
│ │ ├─ emit Staked(staker: alice: [0x328809Bc894f92807417D2dAD6b7C998c1aFdac6], amount: 100000000000000000000 [1e20])
│ │ └─ ← [Stop]
│ └─ ← [Return]
├─ [0] VM::stopPrank()
│ └─ ← [Return]
├─ [26748] BlacklistableERC20::mint(bob: [0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e], 100000000000000000000 [1e20])
│ ├─ emit Transfer(from: 0x0000000000000000000000000000000000000000, to: bob: [0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e], amount: 100000000000000000000 [1e20])
│ └─ ← [Stop]
├─ [0] VM::startPrank(bob: [0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e])
│ └─ ← [Return]
├─ [24325] BlacklistableERC20::approve(0xa0Cb889707d426A7A386870A03bc70d1b0697598, 100000000000000000000 [1e20])
│ ├─ emit Approval(owner: bob: [0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e], spender: 0xa0Cb889707d426A7A386870A03bc70d1b0697598, amount: 100000000000000000000 [1e20])
│ └─ ← [Return] true
├─ [81199] 0xa0Cb889707d426A7A386870A03bc70d1b0697598::stake(100000000000000000000 [1e20])
│ ├─ [81030] ConfidencePool::stake(100000000000000000000 [1e20]) [delegatecall]
│ │ ├─ [323] MockSafeHarborRegistry::getAttackRegistry() [staticcall]
│ │ │ └─ ← [Return] MockAttackRegistry: [0x2e234DAe75C793f67A35089C9d99245E1C58470b]
│ │ ├─ [374] MockAttackRegistry::getAgreementState(MockAgreement: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [staticcall]
│ │ │ └─ ← [Return] 1
│ │ ├─ [537] BlacklistableERC20::balanceOf(0xa0Cb889707d426A7A386870A03bc70d1b0697598) [staticcall]
│ │ │ └─ ← [Return] 100000000000000000000 [1e20]
│ │ ├─ [3706] BlacklistableERC20::transferFrom(bob: [0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e], 0xa0Cb889707d426A7A386870A03bc70d1b0697598, 100000000000000000000 [1e20])
│ │ │ ├─ emit Transfer(from: bob: [0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e], to: 0xa0Cb889707d426A7A386870A03bc70d1b0697598, amount: 100000000000000000000 [1e20])
│ │ │ └─ ← [Return] true
│ │ ├─ [537] BlacklistableERC20::balanceOf(0xa0Cb889707d426A7A386870A03bc70d1b0697598) [staticcall]
│ │ │ └─ ← [Return] 200000000000000000000 [2e20]
│ │ ├─ emit Staked(staker: bob: [0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e], amount: 100000000000000000000 [1e20])
│ │ └─ ← [Stop]
│ └─ ← [Return]
├─ [0] VM::stopPrank()
│ └─ ← [Return]
├─ [3254] MockAttackRegistry::setAgreementState(5)
│ └─ ← [Return]
├─ [0] VM::prank(moderator: [0x62853092D02FA98524B9618334F605a1801432cA])
│ └─ ← [Return]
├─ [130808] 0xa0Cb889707d426A7A386870A03bc70d1b0697598::flagOutcome(1, false, 0x0000000000000000000000000000000000000000)
│ ├─ [130627] ConfidencePool::flagOutcome(1, false, 0x0000000000000000000000000000000000000000) [delegatecall]
│ │ ├─ [323] MockSafeHarborRegistry::getAttackRegistry() [staticcall]
│ │ │ └─ ← [Return] MockAttackRegistry: [0x2e234DAe75C793f67A35089C9d99245E1C58470b]
│ │ ├─ [374] MockAttackRegistry::getAgreementState(MockAgreement: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [staticcall]
│ │ │ └─ ← [Return] 5
│ │ ├─ emit ScopeLocked(timestamp: 1750000000 [1.75e9])
│ │ ├─ emit RiskWindowEnded(timestamp: 1750000000 [1.75e9])
│ │ ├─ emit OutcomeFlagged(moderator: moderator: [0x62853092D02FA98524B9618334F605a1801432cA], outcome: 1, goodFaith: false, attacker: 0x0000000000000000000000000000000000000000)
│ │ └─ ← [Stop]
│ └─ ← [Return]
├─ [632] 0xa0Cb889707d426A7A386870A03bc70d1b0697598::outcome() [staticcall]
│ ├─ [466] ConfidencePool::outcome() [delegatecall]
│ │ └─ ← [Return] 1
│ └─ ← [Return] 1
├─ [1426] 0xa0Cb889707d426A7A386870A03bc70d1b0697598::totalEligibleStake() [staticcall]
│ ├─ [1260] ConfidencePool::totalEligibleStake() [delegatecall]
│ │ └─ ← [Return] 200000000000000000000 [2e20]
│ └─ ← [Return] 200000000000000000000 [2e20]
├─ [20691] BlacklistableERC20::blacklist(alice: [0x328809Bc894f92807417D2dAD6b7C998c1aFdac6])
│ └─ ← [Stop]
├─ [679] BlacklistableERC20::isBlacklisted(alice: [0x328809Bc894f92807417D2dAD6b7C998c1aFdac6]) [staticcall]
│ └─ ← [Return] true
├─ [0] VM::prank(alice: [0x328809Bc894f92807417D2dAD6b7C998c1aFdac6])
│ └─ ← [Return]
├─ [0] VM::expectRevert(custom error 0xf4844814)
│ └─ ← [Return]
├─ [29592] 0xa0Cb889707d426A7A386870A03bc70d1b0697598::claimSurvived()
│ ├─ [29409] ConfidencePool::claimSurvived() [delegatecall]
│ │ ├─ [756] BlacklistableERC20::transfer(alice: [0x328809Bc894f92807417D2dAD6b7C998c1aFdac6], 100000000000000000000 [1e20])
│ │ │ └─ ← [Revert] USDC: account is blacklisted
│ │ └─ ← [Revert] USDC: account is blacklisted
│ └─ ← [Revert] USDC: account is blacklisted
├─ [1296] 0xa0Cb889707d426A7A386870A03bc70d1b0697598::eligibleStake(alice: [0x328809Bc894f92807417D2dAD6b7C998c1aFdac6]) [staticcall]
│ ├─ [1124] ConfidencePool::eligibleStake(alice: [0x328809Bc894f92807417D2dAD6b7C998c1aFdac6]) [delegatecall]
│ │ └─ ← [Return] 100000000000000000000 [1e20]
│ └─ ← [Return] 100000000000000000000 [1e20]
├─ [3242] 0xa0Cb889707d426A7A386870A03bc70d1b0697598::hasClaimed(alice: [0x328809Bc894f92807417D2dAD6b7C998c1aFdac6]) [staticcall]
│ ├─ [3070] ConfidencePool::hasClaimed(alice: [0x328809Bc894f92807417D2dAD6b7C998c1aFdac6]) [delegatecall]
│ │ └─ ← [Return] false
│ └─ ← [Return] false
├─ [1426] 0xa0Cb889707d426A7A386870A03bc70d1b0697598::totalEligibleStake() [staticcall]
│ ├─ [1260] ConfidencePool::totalEligibleStake() [delegatecall]
│ │ └─ ← [Return] 200000000000000000000 [2e20]
│ └─ ← [Return] 200000000000000000000 [2e20]
├─ [537] BlacklistableERC20::balanceOf(bob: [0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e]) [staticcall]
│ └─ ← [Return] 0
├─ [0] VM::prank(bob: [0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e])
│ └─ ← [Return]
├─ [53611] 0xa0Cb889707d426A7A386870A03bc70d1b0697598::claimSurvived()
│ ├─ [53448] ConfidencePool::claimSurvived() [delegatecall]
│ │ ├─ [23013] BlacklistableERC20::transfer(bob: [0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e], 100000000000000000000 [1e20])
│ │ │ ├─ emit Transfer(from: 0xa0Cb889707d426A7A386870A03bc70d1b0697598, to: bob: [0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e], amount: 100000000000000000000 [1e20])
│ │ │ └─ ← [Return] true
│ │ ├─ emit ClaimSurvived(staker: bob: [0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e], principal: 100000000000000000000 [1e20], bonusShare: 0)
│ │ └─ ← [Stop]
│ └─ ← [Return]
├─ [537] BlacklistableERC20::balanceOf(bob: [0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e]) [staticcall]
│ └─ ← [Return] 100000000000000000000 [1e20]
├─ [1426] 0xa0Cb889707d426A7A386870A03bc70d1b0697598::totalEligibleStake() [staticcall]
│ ├─ [1260] ConfidencePool::totalEligibleStake() [delegatecall]
│ │ └─ ← [Return] 100000000000000000000 [1e20]
│ └─ ← [Return] 100000000000000000000 [1e20]
├─ [537] BlacklistableERC20::balanceOf(0xa0Cb889707d426A7A386870A03bc70d1b0697598) [staticcall]
│ └─ ← [Return] 100000000000000000000 [1e20]
├─ [0] VM::expectRevert(NothingToSweep())
│ └─ ← [Return]
├─ [2179] 0xa0Cb889707d426A7A386870A03bc70d1b0697598::sweepUnclaimedBonus()
│ ├─ [2014] ConfidencePool::sweepUnclaimedBonus() [delegatecall]
│ │ ├─ [537] BlacklistableERC20::balanceOf(0xa0Cb889707d426A7A386870A03bc70d1b0697598) [staticcall]
│ │ │ └─ ← [Return] 100000000000000000000 [1e20]
│ │ └─ ← [Revert] NothingToSweep()
│ └─ ← [Revert] NothingToSweep()
├─ [0] VM::addr(<pk>) [staticcall]
│ └─ ← [Return] newRecovery: [0xf384aB531188266F3308d37E3989959b29B289dc]
├─ [0] VM::label(newRecovery: [0xf384aB531188266F3308d37E3989959b29B289dc], "newRecovery")
│ └─ ← [Return]
├─ [9483] 0xa0Cb889707d426A7A386870A03bc70d1b0697598::setRecoveryAddress(newRecovery: [0xf384aB531188266F3308d37E3989959b29B289dc])
│ ├─ [9314] ConfidencePool::setRecoveryAddress(newRecovery: [0xf384aB531188266F3308d37E3989959b29B289dc]) [delegatecall]
│ │ ├─ emit RecoveryAddressUpdated(oldAddr: recovery: [0x73030B99950fB19C6A813465E58A0BcA5487FBEa], newAddr: newRecovery: [0xf384aB531188266F3308d37E3989959b29B289dc])
│ │ └─ ← [Stop]
│ └─ ← [Return]
├─ [0] VM::expectRevert(NothingToSweep())
│ └─ ← [Return]
├─ [2179] 0xa0Cb889707d426A7A386870A03bc70d1b0697598::sweepUnclaimedBonus()
│ ├─ [2014] ConfidencePool::sweepUnclaimedBonus() [delegatecall]
│ │ ├─ [537] BlacklistableERC20::balanceOf(0xa0Cb889707d426A7A386870A03bc70d1b0697598) [staticcall]
│ │ │ └─ ← [Return] 100000000000000000000 [1e20]
│ │ └─ ← [Revert] NothingToSweep()
│ └─ ← [Revert] NothingToSweep()
├─ [537] BlacklistableERC20::balanceOf(0xa0Cb889707d426A7A386870A03bc70d1b0697598) [staticcall]
│ └─ ← [Return] 100000000000000000000 [1e20]
├─ [537] BlacklistableERC20::balanceOf(alice: [0x328809Bc894f92807417D2dAD6b7C998c1aFdac6]) [staticcall]
│ └─ ← [Return] 0
└─ ← [Return]
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 14.97ms (7.84ms CPU time)
pragma solidity 0.8.26;
import {Test} 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 {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
// Reuse existing test doubles for external BattleChain contracts (not in-scope).
import {MockAttackRegistry} from "test/mocks/MockAttackRegistry.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
import {MockAgreement} from "test/mocks/MockAgreement.sol";
// ---------------------------------------------------------------------------
// BlacklistableERC20 — simulates USDC's blacklist mechanism.
// Transfers TO a blacklisted address revert, exactly as USDC does on-chain.
// This is NOT a replacement for any in-scope contract; it is the stake token
// (an external dependency) configured to exhibit USDC-like behaviour.
// ---------------------------------------------------------------------------
contract BlacklistableERC20 is ERC20 {
mapping(address => bool) public isBlacklisted;
address public immutable blacklister;
constructor() ERC20("USD Coin", "USDC") {
blacklister = msg.sender;
}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
function blacklist(address account) external {
require(msg.sender == blacklister, "not blacklister");
isBlacklisted[account] = true;
}
// OZ v5 hook — called by every transfer/mint/burn.
// Reverts when the recipient is blacklisted, mirroring USDC behaviour.
function _update(address from, address to, uint256 value) internal override {
require(!isBlacklisted[to], "USDC: account is blacklisted");
super._update(from, to, value);
}
}
// ---------------------------------------------------------------------------
// PoC test
// ---------------------------------------------------------------------------
contract USDCBlacklistStuckPrincipalTest is Test {
uint256 internal constant ONE = 1e18;
// Realistic base timestamp (~2025-06-15) matching the rest of the suite.
uint256 internal constant BASE_TIMESTAMP = 1_750_000_000;
address internal constant DEFAULT_SCOPE_ACCOUNT = address(0xC0FFEE);
BlacklistableERC20 internal usdc;
MockAttackRegistry internal attackRegistry;
MockSafeHarborRegistry internal safeHarborRegistry;
MockAgreement internal agreementContract;
ConfidencePool internal pool;
address internal agreement;
address internal moderator = makeAddr("moderator");
address internal recovery = makeAddr("recovery");
address internal alice = makeAddr("alice"); // victim staker
address internal bob = makeAddr("bob"); // normal staker
address internal carol = makeAddr("carol"); // bonus contributor
function setUp() public {
vm.warp(BASE_TIMESTAMP);
usdc = new BlacklistableERC20();
attackRegistry = new MockAttackRegistry();
safeHarborRegistry = new MockSafeHarborRegistry();
agreementContract = new MockAgreement(address(this));
agreement = address(agreementContract);
agreementContract.setContractInScope(DEFAULT_SCOPE_ACCOUNT, true);
safeHarborRegistry.setAttackRegistry(address(attackRegistry));
safeHarborRegistry.setAgreementValid(agreement, true);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
// Deploy the real ConfidencePool implementation + clone — no mocks.
ConfidencePool implementation = new ConfidencePool();
pool = ConfidencePool(Clones.clone(address(implementation)));
address[] memory scope = new address[]();
scope[0] = DEFAULT_SCOPE_ACCOUNT;
pool.initialize(
agreement,
address(usdc), // <-- USDC-like token
address(safeHarborRegistry),
moderator,
block.timestamp + 31 days,
ONE,
recovery,
address(this), // owner (sponsor)
scope
);
}
// -----------------------------------------------------------------------
// PoC: USDC-blacklisted staker's principal is permanently stuck.
//
// Attack path (step by step):
// 1. Alice and Bob stake USDC into a live pool.
// 2. Pool resolves SURVIVED — no attack occurred.
// 3. USDC compliance blacklists Alice's address (e.g., OFAC sanction).
// 4. Alice calls claimSurvived() — reverts; safeTransfer to Alice fails.
// 5. Bob claims successfully.
// 6. sweepUnclaimedBonus() reverts NothingToSweep — Alice's principal
// is counted in totalEligibleStake and therefore reserved.
// 7. Admin changes recoveryAddress — still NothingToSweep.
// 8. Alice's 100 USDC is permanently locked with no on-chain recovery.
// -----------------------------------------------------------------------
function testBlacklistedStakerPrincipalPermanentlyStuck() external {
uint256 aliceStake = 100 * ONE;
uint256 bobStake = 100 * ONE;
uint256 bonusAmount = 50 * ONE;
// ── Step 1: Alice and Bob stake ──────────────────────────────────────
usdc.mint(alice, aliceStake);
vm.startPrank(alice);
usdc.approve(address(pool), aliceStake);
pool.stake(aliceStake);
vm.stopPrank();
usdc.mint(bob, bobStake);
vm.startPrank(bob);
usdc.approve(address(pool), bobStake);
pool.stake(bobStake);
vm.stopPrank();
usdc.mint(carol, bonusAmount);
vm.startPrank(carol);
usdc.approve(address(pool), bonusAmount);
pool.contributeBonus(bonusAmount);
vm.stopPrank();
// ── Step 2: Pool resolves SURVIVED ───────────────────────────────────
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.SURVIVED));
assertEq(pool.totalEligibleStake(), aliceStake + bobStake);
// ── Step 3: USDC blacklists Alice ────────────────────────────────────
usdc.blacklist(alice);
assertTrue(usdc.isBlacklisted(alice));
// ── Step 4: Alice tries to claim — REVERTS ───────────────────────────
// safeTransfer(alice, payout) reverts inside claimSurvived().
// The entire tx rolls back; Alice's state is unchanged.
vm.prank(alice);
vm.expectRevert(); // "USDC: account is blacklisted"
pool.claimSurvived();
// Confirm full rollback — nothing changed for Alice.
assertEq(pool.eligibleStake(alice), aliceStake,
"Alice eligibleStake unchanged after revert");
assertFalse(pool.hasClaimed(alice),
"Alice hasClaimed still false");
assertEq(pool.totalEligibleStake(), aliceStake + bobStake,
"totalEligibleStake unchanged — Alice's share still counted");
// ── Step 5: Bob claims successfully ──────────────────────────────────
uint256 bobBefore = usdc.balanceOf(bob);
vm.prank(bob);
pool.claimSurvived();
assertGt(usdc.balanceOf(bob) - bobBefore, bobStake,
"Bob received principal + bonus share");
// After Bob claims, only Alice's principal remains in the pool.
assertEq(pool.totalEligibleStake(), aliceStake,
"Only Alice's stake remains in totalEligibleStake");
assertEq(usdc.balanceOf(address(pool)), aliceStake,
"Pool balance equals Alice's stuck principal");
// ── Step 6: sweepUnclaimedBonus cannot touch Alice's principal ────────
// reserved = totalEligibleStake = 100 USDC
// freeBalance = 100 USDC
// amount = freeBalance - reserved = 0 → NothingToSweep
vm.expectRevert(IConfidencePool.NothingToSweep.selector);
pool.sweepUnclaimedBonus();
// ── Step 7: Admin changes recoveryAddress — still no recovery ─────────
pool.setRecoveryAddress(makeAddr("newRecovery"));
vm.expectRevert(IConfidencePool.NothingToSweep.selector);
pool.sweepUnclaimedBonus();
// ── Step 8: Funds are permanently stuck ──────────────────────────────
assertEq(usdc.balanceOf(address(pool)), aliceStake,
"CRITICAL: 100 USDC permanently locked in pool");
assertEq(usdc.balanceOf(alice), 0,
"Alice received nothing — principal irrecoverable");
}
}

Support

FAQs

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

Give us feedback!