Root cause — contributeBonus (src/ConfidencePool.sol:266-285) takes an irrevocable deposit without setting the expiryLocked reliance latch that its sibling deposit entrypoint stake sets at :229-231. setExpiry (:622-631) gates on nothing else, so the latch never fires for a bonus-only pool.
Impact — The sponsor moves expiry to type(uint32).max (year 2106) after a third-party Bonus Contributor's capital is committed and unrecoverable, binding it to a deadline the contributor never agreed to. No refund path exists (claimExpired reverts PoolNotExpired; sweepUnclaimedBonus reverts OutcomeNotEligibleForSweep), so the contribution is stranded for ~80 years and the reward pot it was meant to fund is neutralised.
expiryLocked is a one-way reliance latch. stake sets it before taking funds (ConfidencePool.sol:229-231), and setExpiry gates solely on it (:623). docs/DESIGN.md §10 states the rule it enforces: "expiry — sponsor-mutable only until the first stake (one-way expiryLocked latch). This protects staker reliance: once anyone has deposited against a given deadline (which feeds the k=2 weighting as T for the EXPIRED path), the sponsor cannot move it."
contributeBonus is the sibling deposit entrypoint — permissionless, taking real value, gated on the same expiry (:269) and the same _assertDepositsAllowed (:271) — and it has no refund path: totalBonus is only ever incremented here and decremented by sweepUnclaimedBonus, never returned to the contributor. Yet it never writes expiryLocked. On a pool funded by bonus but not yet staked, the latch stays false and the sponsor can move expiry to type(uint32).max (year 2106), binding capital to a deadline the contributor never agreed to. The deposit §10's rationale describes — "once anyone has deposited against a given deadline" — has occurred; the latch it justifies does not fire.
The affected party is a documented first-class actor, not a hypothetical. README.md enumerates five actors and lists "5. Bonus Contributor / Attacker" separately from "2. Pool Sponsor (Pool Owner)", with the contributor able to "contribute to the bonus pool permissionlessly via contributeBonus". Third-party bonus funding is an intended flow, so this is not self-harm.
Likelihood:
Occurs on any pool where bonus is contributed before the first stake lands — the natural funding order, since a reward pot is seeded to attract stakers rather than after them.
Requires no operator error, no privileged misuse, and no unusual registry state. The sponsor simply calls setExpiry, which the latch gap leaves open to them.
Bounded by the fact that a sponsor who is also the sole bonus contributor only harms themselves. The finding depends on the third-party contributor flow that README.md §5 documents.
Impact:
Capital committed against an advertised deadline is bound to one the contributor never agreed to, up to year 2106 — an ~80-year effective freeze. claimExpired reverts PoolNotExpired until block.timestamp >= expiry (:513), and sweepUnclaimedBonus reverts OutcomeNotEligibleForSweep while the outcome is UNRESOLVED (:475). No refund path exists.
In practice no staker joins a pool carrying an 80-year lock, so the contribution never performs the function it was made for, and the reward pot the sponsor advertised is neutralised.
Impact is bounded, which is why this is rated Low rather than higher: README.md §5 states "bonus contributors gain no claim on stake or bonus", so no party holds an enforceable entitlement that is taken. setExpiry also gives the sponsor no new extraction path — the bonus already routes to recoveryAddress under the documented sweep rules. The harm is stranded capital and a broken reliance guarantee, not theft.
No staker is harmed. Any stake latches expiry on arrival, and stakers joining afterward do so against a published expiry they can read.
Executed against the repo's own harness (test/helpers/BaseConfidencePoolTest.sol). Both tests pass on unmodified source:
Set the same latch in contributeBonus that stake already sets, so any deposit against the deadline freezes it — aligning the mechanism with the reliance rationale docs/DESIGN.md §10 already states.
Verified: applying this patch makes test_poc_contributeBonus_leavesExpiryUnlocked fail (the exploit is closed) and causes zero regressions — the existing suite goes from 260 passed / 0 failed to 259 passed / 1 failed, where the single failure is the PoC itself. No existing test asserts that contributeBonus leaves expiry unlocked.
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.