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

Single CORRUPTED Signal Drains All Sibling ConfidencePools

Author Revealed upon completion

Root + Impact

All ConfidencePool clones that share the same agreement read the same registry state. A single CORRUPTED signal therefore triggers the auto‑CORRUPTED backstop in every sibling pool that has observed a risk window and passed its grace period, multiplying the loss across all pools tied to that agreement.

Description

  • Each ConfidencePool operates independently with its own stake and bonus, but all pools created for the same agreement share the same registry state.

  • When one agreement reaches CORRUPTED, the mechanical auto‑CORRUPTED backstop fires in every sibling pool that has observed a risk window and passed its grace period. One signal drains every pool tied to that agreement, multiplying the loss.

// src/ConfidencePool.sol _getAgreementState() – all pools read the same agreement
function _getAgreementState() internal view returns (IAttackRegistry.ContractState) {
address attackRegistry = safeHarborRegistry.getAttackRegistry();
// @> Returns the single agreement state for the pool's agreement
return IAttackRegistry(attackRegistry).getAgreementState(agreement);
}
// src/ConfidencePoolFactory.sol createPool() – unbounded pools per agreement
bytes32 salt = keccak256(abi.encode(agreement, _poolsByAgreement[agreement].length));
pool = Clones.cloneDeterministic(poolImplementation, salt);
// @> No limit on pools per agreement; all share the same registry fate

Risk

Likelihood:

  • An agreement must be placed into CORRUPTED (DAO‑approved UNDER_ATTACK followed by sponsor‑controlled markCorrupted).

  • Each sibling pool must have observed a risk window and passed its own 180‑day grace period. After that, the auto‑CORRUPTED backstop triggers in all of them independently.

Impact:

  • The blast radius is the sum of all pools' balances. An attacker who engineers one agreement‑level corruption drains multiple pools.

  • Stakers in different pools are unknowingly exposed to the same single point of failure.

Proof of Concept

Explanation:

Two pools are created on the same agreement, each with a different staker depositing 50 tokens. Both pools observe the UNDER_ATTACK risk window via pokeRiskWindow. The registry is then moved to CORRUPTED. After warping past the grace period, claimExpired is called on both pools, forcing them into bad‑faith CORRUPTED. The sponsor calls claimCorrupted on each and receives the combined 100 tokens. This demonstrates that one corruption signal on the agreement sweeps all sibling pools.

function test_H2_AmplifiedSweepAcrossPools() public {
MockRegistry registry = new MockRegistry();
registry.setAgreementState(agreement, IAttackRegistry.ContractState.UNDER_ATTACK);
address sponsor = makeAddr("sponsor");
address staker1 = makeAddr("staker1");
address staker2 = makeAddr("staker2");
vm.startPrank(sponsor);
ConfidencePool poolA = factory.createPool(agreement, address(stakeToken), expiry, minStake, sponsor, accounts);
ConfidencePool poolB = factory.createPool(agreement, address(stakeToken), expiry, minStake, sponsor, accounts);
vm.stopPrank();
deal(address(stakeToken), staker1, 50 ether);
vm.startPrank(staker1);
stakeToken.approve(address(poolA), 50 ether);
poolA.stake(50 ether);
vm.stopPrank();
deal(address(stakeToken), staker2, 50 ether);
vm.startPrank(staker2);
stakeToken.approve(address(poolB), 50 ether);
poolB.stake(50 ether);
vm.stopPrank();
poolA.pokeRiskWindow();
poolB.pokeRiskWindow();
registry.setAgreementState(agreement, IAttackRegistry.ContractState.CORRUPTED);
vm.warp(expiry + 181 days);
poolA.claimExpired();
poolB.claimExpired();
uint256 sponsorBefore = stakeToken.balanceOf(sponsor);
vm.prank(sponsor);
poolA.claimCorrupted();
poolB.claimCorrupted();
uint256 sponsorAfter = stakeToken.balanceOf(sponsor);
assertEq(sponsorAfter - sponsorBefore, 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.

// 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!