contributeBonus() deposits tokens into the pool but never sets expiryLocked = true, unlike stake() which sets it immediately. A malicious pool owner can call setExpiry() after bonus has been contributed but before any staker joins, silently extending the pool lifetime to trap the contributor's tokens, then recover them through sweepUnclaimedBonus() once the extended expiry passes.
Because contributeBonus never sets expiryLocked, setExpiry remains callable after bonus is deposited, as long as no staker has joined yet.
The developer's intent is that pool parameters become immutable once participants commit funds. expiryLocked enforces this for setExpiry(). The design correctly locks the expiry on the first stake() call, because a staker is the primary participant whose capital is at risk.
However, bonus contributors are also participants: they trust that the pool will expire on the advertised schedule, after which any unclaimed bonus is swept back to the pool owner's recoveryAddress. If the owner can extend the expiry after receiving a bonus deposit, the contributor's funds are locked far beyond their expectations. Combined with a malicious recoveryAddress, the owner can eventually extract all contributed bonus with no legitimate claim by stakers (by never marketing the pool).
The flaw is an asymmetry in the lock trigger: stake locks, contributeBonus does not. The developer likely assumed sponsors contribute bonus before stakers join, making the lock sequence contributeBonus → stake → lock. The exploit inverts this: contributeBonus → extend expiry → never stake.
expiryLocked == false — no staker has called stake() yet.
totalBonus > 0 — at least one bonus contribution has been received.
outcome == UNRESOLVED — pool not yet resolved.
The pool owner (sponsor) is malicious or willing to act against the bonus contributor's interests.
No stakers are aware of or willing to join the pool (owner withholds marketing).
Attacker creates a pool via ConfidencePoolFactory.createPool() with expiry = block.timestamp + 31 days and recoveryAddress = attacker_wallet.
A protocol DAO or sponsor calls contributeBonus(1000e18) to seed pool incentives. expiryLocked remains false.
Attacker calls setExpiry(type(uint32).max) — succeeds because expiryLocked == false. Pool is now locked until the year 2106.
Attacker never markets the pool. No staker joins. totalEligibleStake == 0.
After the new expiry passes (or attacker uses a shorter but still far extension), any caller triggers claimExpired() → outcome set to EXPIRED, claimsStarted = true.
Any caller calls sweepUnclaimedBonus(). Since riskWindowStart == 0 and totalEligibleStake == 0, reserved = 0. Entire pool balance swept to recoveryAddress = attacker_wallet.
Attacker receives 1000e18 tokens. Contributor receives nothing.
Full loss of bonus contributor funds. The attack is low-risk for the attacker (no capital required beyond pool creation gas), and the victim has no on-chain recourse once the expiry is extended. The bonus is locked until the extended expiry date — which can be up to type(uint32).max (~year 2106).
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.