The protocol permits the sponsor to replace a pool's scope only while the
registry is in pre-attack staging. The first pool interaction that observes any
other registry state must permanently set scopeLocked, so that the pool's
coverage cannot later expand beyond the scope on which existing stakers relied.
setPoolScope() first calls _observePoolState(). When the registry is in
ATTACK_REQUESTED and the pool is not yet locked, that helper sets
scopeLocked = true. setPoolScope() then reverts because the flag is true.
The revert rolls back the newly written flag. Consequently, the pool retains no
history that it observed ATTACK_REQUESTED.
The AttackRegistry permits the DAO to reject an attack request and returns the
agreement to NOT_DEPLOYED. The sponsor can then call setPoolScope() again:
the registry is once more in a staging state and the still-false scopeLocked
permits a scope replacement. This can occur while a user who staked during
NOT_DEPLOYED remains in the pool; stake() observes the state but does not
lock the scope in that staging state.
The protocol's design makes the scope the binding audit trail for the
moderator's off-chain decision between SURVIVED and CORRUPTED. Thus, this is
not merely an event/indexing inconsistency: it lets the sponsor expand the
coverage used to determine an existing staker's later settlement outcome.
This is not an accepted design choice. docs/DESIGN.md says scope locks permanently on the first interaction observing any state outside NOT_DEPLOYED / NEW_DEPLOYMENT, and that once locked, stakers' exposure is bounded by what they signed up for at deposit time. The issue is that the first observation is rolled back by a revert, allowing later coverage changes for existing stake.
Likelihood: Low
A user stakes while the registry is NOT_DEPLOYED, which leaves
scopeLocked unset by design. The agreement can be a valid factory-created
agreement without being registered in the AttackRegistry.
The agreement reaches ATTACK_REQUESTED, the sponsor becomes the first
observer through a reverting setPoolScope() call, and no successful pool
interaction persists the lock during that interval.
The DAO rejects the request, returning the registry to NOT_DEPLOYED.
Rejection is a supported registry transition but is outside the sponsor's
direct control. A later loss additionally requires a future in-scope
corruption; stakers can also withdraw while the registry is in either
NOT_DEPLOYED or ATTACK_REQUESTED.
Impact: Medium
The sponsor can enlarge the pool-local scope after a historical
post-staging observation while prior stake remains in the pool. This violates
the pool's fixed-coverage commitment and expands those stakers' future
exposure beyond the scope originally advertised to them.
Scope is used by the moderator as the binding audit trail. A later corruption
of an address added through this bypass can change the pool's resolution from
SURVIVED to CORRUPTED; the corrupted path can ultimately transfer the
pool's token balance to recoveryAddress or a good-faith attacker. The scope
replacement itself does not move funds, so this is indirect fund risk rather
than an immediate drain.
Under the CodeHawks impact/likelihood matrix, Low likelihood and Medium impact
classify this issue as Low severity.
Add the following test to test/unit/ConfidencePool.scope.t.sol inside ConfidencePoolScopeTest. The test uses the existing suite fixtures and helpers (_seedAgreementScope, _multiAccountScope, _stake, pool, attackRegistry, and agreementContract):
The existing test testAuditScopeLockRollsBackWhenSetPoolScopeIsFirstPostStagingObservation
already demonstrates that the reverting observation leaves scopeLocked false
and that a subsequent staging-state call replaces the scope. The additional
stake assertions show why the state inconsistency affects existing stakers.
Verified locally with:
Result: 1 passed; 0 failed.
Make the first post-staging setPoolScope() call succeed after persisting the
new lock, without applying the requested replacement. Later attempts can
continue to revert because the lock was already present before the call.
Apply the same persistence rule to pokeRiskWindow(): a first observation of
ATTACK_REQUESTED must not revert away the newly set scope lock merely because
no risk-window timestamp was sealed.
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.