A Confidence Pool can be given a term that outlives the guarantee it depends on.
In the reproduction, Pool A covers an account for 90 days. Agreement A — the only Agreement Pool A ever reads for settlement — guarantees to keep that account for just 30 days. After day 30, the account is removed from Agreement A and registered under a new Agreement B. Pool A still lists the account in its locked scope, but it keeps reading Agreement A. Agreement B then becomes CORRUPTED while Agreement A stays non-terminal, so Pool A cannot select any CORRUPTED settlement branch for an account it still covers. At Pool expiry, the permissionless claimExpired() finalizes EXPIRED and pays the complete corpus — 100e18 principal plus 50e18 bonus — to the staker. That finalization cannot be reversed.
Neither function that sets Pool expiry requires the Agreement commitment to last as long as the Pool. ConfidencePoolFactory.createPool() and ConfidencePool.setExpiry() accept a Pool term that runs past the point where the covered accounts are guaranteed to still be inside the Agreement used for settlement.
The Pool makes two design decisions that only stay compatible while the stored Agreement can actually keep the Pool's accounts.
The Pool's scope is local and becomes permanent. _replaceScope() validates each account against the Agreement only at the moment the scope is set, and _observePoolState() locks the scope the first time it sees the Agreement past pre-attack staging. After the lock, nothing re-checks Agreement membership, so the Pool's coverage list stops tracking the Agreement.
Settlement reads one Agreement for the Pool's whole life. The Pool stores a single Agreement address at initialization, and _getAgreementState() resolves every outcome from that Agreement's registry state.
The Agreement commitment is what holds those two decisions together. Upstream, an account cannot be removed from an Agreement while block.timestamp < getCantChangeUntil(). While the commitment is in force, every locked Pool account is guaranteed to remain inside the stored Agreement, so that Agreement can always represent the account's state.
No Pool expiry path requires the commitment to cover the Pool term. createPool() and setExpiry() accept an expiry beyond the commitment end, which opens a gap: after the commitment lapses but before the Pool expires, the covered account can leave the stored Agreement while the Pool stays active and keeps reading that same Agreement. The Pool can remain active after a covered account is allowed to leave the only Agreement the Pool reads for settlement.
The missing invariant belongs in the two in-scope functions that set Pool expiry. The rest of the list is the in-scope code where the mismatch later manifests.
ConfidencePoolFactory.createPool() — src/ConfidencePoolFactory.sol:67-114. Validates the minimum expiry lead, Agreement validity, and Agreement ownership (lines 78-82), then accepts expiry and initializes the clone. It never compares expiry against getCantChangeUntil(). The PoC exercises this path directly.
ConfidencePool.setExpiry() — src/ConfidencePool.sol:622-632. Before the first stake, the Pool owner can move expiry subject only to the lock flag, the minimum lead, and the uint32 ceiling (lines 623-625). It has the same missing commitment check. This path is established by source inspection; the PoC does not call it.
ConfidencePool.agreement — src/ConfidencePool.sol:61. The settlement Agreement is assigned once during initialize() (line 204) and has no setter, so the Pool reads the same Agreement for its whole life.
ConfidencePool._replaceScope() — src/ConfidencePool.sol:749-776. Each account is checked against IAgreement(agreement).isContractInScope() only when scope is set (line 768). A later removal from the Agreement does not touch Pool storage.
ConfidencePool._getAgreementState() — src/ConfidencePool.sol:740-744. Settlement reads getAgreementState(agreement) for the stored Agreement.
ConfidencePool.flagOutcome() — src/ConfidencePool.sol:322-379. The moderator can flag CORRUPTED only when the stored Agreement's state is CORRUPTED (line 347), and SURVIVED only when that Agreement is terminal (line 338). While the stored Agreement is non-terminal, both revert with InvalidOutcome.
ConfidencePool.claimExpired() — src/ConfidencePool.sol:512-607. After expiry, this permissionless function reads the stored Agreement's state and, for any non-terminal state, resolves EXPIRED (lines 561-571) and sets claimsStarted = true (line 575), which finalizes the outcome.
The two setters and the settlement read, abbreviated (unrelated checks omitted):
Both ConfidencePoolFactory.createPool() and ConfidencePool.setExpiry() set the Pool's expiry without checking that the Agreement commitment extends at least as far:
The unsafe term mismatch is accepted by these two in-scope functions. Without the check, the Pool's term can exceed the window during which the stored Agreement is guaranteed to hold the Pool's locked accounts. Once the commitment lapses, three things are true at the same time:
the covered account can be removed from the stored Agreement;
the Pool's scope stays locked and continues to list that account;
settlement keeps reading the stored Agreement, which is the Pool's only source of outcome state.
The Pool's coverage list and its settlement source then describe different sets of accounts, and the one in-scope place to prevent that divergence is the expiry check both setters omit.
The Agreement commitment is the period during which every account covered by the Pool is guaranteed to remain inside the Agreement used for settlement. Upstream, removeAccounts() reverts while block.timestamp < getCantChangeUntil(), so nothing can pull a covered account out of the stored Agreement while the commitment is in force.
Because the Pool permanently relies on that Agreement, createPool() and setExpiry() must ensure that the commitment covers the full Pool term. Otherwise, the Pool can continue covering an account after its settlement Agreement is no longer able to represent that account. The enforcement point is the Pool's own expiry:
This invariant keeps the locked scope and the settlement source describing the same accounts for the entire underwritten term. It is also durable: an Agreement commitment can be extended but never shortened (extendCommitmentWindow() reverts on a smaller value), so once it holds at Pool creation it holds for the Pool's whole life.
Agreement A is the Pool's stored settlement Agreement; Agreement B is a later Agreement the covered account moves to. In the reproduction:
The sponsor creates Agreement A containing coveredAccount and retainedAccount, with a 30-day commitment.
The sponsor creates Pool A against Agreement A with only coveredAccount in its local scope, and sets Pool A's expiry to 90 days — 60 days past the commitment.
A staker deposits 100e18 and the sponsor contributes a 50e18 bonus, for a 150e18 corpus.
Agreement A enters UNDER_ATTACK: the sponsor requests attack mode and a separate registry moderator approves it, which binds both accounts to Agreement A. Pool A observes the active-risk state and locks its scope, so coveredAccount is now permanently part of Pool A's coverage.
Agreement A's 30-day commitment expires while Pool A's 90-day term is still running. The sponsor removes coveredAccount from Agreement A, keeping retainedAccount so the Agreement stays valid; the registry clears coveredAccount's binding to Agreement A.
The sponsor registers coveredAccount under a new Agreement B whose only account is coveredAccount. Agreement B enters UNDER_ATTACK and is then marked CORRUPTED by its attack moderator, which registration assigns to the sponsor as Agreement B's owner.
Agreement A remains UNDER_ATTACK. Pool A still lists coveredAccount in its locked scope and still reads Agreement A, so the Pool moderator can flag neither CORRUPTED (Agreement A is not CORRUPTED) nor SURVIVED (Agreement A is not terminal); both revert with InvalidOutcome.
After Pool A's expiry, any caller invokes claimExpired(). Agreement A is non-terminal, so the Pool resolves EXPIRED, sets claimsStarted, and pays the full 150e18 to the staker. The Pool balance is zero and the outcome is final.
The decisive gap is between steps 5 and 8: the Agreement commitment ends first, the Pool term is still running, and the removal and migration are legal for the rest of that term.
docs/DESIGN.md §8 (lines 209-231) states that the Pool's scope is a fixed, pool-local commitment, and that if the sponsor narrows the Agreement, "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." protocol-readme.md (line 57) makes the same promise: once scope locks, agreement-level changes "don't alter what this pool covers," and stakers "are exposed to exactly what they signed up for at deposit time."
The documentation is explicit that the locked Pool scope remains binding, that Agreement-level changes do not alter what the Pool covers, and that stakers remain exposed to the accounts they accepted. Without commitment coverage, the Pool can continue covering an account after the stored Agreement can no longer represent that account's state. Requiring the commitment to cover the Pool term prevents that divergence.
High. The defect controls the disposition of the complete Pool corpus.
Because Pool A keeps reading Agreement A, and Agreement A never becomes CORRUPTED, every Pool-level CORRUPTED settlement path is unavailable — the moderator's good-faith and bad-faith CORRUPTED classifications and the permissionless auto-CORRUPTED backstop all require the stored Agreement to read CORRUPTED. That holds even though the account Pool A still lists as covered has completed a sole-account CORRUPTED lifecycle under Agreement B.
claimExpired() then finalizes Pool A as EXPIRED. The reproduction shows the full 100e18 + 50e18 = 150e18 corpus paid to the staker, the whitehat and the recovery address receiving zero, and the Pool balance reduced to zero. claimExpired() sets claimsStarted, so the outcome cannot be reclassified afterward.
The control uses the same 150e18 Pool corpus and confirms that when the stored Agreement itself becomes CORRUPTED, the corpus is routed through the CORRUPTED bounty branch. The state of the stored Agreement determines which full-value settlement branch is available.
Low. The issue requires a Pool term longer than the Agreement commitment and a later migration of a covered account out of the stored Agreement.
Medium. The impact is high because the mismatch can irreversibly determine the disposition of the complete principal-and-bonus corpus. Likelihood is low because the path requires a longer Pool term and a later account migration. High impact with low likelihood supports Medium severity.
The PoC deploys the real vendored Agreement factory, AttackRegistry, and Safe Harbor registry — not mocks — and drives the lifecycle through the real upstream entry points.
It contains two tests:
Primary migration path (test_PoolOutlivesAgreementCommitmentAndCorruptedBranchIsUnavailable): a covered account is locked into Pool A, migrated to Agreement B after Agreement A's commitment expires, and corrupted under B; Pool A can only resolve EXPIRED, and the full 150e18 reaches the staker.
Control (test_Control_OriginalAgreementCorruptionUsesBountyBranch): the Agreement Pool A reads is itself marked CORRUPTED, and the same 150e18 corpus routes through the good-faith CORRUPTED bounty branch.
The control uses the same 150e18 Pool corpus and demonstrates the branch selected when the stored Agreement itself is CORRUPTED. The state of the stored Agreement determines which full-value settlement branch is available.
The setExpiry() sibling of the missing check is established by source inspection (src/ConfidencePool.sol:622-632); the PoC exercises the createPool() path.
The in-scope project compiles under Solidity 0.8.26 and the vendored BattleChain contracts under 0.8.34, so the upstream contracts are compiled separately into an artifact directory the test consumes via vm.getCode. No RPC, fork, or network access is used.
The canonical PoC lives at:
test/poc/binding_migration/BindingMigrationOrphansLockedScope.t.sol
Run from the contest repository root:
Both tests pass. This run recorded:
The primary test asserts:
The control test asserts:
These excerpts come from the primary test. First, the covered account leaves Agreement A after the commitment ends, is re-registered under Agreement B, and B is marked CORRUPTED, while Pool A keeps listing the account and reading Agreement A:
The moderator cannot classify the Pool while Agreement A is non-terminal, and after expiry the permissionless call resolves EXPIRED and returns the whole corpus:
In the control, Agreement A itself is marked CORRUPTED, the moderator flags good-faith CORRUPTED, and the same 150e18 corpus is claimed as the bounty:
Require the Agreement commitment to cover the complete Pool lifetime in both functions that can set Pool expiry:
Add the check in ConfidencePoolFactory.createPool() and ConfidencePool.setExpiry():
This keeps every covered account inside the Agreement used for settlement for the full Pool term, because upstream removeAccounts() reverts while the commitment is still in force. The check also stays valid for the Pool's whole life: an Agreement commitment can be extended but never shortened, so the invariant holds once it is established at creation.
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.