ConfidencePool validates only that each pool-scope account is a member of the stored Agreement, but membership does not prove that the Agreement is the account's Binding Agreement in the AttackRegistry.
Because every lifecycle and settlement decision subsequently reads only the state of that single stored Agreement, an account can be removed from it, rebound to another Agreement, and corrupted during the pool term without allowing the pool moderator to flag CORRUPTED. The pool can consequently return the stakers' principal and bonus through SURVIVED or EXPIRED, even though an account in the pool's immutable local scope was corrupted.
The pool treats Agreement membership as proof of an account's active binding, causing it to track the wrong security state.
Agreement membership and registry binding are two distinct concepts:
Membership: Contract X is listed in Agreement A's scope.
Binding: Agreement A is the Agreement that AttackRegistry currently recognizes as governing X.
An account may appear in the scope of multiple Agreements, but only one Agreement is actively bound to it at a given time. The active binding is returned by attackRegistry.getAgreementForContract(X).
However, when a Confidence Pool is created, the factory checks only that the Agreement is valid and that the caller owns it:
ConfidencePoolFactory.sol::createPool#L79-L82
When the pool scope is configured, the pool checks only whether each account is a member of the stored Agreement:
ConfidencePool.sol::_replaceScope#L768-L769
None of these checks establishes that getAgreementForContract(X) == A. Therefore, they do not prove that Agreement A is the Agreement currently governing X.
AttackRegistry.sol::getAgreementForContract#L654-L657
This distinction becomes critical after pool creation. The pool stores Agreement A, and every subsequent lifecycle and settlement check reads only Agreement A's state through _getAgreementState():
ConfidencePool.sol::_getAgreementState#L743
As a result, the pool cannot enforce its local scope commitment once a scope account is rebound to another Agreement:
flagOutcome(CORRUPTED, ...) requires the original Agreement itself to report CORRUPTED (ConfidencePool.sol).
If the original Agreement remains UNDER_ATTACK at expiry, claimExpired() selects EXPIRED.
If the original Agreement reaches PRODUCTION, claimExpired() selects SURVIVED.
Both successful outcomes pay eligible stakers their principal plus their bonus share (ConfidencePool.sol).
Suppose a pool is created for Agreement A with a locked local scope of [X]. The following sequence then occurs:
Although X remains in the pool's immutable local scope, the pool continues to observe only Agreement A:
This discrepancy can produce incorrect settlement outcomes:
If Agreement A is PRODUCTION, the pool settles as SURVIVED.
If Agreement A remains UNDER_ATTACK until expiry, the pool settles as EXPIRED.
A moderator's attempt to call flagOutcome(CORRUPTED) reverts because Agreement A is not CORRUPTED.
In other words, the pool's immutable local scope still claims to cover X, but after X is rebound to Agreement B, the pool can no longer observe X's actual security outcome.
The intended behavior is for a pool's locked scope to remain its binding coverage commitment throughout the entire pool term. The design explicitly states that the local scope may continue to contain an account after the underlying Agreement's scope is narrowed and that the pool-local commitment remains authoritative (DESIGN.md).
However, the upstream Safe Harbor contracts distinguish membership in an Agreement from the Agreement currently bound to an account:
IAgreement.isContractInScope(account) == true does not establish that the Agreement is the account's binding Agreement because multiple Agreements may include the same account (IAgreement.sol).
isAgreementValid(agreement) proves only that the Agreement was deployed by the canonical factory; it does not bind any account to that Agreement (IBattleChainSafeHarborRegistry.sol).
The account's active binding is exposed through AttackRegistry.getAgreementForContract(account) (AttackRegistry.sol).
Because the pool validates only membership and later reads only the stored Agreement's state, its settlement logic can become detached from the security state of the accounts in its own locked scope.
Likelihood:
The documented behavior claims the pool-local scope remains binding, while the implementation cannot settle a corruption of a removed/rebound account unless the original agreement is also marked CORRUPTED.
Impact:
Impact 1: The complete pool balance can be allocated according to a successful outcome despite an in-scope corruption.
For good-faith corruption, the attacker can lose the intended bounty of snapshotTotalStaked + snapshotTotalBonus.
For bad-faith corruption, the recovery address can lose the amount that should have been swept to it.
The pool's public immutable scope no longer describes the accounts whose security state determines settlement.
Impact 2: Conversely, corruption of the stale Agreement caused by accounts outside the pool’s locked scope can cause a pool whose rebound scope accounts survived to be settled as CORRUPTED, especially through the scope-blind permissionless expiry fallback.
The Poc test/unit/StaleAgreementBinding.t.sol:StaleAgreementBindingTest demonstrates that a locked pool scope can become detached from the account's live AttackRegistry binding.
The test first locks account X into a pool created for Agreement A. It then removes X from A, rebinds X to Agreement B, and marks B as corrupted. Although X remains in the pool's immutable local scope, the pool continues reading only Agreement A. As a result, the moderator cannot report the real corruption and the pool incorrectly settles as EXPIRED, distributing the principal and bonus to the staker instead of recognizing the loss.
Run test:
Test Pass:
Make the pool's locked account scope, rather than the originally stored Agreement alone, the source of truth for settlement.
The preferred fix is to extend the upstream registry with account-level binding locks or versioned binding history:
When the pool is initialized or its scope is updated, require
getAgreementForContract(account) == agreement for every scope account.
Lock each validated binding until the pool expires, or record every binding transition and the corresponding Agreement state so the pool can determine whether a locally scoped account became corrupted during the pool term.
Resolve the pool from this account-level history instead of relying exclusively on getAgreementState(agreement).
If upstream binding locks or history cannot be added, treat any binding divergence as a disputed state rather than allowing automatic SURVIVED or EXPIRED settlement. In that state:
block the permissionless claimExpired() fallback;
allow the already-trusted moderator to report CORRUPTED when a locked scope account was corrupted under a subsequent Binding Agreement, without requiring the original Agreement itself to be CORRUPTED; and
emit the affected account and replacement Agreement so the decision can be audited off-chain.
Checking that the Agreement's commitment window extends beyond the pool expiry and validating the initial live bindings are useful defense-in-depth measures, but they are not sufficient historical proof. In particular, getAgreementForContract() exposes only the current binding, and the registry may permit an account to be linked to another Agreement after the original Agreement reaches a terminal state.
The contest is live. Earn rewards by submitting a finding.
This is your time to appeal against judgements on your submissions.
Appeals are being carefully reviewed by our judges.
The contest is complete and the rewards are being distributed.