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

Locked pool scope becomes unenforceable after an account is moved to another Agreement

Author Revealed upon completion

Locked pool scope becomes unenforceable after an account is moved to another Agreement

Description

Once the pool scope is locked, it is supposed to remain the binding source of truth even when the sponsor later narrows the underlying Agreement.

The problem is that flagOutcome(CORRUPTED) still requires the original Agreement to be CORRUPTED. When a locked in-scope account is removed, linked to another Agreement and breached there, the moderator cannot apply the correct outcome.

function flagOutcome(...) external onlyModerator {
IAttackRegistry.ContractState state = _observePoolState();
...
if (newOutcome == PoolStates.Outcome.CORRUPTED) {
// @> The pool-local scope may have been breached even though
// @> the original Agreement is not CORRUPTED anymore.
if (state != IAttackRegistry.ContractState.CORRUPTED) {
revert InvalidOutcome();
}
}
}

Risk

Likelihood: Low

  • This occurs when an account is removed from the original Agreement after the commitment period and later registered under another Agreement.

  • The issue materializes when that account is breached under the new Agreement while the original Agreement remains in a non-terminal state.

Impact: High

  • The trusted moderator is unable to mark the pool CORRUPTED, even though an account in the permanently locked pool scope was genuinely breached.

  • At expiry, the pool can resolve as EXPIRED, returning the full principal and bonus to stakers. In practice, the exact account insured by the pool can be hacked and the stakers still get paid as though nothing happened.

Proof of Concept

The following PoC shows that a genuinely breached account in the pool’s locked scope can still cause the pool to resolve as EXPIRED and return all principal and bonus.

function testLockedScopeBecomesUnenforceableAfterRelink() external {
_stake(alice, 100e18);
_contributeBonus(carol, 50e18);
// Lock the pool scope containing account X.
attackRegistry.setAgreementState(
agreementA,
IAttackRegistry.ContractState.UNDER_ATTACK
);
pool.pokeRiskWindow();
// X is removed from Agreement A and registered under Agreement B.
_removeAccountFromAgreement(agreementA, X);
_registerAccountUnderAgreement(agreementB, X);
// X is genuinely breached under Agreement B.
attackRegistry.setAgreementState(
agreementB,
IAttackRegistry.ContractState.CORRUPTED
);
// Agreement A remains UNDER_ATTACK.
attackRegistry.setAgreementState(
agreementA,
IAttackRegistry.ContractState.UNDER_ATTACK
);
// The moderator cannot apply the correct pool outcome.
vm.prank(moderator);
vm.expectRevert(IConfidencePool.InvalidOutcome.selector);
pool.flagOutcome(
PoolStates.Outcome.CORRUPTED,
false,
address(0)
);
// The pool later pays principal + bonus as EXPIRED.
vm.warp(pool.expiry());
uint256 balanceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimExpired();
assertEq(
uint256(pool.outcome()),
uint256(PoolStates.Outcome.EXPIRED)
);
assertEq(token.balanceOf(alice) - balanceBefore, 150e18);
}

Recommended Mitigation

The trusted moderator should be able to resolve a breach of the pool-local locked scope independently of the current state of the original Agreement.

if (newOutcome == PoolStates.Outcome.CORRUPTED) {
...
- if (state != IAttackRegistry.ContractState.CORRUPTED) {
- revert InvalidOutcome();
- }
+ // The moderator's judgement over the locked pool-local scope is the source of truth.
}

Support

FAQs

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

Give us feedback!