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

Hardcoded Bounty Dependency Causes 180-Day Global Fund Deadlock

Author Revealed upon completion

Description

During a Good-Faith corrupted outcome, the designated attacker claims their bounty first, which unblocks the remaining pool funds for the global recovery sweep. The protocol strictly blocks the global recovery process until the attacker has successfully claimed their entire bounty entitlement. When using standard fiat-backed tokens like USDC or USDT, the attacker's address can be blacklisted by the centralized token issuer. This causes the attacker's claim transaction to revert permanently at the token contract level, rendering the bounty unclaimable and completely deadlocking all remaining pool funds for the 180-day grace period.

The attacker can actively and deliberately trigger this token-level blacklist by interacting with sanctioned entities specifically to hold the protocol hostage. The vulnerability spans across a complete function chain that neutralizes all administrative escape hatches and seals the deadlock.

First, the attacker manipulates the pool balance to be fractionally short and executes a partial bounty claim. This permanently flips the internal claims latch to true right before the token transfer occurs.

function claimAttackerBounty() external nonReentrant {
[...]
uint256 payout = remaining <= freeBalance ? remaining : freeBalance;
uint256 newBountyClaimed = bountyClaimed + payout;
bountyClaimed = newBountyClaimed;
if (payout > 0) {
corruptedReserve -= payout;
@> if (!claimsStarted) claimsStarted = true;
stakeToken.safeTransfer(attacker, payout);
}
[...]
}

Once this latch is set, the moderator is permanently locked out of altering the resolution. The protocol assumes claims are finalized, preventing the moderator from re-flagging the outcome to save the funds.

function flagOutcome(PoolStates.Outcome newOutcome, bool goodFaith_, address attacker_) external onlyModerator {
@> if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) revert OutcomeAlreadySet();
[...]
}

With the moderator locked out, the attacker actively triggers the token blacklist. Because the remaining bounty can never be successfully transferred to a blacklisted address, the claim requirement is permanently bricked. The global fund sweep reverts unconditionally.

function claimCorrupted() external nonReentrant {
if (outcome != PoolStates.Outcome.CORRUPTED) revert OutcomeNotSet();
@> if (goodFaith && bountyClaimed < bountyEntitlement) revert MustClaimBountyFirst();
uint256 toSweep = stakeToken.balanceOf(address(this));
if (toSweep == 0) revert NothingToSweep();
[...]
}

The protocol's only remaining escape hatch is strictly time-locked, forcing a 180-day complete denial of service for all honest users before anyone can retrieve the trapped principal.

function sweepUnclaimedCorrupted() external nonReentrant {
if (outcome != PoolStates.Outcome.CORRUPTED) revert OutcomeNotSet();
if (!goodFaith) revert NotGoodFaithCorrupted();
@> if (block.timestamp <= corruptedClaimDeadline) revert ClaimWindowNotExpired();
[...]
}

Risk

Likelihood:

The vulnerability is highly likely to be exploited because malicious actors can actively trigger their own blacklisting to deliberately extort the decentralized autonomous organization. BattleChain operates as a dedicated pre-mainnet sandbox where whitehat hackers actively exploit contracts for real funds, making it trivial for an attacker to intentionally interact with sanctioned entities to purposefully freeze their receiving address. The protocol architecture fatally couples the recovery of all honest user funds to the successful token transfer of this single uncontrollable external attacker address.

Impact:

This architectural flaw results in a complete denial of service that locks all pool funds for 180 days, trapping honest users' principal and protocol donations until the grace period expires. Furthermore, it creates a zero-cost extortion vector where a designated attacker intentionally triggers a token blacklist to hold the entire protocol recovery process hostage. Because the attacker can execute a partial claim to permanently disable the moderator's re-flagging ability before initiating the blacklist, the protocol is left completely paralyzed with no administrative escape hatch.

Proof of Concept

// BattleChainFactoryIntegration.fork.t.sol
contract BlacklistableERC20 is MockERC20 {
mapping(address => bool) public isBlacklisted;
function setBlacklist(address account, bool status) external {
isBlacklisted[account] = status;
}
function transfer(address to, uint256 value) public override returns (bool) {
require(!isBlacklisted[to], "recipient blacklisted");
return super.transfer(to, value);
}
function transferFrom(address from, address to, uint256 value) public override returns (bool) {
require(!isBlacklisted[to], "recipient blacklisted");
return super.transferFrom(from, to, value);
}
}
function testBlacklistDeadlock() public {
BlacklistableERC20 usdc = new BlacklistableERC20();
factory.setStakeTokenAllowed(address(usdc), true);
uint256 expiry = block.timestamp + 365 days;
address[] memory accounts = new address[](1);
accounts[0] = DEMO_AGREEMENT_IN_SCOPE;
vm.prank(DEMO_AGREEMENT_OWNER);
address poolAddr = factory.createPool(DEMO_AGREEMENT, address(usdc), expiry, 1e18, recovery, accounts);
IConfidencePool pool = IConfidencePool(poolAddr);
address staker = makeAddr("staker");
usdc.mint(staker, 1000e18);
vm.prank(staker); usdc.approve(poolAddr, 1000e18);
vm.prank(staker); pool.stake(1000e18);
address attackRegistry = 0xdD029a6374095EEb4c47a2364Ce1D0f47f007350;
bytes4 getStateSel = bytes4(keccak256("getAgreementState(address)"));
vm.mockCall(attackRegistry, abi.encodeWithSelector(getStateSel, DEMO_AGREEMENT), abi.encode(6));
pool.pokeRiskWindow();
address hacker = makeAddr("hacker");
usdc.setBlacklist(hacker, true);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, hacker);
vm.prank(hacker);
vm.expectRevert("recipient blacklisted");
pool.claimAttackerBounty();
vm.expectRevert(IConfidencePool.MustClaimBountyFirst.selector);
pool.claimCorrupted();
vm.expectRevert(IConfidencePool.ClaimWindowNotExpired.selector);
pool.sweepUnclaimedCorrupted();
console.log("Funds locked until", pool.corruptedClaimDeadline(), "(180 days from now)");
}

Logs:

forge test --mt testBlacklistDeadlock -vvv
[⠊] Compiling...
[⠑] Compiling 1 files with Solc 0.8.26
[⠘] Solc 0.8.26 finished in 5.85s
Compiler run successful!
Ran 1 test for test/fork/BattleChainFactoryIntegration.fork.t.sol:BattleChainFactoryIntegrationForkTest
[PASS] testBlacklistDeadlock() (gas: 1462951)
Logs:
Funds locked until 1793629216 (180 days from now)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.54s (3.07ms CPU time)
Ran 1 test suite in 1.54s (1.54s CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Recommended Mitigation

Decouple the global fund sweep from the bounty claim process. Reserve the attacker's pending bounty inside the contract while allowing the remaining funds to be swept immediately.

function claimCorrupted() external nonReentrant {
if (outcome != PoolStates.Outcome.CORRUPTED) revert OutcomeNotSet();
- if (goodFaith && bountyClaimed < bountyEntitlement) revert MustClaimBountyFirst();
uint256 toSweep = stakeToken.balanceOf(address(this));
-
+ uint256 pendingBounty = goodFaith ? (bountyEntitlement - bountyClaimed) : 0;
+ if (toSweep <= pendingBounty) revert NothingToSweep();
+ toSweep -= pendingBounty;
corruptedReserve = toSweep <= corruptedReserve ? corruptedReserve - toSweep : 0;
if (!goodFaith) {
bountyClaimed = bountyEntitlement;
}
if (!claimsStarted) claimsStarted = true;
stakeToken.safeTransfer(recoveryAddress, toSweep);
emit ClaimCorrupted(msg.sender, recoveryAddress, toSweep);
}

Support

FAQs

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

Give us feedback!