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

Pool can settle against a non-binding agreement

Author Revealed upon completion

[Medium] Pool can settle against a non-binding agreement

Description

ConfidencePool validates scope with IAgreement(agreement).isContractInScope(account), but never checks that this agreement is the account's live registry binding:

AttackRegistry.getAgreementForContract(account)

Settlement then reads only:

AttackRegistry.getAgreementState(agreement)

So a pool can cover an account through Agreement A while the registry binds that account to Agreement B.

The pool therefore validates against one source of truth when it checks scope membership, but resolves against a different source of truth when it reads agreement state. If those diverge, the resolution path can be wrong even though all local scope checks passed.

Risk

If Agreement B becomes CORRUPTED, the pool still reads Agreement A and can resolve EXPIRED. Actual scoped-account corruption is ignored; stakers recover principal and the bounty/recovery path is bypassed.

Attack path: A and B both include the target account. The pool is created with A. The registry binds the account to B. B becomes CORRUPTED. flagOutcome(CORRUPTED, ...) reverts because the pool reads A. After expiry grace, stakers call claimExpired().

This is particularly dangerous because the bug does not require the target account to be outside scope. The account is in scope under both agreements. The failure is specifically that the pool ignores which agreement the registry says is authoritative for that account at resolution time.

Proof of Concept

registry.setAgreementForContract(target, address(agreementB));
registry.setAgreementState(address(agreementB), CORRUPTED);
registry.setAgreementState(address(agreementA), NOT_DEPLOYED);
// pool was created with agreementA, which includes target
pool.stake(100e18);
pool.contributeBonus(50e18);
vm.prank(moderator);
vm.expectRevert(IConfidencePool.InvalidOutcome.selector);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
vm.warp(pool.expiry() + pool.MODERATOR_CORRUPTED_GRACE());
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED));

PoC flow:

  1. Create a pool that points to agreementA.

  2. Keep the target account in scope under both agreementA and agreementB.

  3. Bind the target account in the registry to agreementB.

  4. Mark only agreementB as CORRUPTED.

  5. Observe that moderator corruption flagging fails, because the pool still reads the state of agreementA.

  6. After expiry grace, claimExpired() succeeds and the pool settles as EXPIRED.

Mitigation

For each scoped account, validate the live binding:

address bound = IAttackRegistry(registry).getAgreementForContract(account);
if (bound != agreement) revert ScopeAgreementMismatch(account, bound);

If binding may be delayed, enforce this before accepting stake/bonus and before resolution.

Support

FAQs

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

Give us feedback!