A staker's entire economic bet is defined by the pool's scope - the flat list of BattleChain accounts (_scopeAccounts) whose corruption determines whether the pool resolves CORRUPTED (staker loses principal) or SURVIVED (staker keeps principal + bonus). A rational staker deposits against a specific scope.
However, the pool owner (sponsor) can replace that scope after stakers have already deposited. setPoolScope is onlyOwner and is permitted whenever !scopeLocked:
scopeLocked only seals once the registry is observed past pre-attack staging (any state other than NOT_DEPLOYED / NEW_DEPLOYMENT). Critically, stake() is also permitted in those same pre-attack states — _assertDepositsAllowed only blocks PROMOTION_REQUESTED, PRODUCTION, and CORRUPTED. Therefore there is a real overlap window (registry in NOT_DEPLOYED or NEW_DEPLOYMENT) where stakes are accepted and scope is still mutable at the same time. A sponsor can wait for stakers to deposit against scope {A}, then call setPoolScope({B, C}) (completely different scope), silently replacing the coverage those existing stakers are now underwriting.
This directly contradicts the guarantee the protocol makes to stakers in its own documentation:
"Once locked, the pool's coverage is fixed even if the sponsor later expands the agreement — so post-stake additions to the underlying agreement do not extend this pool's coverage, and stakers' exposure is bounded by what they signed up for at deposit time."
—docs/DESIGN.md§8
Per the code, a staker's exposure is bounded by the scope at lock time (ATTACK_REQUESTED / riskWindowStart), not at deposit time. Between deposit and lock, the sponsor can add accounts the staker never agreed to.
Asymmetric reliance protection. expiry freezes on the first stake() via the one-way expiryLocked latch, so once anyone deposits the sponsor can no longer move it - yet scope, which is more consequential because it defines what is insured and thus decides CORRUPTED (staker loses principal) vs SURVIVED, has no such first-stake latch and stays sponsor-mutable after deposits. The protocol therefore freezes the less consequential parameter (how long capital is locked) on the first stake while leaving the more consequential one (what the capital underwrites) mutable. Mirroring the expiryLocked pattern in stake(), or allowing only scope removals post-first-stake, closes the gap.
Sponsor creates a pool for an agreement while the registry is in NEW_DEPLOYMENT, committing to scope {ContractA}, and funds an attractive bonus.
A staker reads the on-chain scope {ContractA}, judges ContractA safe, and stakes a large principal against that specific coverage.
While the registry is still in NEW_DEPLOYMENT (scope not yet locked, stakes already accepted), the sponsor calls setPoolScope({ContractB, ContractC}), adding a contracts the staker never evaluated or agreed to.
The sponsor calls requestUnderAttack and the DAO approves it shortly after (possibly in the same block), so the registry reaches UNDER_ATTACK with little or no time spent in the withdraw-eligible ATTACK_REQUESTED phase. On the first pool interaction that observes UNDER_ATTACK, scopeLocked and riskWindowStart seal together, permanently disabling withdraw(). The staker - who never watched ScopeUpdated and had at most a brief, sponsor/DAO-controlled ATTACK_REQUESTED window to react - is now locked in against {ContractB, ContractC} with no exit.
ContractB is corrupted in scope; the moderator flags CORRUPTED. The staker loses their entire principal to recoveryAddress, despite ContractA was the only contract they signed up for.
Loss of staker principal on non-consented coverage. A staker who underwrote {ContractA} can be forced to underwrite {ContractB, ContractC, …} and lose their full principal to a CORRUPTED resolution triggered by an account they never agreed to insure.
Violation of an explicitly documented staker guarantee. The code does not bound exposure "at deposit time" as docs/DESIGN.md §8 promises; it bounds it at lock time, allowing post-deposit scope expansion.
Create a test/fork/MutablePoolScopeAfterStake.fork.t.sol file
Put the code below in the file
Run BATTLECHAIN_TESTNET_RPC=https://testnet.battlechain.com forge test --match-test SponsorSwapsPoolScopeAfterStakeThenStakerLosesPrincipalToCorrupted -vv in the termnal
Apply the same first-stake reliance latch already used for expiry to scope.
Freeze scope on the first stake - introduce a one-way latch (mirroring expiryLocked) set in stake(), and have setPoolScope revert once any principal has been deposited. This makes the documented "bounded at deposit time" guarantee true.
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.