stake() sets the one-way expiryLocked latch on every successful deposit (if (!expiryLocked) { expiryLocked = true; }), but contributeBonus() has no equivalent line. The latch was designed to protect anyone who has put funds into the pool from the sponsor moving the deadline out from under them — but it was wired only to the staking path, not to the bonus-contribution path, even though contributeBonus() is a permissionless fund-deposit entrypoint with the exact same reliance problem. setExpiry()'s only guard is if (expiryLocked) revert ExpiryLocked();, so as long as no one has staked yet, it's fully open regardless of how much bonus capital sits in the contract.
The sponsor can unilaterally change the pool's term (expiry) after third-party bonus capital is already locked in, with no on-chain signal to the contributor and no way for the contributor to object or exit (bonus contributors have no withdraw/claim path at all).
This shifts T = expiry in the EXPIRED-path k=2 bonus formula and reopens/shortens the staking window, changing the economics the contributor funded against.
Bounded impact: no funds are stolen or trapped — the bonus still eventually reaches stakers or recoveryAddress under the normal rules. The harm is loss of the term guarantee the contributor relied on, not loss of tokens.
Normal behavior:
expiry is the sponsor-controlled pool deadline used to gate staking/bonus deposits and to anchor the EXPIRED-path bonus calculation. To protect anyone who has already put funds into the pool, the contract is designed so that once a deposit has been made against a given expiry, the sponsor can no longer move it — this is enforced by the one-way expiryLocked flag, which setExpiry() checks before allowing any change (if (expiryLocked) revert ExpiryLocked();).
The issue:
expiryLocked is only ever set inside stake() (if (!expiryLocked) { expiryLocked = true; }). contributeBonus() — which is also a permissionless entrypoint that locks real ERC20 value into the pool — never sets this flag. As a result, if a bonus contributor funds the pool before any staker has deposited, expiryLocked remains false, and the sponsor can still freely call setExpiry() to move the deadline, with no on-chain signal to the contributor and no lock preventing it.
Likelihood:
Occurs whenever a bonus contributor funds a pool during the normal window before the first staker deposits — a routine, expected sequence, since sponsors are encouraged to seed sponsor/community bonus contributions early to attract stakers.
Occurs on every setExpiry() call the sponsor makes after that point, since the function performs no check for existing bonus capital — the sponsor doesn't need to time anything or exploit a race, the gap is open by default until the first stake lands.
Impact:
The sponsor unilaterally changes the pool's staking window and the T used in the EXPIRED bonus formula after third-party capital is already committed, with no on-chain notice to the contributor.
Bonus contributors have no claim, withdraw, or exit path in the contract, so they carry this risk with zero recourse once their tokens are transferred in.
Drop it into test/unit/ — it extends the repo's own BaseConfidencePoolTest so it picks up the existing mocks automatically.
This makes expiryLocked fire on the first value-locking deposit of either kind, matching the guarantee setExpiry() is supposed to enforce. No changes needed to setExpiry() itself — it already gates correctly on the flag once the flag is set at the right time.
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.