The sponsor limitation in README.md, protocol-readme.md, and docs/DESIGN.md is false in practice. Even after a pool locks its published scope, the same sponsor still controls the upstream BattleChain agreement that the pool reads through the live AttackRegistry. Post-lock addAccounts(), removeAccounts(), and addOrSetChains() mutations sync directly into that live agreement, while the pool keeps trusting agreement-wide registry state instead of its frozen local scope. A staker can therefore stay locked in a pool that still publishes contract A as its insured scope while the live agreement has already switched to contract B, and a later corruption on B can still drive CORRUPTED settlement against the pool.
The root mistake is that the pool creator must also own the upstream agreement, but the pool's local scopeLocked bit only freezes setPoolScope; it does not freeze the upstream agreement state machine that the value-moving paths actually trust. The docs promise the opposite: the sponsor "cannot alter scope once locked", post-lock agreement changes supposedly "don't alter what this pool covers", and DESIGN says post-stake additions do not extend coverage while even post-lock narrowing leaves the pool's own commitment as the "binding source of truth". The implementation never enforces that invariant.
Once the pool leaves pre-attack staging, only the pool-local scope is frozen. The local lock is set in _observePoolState(), and after that setPoolScope() just reverts. But the upstream agreement stays mutable. It can still add BattleChain accounts after lock through addAccounts(), remove them after the commitment window through removeAccounts(), or wholesale replace the BattleChain chain through addOrSetChains(). Those mutations are not cosmetic: they sync straight into the live registry through the _addToBattleChainScope() hook, the _removeFromBattleChainScope() / _clearBattleChainScope() hooks, and AttackRegistry's register/unregister sync functions. BattleChain's own suite already exercises all three real-life paths in testRegisterContractForExistingAgreement_HappyPath, testUnregisterContractForExistingAgreement_HappyPath, and testSyncNewContracts_HappyPath.
The sink is that the pool's critical paths never consult the frozen scope. withdraw() only checks the live agreement state returned by _observePoolState(), and claimExpired() later auto-resolves from that same live agreement state. That means the published scope can stay pinned to contract A forever while the sponsor has already removed A upstream, inserted replacement contract B, and the pool still locks or settles from whatever state that replacement contract drives at the agreement level.
Reachability conditions:
The sponsor creates the pool from an agreement they own, which the factory already enforces.
The pool has observed any post-staging agreement state, so scopeLocked == true.
For the replacement path, the agreement's own commitment window has expired. This is realistic because the upstream registry only requires 7 days of commitment at registration, while BattleChain's own tests already perform post-registration replacement after that window.
Outside conditions:
No special outside condition is needed for the lock-mismatch path beyond the sponsor using their documented agreement controls.
The full-sweep branch additionally needs the replacement contract to later drive the live agreement to CORRUPTED and the documented claimExpired() fallback to be used after the moderator grace period.
Exploit path:
The sponsor creates a pool from an agreement they own, which the factory enforces.
The pool observes a non-staging registry state, so its local scope freezes to the published account list and can no longer be updated on-chain through setPoolScope().
After that freeze, the same sponsor mutates the upstream agreement by adding a new BattleChain contract, removing an old one, or fully replacing the BattleChain chain.
BattleChain's agreement hooks sync that mutation into the live AttackRegistry, so the live agreement can now point at contract B while pool.getScopeAccounts() still only publishes contract A.
Because withdraw(), pokeRiskWindow(), flagOutcome(), and claimExpired() all trust agreement-wide live state rather than the frozen scope, stakers remain locked or settle based on the replacement set, not the scope they deposited against.
In the strongest path, the sponsor waits until stakers are already locked by real risk, then replaces the agreement's active BattleChain set, and a later corruption on the replacement contract still drives the pool into the CORRUPTED branch even though that contract never appeared in the published pool scope.
Likelihood:
Reason 1 // The underpayment path appears once the moderator has already flagged SURVIVED on a terminal-CORRUPTED agreement while claimsStarted remains false and the correction window is still open.
Reason 2 // The bonus loss is realized on the first intervening sweepUnclaimedBonus() call, because that call moves value out and reduces live totalBonus before the corrected good-faith CORRUPTED snapshot is taken.
The sponsor can bypass the advertised post-lock scope limitation and keep existing stakers bound to a live BattleChain set that was never published in the pool scope. Their principal can stay locked against a mutated agreement attack surface, and if a replacement contract later corrupts the live agreement the pool can still mechanically sweep staker principal to recoveryAddress even though the corrupted contract was never part of getScopeAccounts().
Run command:
Observed output:
Freeze the upstream agreement attack surface at scopeLocked (or at latest when riskWindowStart opens), and reject or ignore later agreement add/remove/replace mutations in withdraw, flagOutcome, and claimExpired.
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.