Normally, once the pool observes the registry in a terminal state (PRODUCTION/CORRUPTED),
riskWindowEnd is sealed one-way (_observePoolState / _markRiskWindowEnd,
src/ConfidencePool.sol:796-798, 819-829) and becomes the fixed upper bound T the k=2 bonus
formula measures every staker's (T − entryTime)² against. Deposit eligibility is supposed to be
closed by that same terminal moment the intent is that nobody enters the bonus pool after the
window everyone else's bonus is being measured against has already ended.
stake() never checks the pool's own persisted riskWindowEnd latch it only gates on the
registry's live state via _assertDepositsAllowed, which blocks PROMOTION_REQUESTED,
PRODUCTION, and CORRUPTED but has no idea whether a terminal state was already observed and
sealed in the past. Whenever the live state reports anything else again a rewind staking
silently reopens while T stays frozen at its old value. Because the k=2 score is
amount·(T − entryTime)², a deposit entered after T still scores positive, and the score
grows with the square of t − T: the later the deposit lands relative to the frozen T, the
more of the bonus pool it dominates.
Contrast with withdraw(), which the team did harden against this exact threat class:
test/unit/ConfidencePool.riskWindow.t.sol:215-234,
testWithdrawRemainsDisabledAfterRegistryRewind, exists specifically to prove the withdraw() side
is safe it deploys a second MockAttackRegistry, repoints SafeHarborRegistry at it to simulate
"registry replacement, mis-migration" (its own words), and asserts withdraw() still reverts. There
is no equivalent test, or equivalent guard, for stake().
Likelihood:
Requires an upstream registry rewind after the pool has already sealed riskWindowEnd
docs/DESIGN.md §11 treats this as a real, anticipated event (a "legitimate DAO registry
migration"), not a contrived edge case: it's the specific reason the team rejected per-clone
registry pinning ("would brick every existing pool on a legitimate DAO registry migration more
likely and more severe than the risk it prevents").
Once a rewind is underway, exploiting it requires nothing beyond an ordinary, permissionless
stake() call at or near the minimum stake no privileged role, no timing race against anyone
else, and the later the staker enters relative to the frozen T, the larger their share, so the
incentive is to simply wait as long as the rewind persists.
Impact:
The bonus pool real, staker/sponsor-contributed value is redistributed away from the stakers
who actually bore the measured at-risk window to a participant who bore none of it. In the PoC
below, a 1-token deposit entered 29 days after the sealed terminal time captures 80.8% of a
100-token bonus pool, out-earning two 100-token stakers who staked for the entire risk window
combined.
This directly contradicts the stated guarantee in docs/DESIGN.md:281 "No funds can be
stolen" for exactly the rewind scenario that line is talking about. The document correctly
proves that guarantee for withdraw() (§11, gated on riskWindowStart) but the same guarantee
does not hold for the bonus pool via stake(), because no equivalent gate exists there.
Principal accounting stays solvent (no insolvency, no reentrancy, no double-spend) this is a
fund-misallocation bug, not a fund-destruction bug, but the misallocation is severe, easy to
execute once the precondition holds, and requires no cooperation from the moderator or sponsor.
Verified directly against this repo (not copied from an unverified source the file path a prior
writeup cited, test/unit/ConfidencePool.poc.t.sol, does not exist in this repo; this test was
written and run fresh using the existing BaseConfidencePoolTest harness). It's also checked into
the repo as a real, standalone runnable file at
test/unit/ConfidencePool.registryRewindStakeReopen.t.sol
(forge test --match-contract ConfidencePoolRegistryRewindStakeReopenTest -vv) reproduced inline
below for a self-contained record:
Give stake() (and contributeBonus(), for the same reason bonus contributed after the window
closes shouldn't be treated as still-open) a persisted-latch guard mirroring the one withdraw()
already has, instead of trusting live registry state alone:
Apply the same riskWindowEnd != 0 guard to contributeBonus(). Add a regression test analogous to
testWithdrawRemainsDisabledAfterRegistryRewind observe a terminal state, rewind the registry to
every deposit-permitted state, and assert stake()/contributeBonus() still revert StakingClosed.
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.