The pool expiry is documented as sponsor-mutable only until the first stake: a one-way expiryLocked latch (DESIGN §10, lines 257-262) pins expiry once anyone has deposited. The DESIGN doc's exact wording (§10, lines 258-259):
"once anyone has deposited against a given deadline (which feeds the k=2 weighting as
Tfor the EXPIRED path), the sponsor cannot move it."
The contract implements this via expiryLocked, flipped to true inside stake() at ConfidencePool.sol:229-231:
expiryLocked is set inside stake(), after the tx is already included in the block. There is no expectedExpiry/deadline parameter on stake() that would bind the sponsor's promised expiry to the staker's signed tx.
A sponsor (pool owner — who also controls recoveryAddress, setRecoveryAddress, setExpiry, pause, and pool scope until staging ends) sees Alice's stake() tx in the mempool and front-runs it with setExpiry(type(uint32).max) (max permitted by line 625). Alice's stake is accepted with expiry = 2106-02-07 (~80 years), instead of the 31-day deadline she believed she was depositing against. Her funds lock until 2106 unless she withdraws before risk materializes — but if the registry enters UNDER_ATTACK/PROMOTION_REQUESTED between her deposit and her reaction, withdraw() reverts (riskWindowStart != 0, line 294) and her principal is trapped.
setExpiry is already gated by block.timestamp + _MIN_EXPIRY_LEAD (line 624, ≥30 days lead) — so the sponsor CANNOT shorten the window to now. They can only extend arbitrarily up to uint32.max. The grief vector is unbounded lock-up extension, never shortening.
DESIGN §10 lists expiry as a "Sponsor trust surface" — stakers should verify pool parameters before depositing. We agree the sponsor is trusted with expiry until the first stake. But the mempool front-run window is different: even a staker who verified expiry = now + 30 days on-chain before signing has no way to atomic-bind their stake() to the expiry they observed. The DESIGN promise — "deposited against a given deadline" (§10 line 258) — is broken by the implementation's in-tx-late latch.
This is the same class of front-run problem that EIP-2612 permit / deadline parameters solve elsewhere. The sponsor can extend, the staker cannot atomic-react to a mempool-observed setExpiry before their stake lands.
Sponsor is a privileged role (set at initialize via owner_). Anyone creating a pool via ConfidencePoolFactory.createPool becomes the pool's owner (passed as msg.sender at line 82, 90). The sponsor of a maliciously-operated pool could observe staker intent and grief.
Practical setup: sponsor creates a pool with expiry = now + 30 days (minimum allowed), advertised to attract stakers (low lock-up → more deposits). For each staker's stake() tx, sponsor front-runs with setExpiry(MAX_UINT32). Every staked deposit is now locked to 2106.
The pool can be paused between stakes to give sponsor time, but even without pause, block-builders see both txs and can re-order.
Staker principal is locked up to 2106 (80 years). If the registry transitions to active-risk, withdraw() reverts (riskWindowStart != 0, line 294) — funds are at-risk for the full remaining term without escape hatch. If the registry reaches CORRUPTED later and the auto-resolution path fires, principal sweeps to recoveryAddress (sponsor-controlled) instead of being returned to the staker.
The bonus mechanics (k=2 score uses T = expiry for the EXPIRED path) pay stakers as if they held to 2106, but the time-value loss of not having access to funds for 80 years dwarfs any bonus. The lock-up is the harm.
The sponsor does not benefit (the pool's own rules still apply), but this is a pure griefing vector — the sponsor's motive can be market-manipulation, attacker-cooperation, or discouragement of withdrawals.
A front-run PoC can be verified by replacing vm.prank(sponsor); pool.setExpiry(...) with vm.txGasPrice ordering using a builder — but the principle is identical: stake() makes no commitment check on expiry, so the sponsor can mutate it in the same block before inclusion.
Add an expectedExpiry parameter to stake() (the EIP-2612 deadline pattern adapted to a single value rather than a signed permit). The staker signs the exact expiry they observed, and the pool reverts if the sponsor moved it before block inclusion:
Add the new error and the interface declaration. The staker's wallet UI reads pool.expiry() before signing and binds it; any sponsor front-run of setExpiry between read and inclusion now reverts Alice's tx harmlessly rather than locking her funds. After the first successful stake, expiryLocked is set and the sponsor can no longer move expiry, so expectedExpiry becomes redundant but harmless.
Alternative shorter mitigation: lock expiry at createPool time rather than at first stake, since the factory's expiry parameter already serves as the advertised deadline (DESIGN §10 promises "deposited against a given deadline" — the factory-time deadline is the one stakers should reasonably verify against). This requires removing setExpiry entirely or gating it to the same pre-stake window only and accepting that the grace period is shortened — but it eliminates the mempool window by making the deadline immutable from createPool onwards. The first mitigation (commit-to-bind) is preferred because it preserves the sponsor's intended pre-stake flexibility while closing the front-run gap for the staker.
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.