The intended behavior is that a pool's locked account list remains its binding coverage surface after the underlying
agreement is narrowed. The published scope is the audit trail used by the moderator, and corruption of a covered
account must not settle as a successful survival/expiry outcome. This is stated in
docs/DESIGN.md section 8
and the
protocol-readme.md scope-lock guarantee.
ConfidencePool cannot enforce that guarantee. It validates each scoped account against the agreement only when
the scope is set, then every deposit and resolution decision reads the aggregate state of the pool's original
agreement. It never resolves the current registry binding of a locked account.
This creates the following supported lifecycle:
Agreement A contains contracts X and Y. Pool A locks local scope [X] while A is UNDER_ATTACK.
After A's commitment window, A's sponsor removes X. The real dependency unbinds X without changing A's
independently stored UNDER_ATTACK state on Y.
A different sponsor legitimately binds X to Agreement B. B follows the normal lifecycle and becomes
CORRUPTED.
Pool A still publishes X as covered, but sees only A's UNDER_ATTACK state. Its moderator is code-blocked
from flagging CORRUPTED, and an unprivileged user can stake after B/X's corruption is final and public.
At Pool A's expiry, A is still non-terminal, so claimExpired() resolves EXPIRED and pays that user principal
plus the prefunded bonus. A pre-existing losing staker is paid on the same wrong side.
The relevant in-scope code is:
Source links:
The transition is reachable through the pinned real dependency. Agreement.removeAccounts() permits narrowing
after the commitment window, unregisterContractForExistingAgreement() deletes the account binding without a
terminal-state requirement, and agreement state is derived from per-agreement flags:
Section 8 acknowledges that a locked list may still reference an account removed from its original agreement. It
does not say that corruption of that covered account under its new authoritative binding may resolve EXPIRED,
pay the survival side, or allow new positions after the event is public. That result conflicts with the same
section's fixed-coverage promise and its statement that the moderator and published scope determine whether the
breach surface was covered. Here the honest moderator attempts the covered CORRUPTED outcome and is blocked by
the in-scope code. The finding is the wrong deposit and settlement behavior after the acknowledged narrowing, not
the dependency's intended narrowing/rebinding primitives.
Likelihood: Low
The path requires a multi-step but supported lifecycle: A must remain active on another account after its
commitment expires, X must be removed and rebound to B, B/X must be corrupted, and Pool A must remain live.
No malicious trusted role or collusion is required. A and B can have different sponsors performing ordinary
lifecycle actions. Once the state exists, the profitable stake() and claimExpired() calls are permissionless.
The PoC uses deployed scope contracts, distinct funded A/B sponsors, the real fee/bond/DAO approval paths, an
ordinary ERC20, and the exact target factory and pool bytecode.
Impact: High
Funds are directly misdistributed. A user entering after the covered corruption can capture up to the entire
prefunded bonus; the PoC transfers 150 tokens for a 100-token post-corruption stake.
Pre-existing losing positions can receive the affected pool's principal and bonus even though the locked covered
account was corrupted.
The pool records no corruption bounty/reserve, so its good-faith whitehat or bad-faith recovery settlement is
unreachable for that covered event.
Pool size and bonus are not capped by this mechanism, so the direct loss is bounded by the affected pool balance.
High impact with low likelihood maps to Medium under the CodeHawks severity matrix.
The test below executes the exact target commit
58e8ba4ce3f3277866e4926f3140e597f9554a1e Solidity 0.8.26 factory/pool bytecode against the real pinned
Safe Harbor dependency commit fde1b2abe9e5c27175f5b6f7324bcce6afc3b059. It does not use vm.store or a
mock registry. The artifact SHA-256 is
5261f40b578fb4ecb20432c1c14ba39746569c04c2079573d5683a08a97808a5.
From the contest repository root, build and copy the two generated target artifacts into the dependency test
fixture directory:
Add the following as
lib/battlechain-safe-harbor-contracts/test/repro/FScopeSubmissionPoC.t.sol:
Run:
Observed result in two isolated runs:
Closing deposits on binding divergence is necessary but not sufficient: resolution also needs to represent a
covered account's corruption after rebinding. At minimum, check every locked account's registry binding before
stake() and contributeBonus(), prevent automatic EXPIRED while a binding divergence is unresolved, and let
the trusted moderator flag CORRUPTED when a locked-scope account's current binding is corrupted. For a complete
fix, persist binding/corruption history for the pool term or prevent rebinding while a live pool covers the account;
otherwise a later rebind can erase the current-pointer evidence.
An immediate defense for the demonstrated path is conceptually:
Apply the same deposit check to contributeBonus(). The production fix should replace the illustrative aggregate
status helper with stored per-account transition evidence or an upstream binding lock, then add the regression:
A[X,Y] UNDER_ATTACK -> Pool A[X] locks -> A removes X -> B binds and corrupts X -> post-corruption stake reverts and Pool A cannot resolve EXPIRED.
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.