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

Locked pool scope can become unenforceable for `CORRUPTED` after the underlying agreement narrows, letting stakers escape a loss they committed to bear

Author Revealed upon completion

Description

Terminology used below, to keep three different things distinct: G1 is the one agreement this pool is permanently pointed at (pool.agreement, set once at deployment). G2 is a second, unrelated agreement that a covered contract can later be re-registered under. The contract is the actual off-chain smart contract stakers underwrote (e.g. a specific protocol address) — whether it is breached is a real-world fact, separate from which agreement's registry entry reports it.

  • ConfidencePool proves CORRUPTED entirely through one fixed agreement's entry in the AttackRegistry: flagOutcome(CORRUPTED, ...) succeeds only when getAgreementState(agreement) == CORRUPTED, where agreement is set once in initialize() and never reassigned anywhere in the contract. This works as intended as long as a covered contract's registry entry stays under the one agreement the pool was deployed against — which is the implicit assumption baked into checking a single fixed address for the pool's entire lifetime.

  • That assumption doesn't always hold. BattleChain lets an agreement owner remove a contract from G1 once its commitment window ends (Agreement.removeAccounts()AttackRegistry.unregisterContractForExistingAgreement()), and the registry's own getAgreementForContract() shows the binding can move to a different agreement, G2, afterward. If the contract is genuinely breached only after that move, the registry records the breach against G2 — never against G1. There is no code path and no moderator discretion that lets this pool prove that breach: the CORRUPTED check reads one fixed address for the pool's entire lifetime, and that address's registry entry will never show CORRUPTED for a breach that happened after the contract moved elsewhere. This is a hard, permanent dead end for that specific breach, not a delay.

  • DESIGN.md §8 discusses agreement narrowing, but only as a record-keeping residual: "the pool's locked scope may reference accounts no longer in the agreement — but the pool's own commitment to stakers remains the binding source of truth." Read in context, that sentence is about the local scope list possibly going stale while the moderator's off-chain judgement keeps working. It says nothing about G1's own registry entry becoming permanently unable to reflect a real breach because the breach materializes under G2 instead.

// src/ConfidencePool.sol
address public agreement; // set once in initialize(), never reassigned
function _getAgreementState() internal view returns (IAttackRegistry.ContractState) {
address attackRegistry = safeHarborRegistry.getAttackRegistry();
if (attackRegistry == address(0)) revert InvalidAgreement();
@> return IAttackRegistry(attackRegistry).getAgreementState(agreement); // always the original, fixed agreement
}
function flagOutcome(PoolStates.Outcome newOutcome, bool goodFaith_, address attacker_) external onlyModerator {
...
} else if (newOutcome == PoolStates.Outcome.CORRUPTED) {
...
@> if (state != IAttackRegistry.ContractState.CORRUPTED) revert InvalidOutcome();
// no path here checks the current Binding Agreement of any locally-scoped account —
// only the pool's own fixed `agreement` can ever satisfy this check
}

Risk

Likelihood:

  • Occurs whenever the agreement owner calls Agreement.removeAccounts() on a covered contract after the BattleChain commitment window ends, and that contract is later re-registered under a different Binding Agreement that goes on to be marked CORRUPTED — a supported, permitted BattleChain-side operation, not a malicious edge case.

  • Every pool holding that contract in its locked local scope is affected identically, since agreement can never be repointed and _getAgreementState() always resolves through it.

Impact:

  • Stakers who underwrote a contract according to the pool's locked local scope receive a SURVIVED/EXPIRED-level payout (principal + bonus) even though that contract was, in fact, corrupted — the pool has no on-chain path to reach CORRUPTED for that breach, permanently, regardless of what the moderator knows.

  • Funds that should route to the bad-faith recoveryAddress path or the good-faith attacker bounty path for that breach are unavailable, because flagOutcome(CORRUPTED, ...) cannot be satisfied for this pool no matter what the moderator believes or intends.

Proof of Concept

The harness models only G1 (this pool's fixed agreement) — it does not stand up a second G2 contract, since G2's state is irrelevant to what the pool can observe. attackRegistry.setAgreementState(UNDER_ATTACK) represents G1 never advancing to CORRUPTED, which is the only on-chain fact this pool can ever act on — standing in for the real-world case where the contract was genuinely breached, but under G2 after being removed from G1.

// test/unit/PoCLocalScopeNarrowingBlindSpot.t.sol
function test_PoC_localScopeCanRemainCoveredWhileAgreementStateCannotSignalCorruption() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
_passThroughUnderAttack();
assertTrue(pool.isAccountInScope(DEFAULT_SCOPE_ACCOUNT), "pool still locally commits to account");
// Models the BattleChain-side sequence: after the commitment window, the covered account is
// removed from this pool's original agreement and later corrupted under a different Binding
// Agreement. This pool's own agreement never returns CORRUPTED as a result.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.prank(moderator);
vm.expectRevert(IConfidencePool.InvalidOutcome.selector);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
vm.warp(pool.expiry());
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED), "pool cannot resolve CORRUPTED");
assertEq(token.balanceOf(alice) - aliceBefore, 150 * ONE, "staker receives principal plus bonus");
}
forge test --match-path test/unit/PoCLocalScopeNarrowingBlindSpot.t.sol -vv
[PASS] test_PoC_localScopeCanRemainCoveredWhileAgreementStateCannotSignalCorruption() (gas: 524579)
Suite result: ok. 1 passed; 0 failed; 0 skipped

Recommended Mitigation

This needs a mechanism change, not a call-site fix. Pick one:

  1. Track or query the current Binding Agreement (AttackRegistry.getAgreementForContract(account)) for each locked local-scope account, and allow CORRUPTED resolution when any locally-scoped account's current Binding Agreement is CORRUPTED, not just the pool's original agreement.

  2. Snapshot enough per-account binding information at scope lock to prove later corruption of the covered account even after the agreement narrows.

  3. If neither is feasible, treat this as a documentation fix instead: extend DESIGN.md §8 to state plainly that once an account is unregistered from the pool's original agreement, this pool can never prove CORRUPTED for a later breach of that account — not just that the local scope list may go stale.

Support

FAQs

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

Give us feedback!