FoundrySolidityLayer 2
7.25 ETH
Submission Details
Impact: low
Likelihood: low

First dust stake permanently locks expiry even after full withdraw, freezing sponsor ability to extend the term

Author Revealed upon completion

Root + Impact

Description

  • The pool owner may update expiry only while expiryLocked is false. The first successful stake sets expiryLocked = true so later stakers can rely on a fixed deadline for staking closure and for the EXPIRED path’s k=2 upper bound T = expiry.

    • The latch is never cleared when a staker fully exits via withdraw, even when totalEligibleStake returns to zero and the registry is still pre-attack. Any address can post exactly minStake, immediately withdraw, and permanently freeze setExpiry. That griefs pool setup: the sponsor can no longer extend the underwriting window for subsequent legitimate stakers, despite no capital remaining in the pool.

// src/ConfidencePool.sol
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
// ...
if (!expiryLocked) {
expiryLocked = true; // @> one-way latch; never cleared on withdraw
}
// ...
}
function withdraw() external nonReentrant {
// ... zeros caller's stake and returns principal ...
// @> does not clear expiryLocked even when totalEligibleStake returns to 0
}
function setExpiry(uint256 newExpiry) external onlyOwner {
if (expiryLocked) revert ExpiryLocked(); // @> blocked forever after first stake
if (newExpiry < block.timestamp + _MIN_EXPIRY_LEAD) revert ExpiryTooSoon();
if (newExpiry > type(uint32).max) revert ExpiryTooFar();
// ...
}

Risk

Likelihood:

  • A public pool remains in a pre-attack registry state (NOT_DEPLOYED, NEW_DEPLOYMENT, or ATTACK_REQUESTED) with expiry not yet locked, and any address posts minStake then withdraws before the sponsor finalizes the intended term.

    • Front-running of the sponsor’s first “real” stake—or opportunistic dust stake while the pool is advertised—permanently trips the latch using only temporary capital equal to minStake.

Impact:

  • The sponsor can no longer call setExpiry to extend the underwriting window for later cohorts, even when totalEligibleStake == 0 and withdrawals remain open.

    • Funds are not stolen, but pool configuration can be griefed into a shorter-than-intended term, harming subsequent stakers who wanted a longer horizon or stranding a pool that still needed a longer expiry before meaningful deposits.

Proof of Concept

What this PoC proves

  1. Before any stake, the owner can still call setExpiry (baseline: latch off).

  2. Alice stakes only minStake (ONE in the test harness).

  3. expiryLocked flips to true.

  4. Alice fully withdraws while the registry is still pre-attack (allowed path).

  5. expiryLocked remains true despite zero remaining stake.

  6. Owner setExpiry reverts with ExpiryLocked, so the term cannot be extended.

Why the steps matter

• Steps 2–3 show that any successful stake, including dust-sized capital, is enough to arm the permanent latch.
• Step 4 shows the attacker does not need to leave funds at risk—temporary capital is enough.
• Steps 5–6 prove the grief is permanent and on-chain enforceable: sponsor configuration is bricked for the life of the clone.

// File: test/audit/AuditResiduals.t.sol
// Run: forge test --match-test test_PoC_dustStakeLocksExpiryAfterFullWithdraw -vv
// Result: PASS — setExpiry reverts ExpiryLocked after dust stake + full withdraw
function test_PoC_dustStakeLocksExpiryAfterFullWithdraw() external {
uint256 oldExpiry = pool.expiry();
// 1) Dust stake arms the one-way latch
_stake(alice, ONE); // minStake
assertTrue(pool.expiryLocked());
// 2) Full exit is still allowed pre-attack — attacker recovers capital
_withdraw(alice);
assertEq(pool.totalEligibleStake(), 0);
assertTrue(pool.expiryLocked(), "latch survives full exit");
// 3) Sponsor can no longer extend expiry for future stakers
vm.expectRevert(IConfidencePool.ExpiryLocked.selector);
pool.setExpiry(oldExpiry + 30 days);
}
Expected outcome: Test passes. After dust stake + full withdraw, setExpiry always reverts ExpiryLocked.

Recommended Mitigation

Goal: Prevent unprivileged dust stakes from permanently freezing sponsor term configuration when no capital remains at risk, without re-introducing the “move expiry under an active cohort” attack that the one-way latch was meant to stop.

Approach (recommended): Keep the latch while any stake is outstanding or while the registry has left pre-attack staging. Clear expiryLocked only when both totalEligibleStake == 0 and the live registry state is still pre-attack. That way:

• A dust griefer who enters and exits before real deposits does not brick setExpiry.

• A sponsor still cannot move expiry while stakers are bonded (original safety property).

• Once risk materializes, the latch stays one-way even if everyone later exits.


Why this fix works

• Breaks the PoC at step 5: after full withdraw with zero remaining stake in pre-attack state, expiryLocked clears and setExpiry succeeds again.

• Does not allow moving expiry while other stakers still have principal in the pool.

• Does not re-open expiry after the pool has left pre-attack staging.

Operational alternative: Keep the permanent latch and document that sponsors must set final expiry before any address can call stake. Prefer the code change above when the product wants pre-deposit configuration flexibility without a grief surface.

function withdraw() external nonReentrant {
// ... existing gates, accounting, totalEligibleStake -= amount ...
+ // Allow re-tuning expiry only when the pool is empty and still pre-attack.
+ // Preserves "no moving expiry under an active staker cohort".
+ if (
+ totalEligibleStake == 0
+ && (state == IAttackRegistry.ContractState.NOT_DEPLOYED
+ || state == IAttackRegistry.ContractState.NEW_DEPLOYMENT
+ || state == IAttackRegistry.ContractState.ATTACK_REQUESTED)
+ ) {
+ expiryLocked = false;
+ }
stakeToken.safeTransfer(msg.sender, amount);
emit Withdrawn(msg.sender, amount);
}

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!