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

Mechanical Bad-Faith CORRUPTED via claimExpired Overrules Moderator Discretion

Author Revealed upon completion

Root + Impact

The claimExpired function contains a permissionless auto‑CORRUPTED backstop that, after a 180‑day grace window, mechanically forces a bad‑faith CORRUPTED resolution and sweeps the entire pool balance to the sponsor‑controlled recoveryAddress. This overrides the outcome moderator’s discretion and allows a sponsor who can trigger the CORRUPTED registry signal to eventually capture all staker principal and bonus.

Description

  • Normally, the outcome moderator decides whether a pool resolves to SURVIVED or CORRUPTED. The moderator can re-flag up to the first claim to correct any mistake.

  • After expiry + 180 days, any caller can mechanically force a bad‑faith CORRUPTED resolution by calling claimExpired. This overrides the moderator’s discretion and sweeps the entire pool balance to the sponsor‑controlled recoveryAddress, even if the moderator would have chosen SURVIVED.

// src/ConfidencePool.sol claimExpired() – auto‑CORRUPTED branch
if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
// @> Permissionless resolution to CORRUPTED after grace
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) { // @> Grace window still open
revert AgreementCorruptedAwaitingModerator();
}
outcome = PoolStates.Outcome.CORRUPTED; // @> Forces bad‑faith CORRUPTED
corruptedReserve = snapshotTotalStaked + snapshotTotalBonus; // @> Entire balance sweepable
claimsStarted = true; // @> Locks out moderator re‑flag
emit OutcomeFlagged(address(0), PoolStates.Outcome.CORRUPTED, false, address(0));
return;
}

Risk

Likelihood:

  • The pool must have observed a risk window (riskWindowStart != 0) and the agreement must be in CORRUPTED state, which requires a prior DAO‑approved UNDER_ATTACK phase followed by a sponsor‑controlled markCorrupted call.

  • The 180‑day grace period after expiry must have elapsed. After that, any external actor can trigger the backstop with a single transaction.

Impact:

  • The entire pool balance (staker principal + bonus) is swept to the sponsor‑controlled recoveryAddress.

  • The moderator’s ability to correctly classify an out‑of‑scope breach as SURVIVED is permanently bypassed.

Proof of Concept

  • Explanation:

The test sets up a pool with a sponsor who also acts as the recovery address. A staker deposits 100 tokens. The risk window is opened by observing an UNDER_ATTACK registry state. The registry is then manipulated to CORRUPTED (simulating the sponsor‑as‑attack‑moderator path). Time is warped past the 180‑day grace period after expiry. An arbitrary attacker calls claimExpired, which forces the pool into bad‑faith CORRUPTED and sets claimsStarted = true. The sponsor then calls claimCorrupted and receives the full 100 tokens. The test verifies that the moderator’s intended outcome was ignored and all funds went to the sponsor.

function test_H1_AutoCorruptedSweep() public {
MockRegistry registry = new MockRegistry();
registry.setAgreementState(agreement, IAttackRegistry.ContractState.UNDER_ATTACK);
address sponsor = makeAddr("sponsor");
address staker = makeAddr("staker");
address attacker = makeAddr("attacker");
vm.startPrank(sponsor);
ConfidencePool pool = factory.createPool(
agreement, address(stakeToken), expiry, minStake, sponsor, accounts
);
vm.stopPrank();
deal(address(stakeToken), staker, 100 ether);
vm.startPrank(staker);
stakeToken.approve(address(pool), 100 ether);
pool.stake(100 ether);
vm.stopPrank();
pool.pokeRiskWindow();
registry.setAgreementState(agreement, IAttackRegistry.ContractState.CORRUPTED);
vm.warp(expiry + 181 days);
vm.prank(attacker);
pool.claimExpired();
uint256 balanceBefore = stakeToken.balanceOf(sponsor);
vm.prank(sponsor);
pool.claimCorrupted();
uint256 balanceAfter = stakeToken.balanceOf(sponsor);
assertEq(balanceAfter - balanceBefore, 100 ether);
}

Recommended Mitigation

Mitigation explanation:

The mitigation introduces per‑pool scope verification before the mechanical backstop can fire. Even if the agreement is globally CORRUPTED, a specific pool is only swept if the actual breached contract falls within that pool’s committed scope. This can be checked by calling IAgreement(agreement).isContractInScope(breachContract) and requiring it to be true. An alternative is to allow a per‑pool override: a separate moderator (or a governance vote) can exempt an individual pool from the auto‑CORRUPTED sweep when the breach is clearly out of scope. This breaks the shared‑fate amplification, ensuring one corrupt agreement does not automatically drain every pool.


```diff

- // Every pool reads the same agreement state; CORRUPTED sweeps all

+ // Require per-pool scope verification before mechanical backstop

+ require(

+ IAgreement(agreement).isContractInScope(relevantBreachContract),

+ "Breach not in this pool's scope"

+ );

+ // Or add a per-pool override that allows a separate moderator to exempt a pool.

```

Support

FAQs

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

Give us feedback!