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

Expiry can remain sponsor-controlled for bonus-only or far-future pools

Author Revealed upon completion

Root + Impact

Description

The pool owner can change expiry until the first successful stake. Bonus contributions do not engage the same lock even though they transfer non-refundable tokens into pool custody. In a bonus-only pool, the owner can repeatedly move expiry forward after every prior deadline, preventing mechanical resolution and keeping the bonus locked.

Separately, the only upper bound on a pre-stake expiry is type(uint32).max. The owner can select the year-2106 ceiling and a later staker permanently latches that value. If the moderator never resolves the pool, claimExpired() remains unavailable for decades and the staker has no early withdrawal once risk begins.

expiryLocked is set only inside stake():

src/ConfidencePool.sol
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
// ... validation omitted ...
if (!expiryLocked) {
expiryLocked = true; // @> Only the first stake freezes the deadline.
}
// ... transfer and accounting omitted ...
}
function contributeBonus(uint256 amount) external nonReentrant whenPoolNotPaused {
// ...
stakeToken.safeTransferFrom(msg.sender, address(this), amount);
// ...
totalBonus += received; // @> Funds enter permanently, but expiryLocked remains false.
}

The owner setter checks only that the stake latch is false and that the new timestamp is at least 30 days in the future but no greater than the uint32 ceiling:

function setExpiry(uint256 newExpiry) external onlyOwner {
if (expiryLocked) revert ExpiryLocked();
if (newExpiry < block.timestamp + _MIN_EXPIRY_LEAD) revert ExpiryTooSoon();
if (newExpiry > type(uint32).max) revert ExpiryTooFar(); // @> The year-2106 ceiling is valid.
expiry = uint32(newExpiry); // @> Can be repeated indefinitely while no stake has landed.
emit ExpiryUpdated(oldExpiry, newExpiry);
}

Bonus contributors cannot withdraw their donation. sweepUnclaimedBonus() requires a SURVIVED or EXPIRED outcome, and the owner can keep block.timestamp < expiry by extending just before or even after the previous deadline because setExpiry() does not require the old expiry still to be in the future.

For principal, an owner can call setExpiry(type(uint32).max) before the first stake. The stake succeeds because the deadline is in the future and then sets expiryLocked = true. The owner cannot correct the horizon afterward, and the staker cannot call claimExpired() until the selected timestamp:

function claimExpired() external nonReentrant {
if (block.timestamp < expiry) revert PoolNotExpired(); // @> Backstop inherits the extreme horizon.
// ...
}

The repository documents sponsor control before first stake, so this is primarily a custody-commitment and configuration-footgun issue. Likelihood depends on a malicious or severely mistaken sponsor and on users funding a visibly extreme pool, but the affected funds can remain inaccessible for the practical lifetime of the protocol.

Risk

Likelihood:

  • The owner must intentionally or mistakenly keep rolling a bonus-only deadline or publish an extreme pre-stake deadline.

  • Expiry is public, so careful contributors and stakers can detect a far-future value before transacting; front-running the first funding transaction remains possible.

  • Once the edge configuration exists, the lockup is deterministic and requires no further attacker capability.

Impact:

  • All bonus deposited before the first stake can remain locked indefinitely because the contributor has no withdrawal path and cannot freeze the promised horizon.

  • Staked principal can be committed until 2106 if the moderator does not provide an earlier terminal outcome.

  • The extreme expiry also stretches every lifecycle operation keyed to expiry and delays the permissionless liveness backstop.

Proof of Concept

Create test/audit/CP055SponsorControlledExpiry.t.sol with the following contents:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract CP055SponsorControlledExpiryTest is BaseConfidencePoolTest {
function test_BonusOnlyPoolExpiryCanBeRolledForwardRepeatedly() external {
_contributeBonus(carol, 50 * ONE);
assertFalse(pool.expiryLocked());
uint256 originalExpiry = pool.expiry();
vm.warp(originalExpiry);
uint256 firstExtension = block.timestamp + 31 days;
pool.setExpiry(firstExtension);
vm.prank(carol);
vm.expectRevert(IConfidencePool.PoolNotExpired.selector);
pool.claimExpired();
vm.warp(firstExtension);
uint256 secondExtension = firstExtension + 31 days;
pool.setExpiry(secondExtension);
assertEq(pool.expiry(), secondExtension);
assertFalse(pool.expiryLocked(), "bonus custody never freezes the deadline");
assertEq(token.balanceOf(address(pool)), 50 * ONE, "the contribution remains locked");
}
function test_FirstStakerCanLockAnExtremeOwnerSelectedExpiry() external {
pool.setExpiry(type(uint32).max);
_stake(alice, 100 * ONE);
assertTrue(pool.expiryLocked());
assertEq(pool.expiry(), type(uint32).max);
vm.expectRevert(IConfidencePool.ExpiryLocked.selector);
pool.setExpiry(block.timestamp + 31 days);
vm.warp(block.timestamp + 50 * 365 days);
vm.prank(alice);
vm.expectRevert(IConfidencePool.PoolNotExpired.selector);
pool.claimExpired();
assertEq(pool.eligibleStake(alice), 100 * ONE, "principal remains committed decades later");
}
}

Run the PoC from the repository root:

  1. Execute forge test --offline --match-path test/audit/CP055SponsorControlledExpiry.t.sol -vv.

  2. Confirm that both tests pass. One rolls a bonus-only expiry forward twice while the 50-token contribution remains held. The other locks uint32.max with a 100-token stake and shows that both deadline correction and expiry claiming remain unavailable decades later.

Recommended Mitigation

Freeze expiry when any accounted user value first enters the pool, not only when stake enters, and impose a protocol-level maximum duration measured from the current time or pool creation. Validate the same duration bound during initialization and later updates.

src/ConfidencePool.sol
+uint256 public constant MAX_POOL_DURATION = 365 days;
function initialize(
// ...
uint256 expiry_,
// ...
) external initializer {
if (expiry_ < block.timestamp + _MIN_EXPIRY_LEAD) revert ExpiryTooSoon();
- if (expiry_ > type(uint32).max) revert ExpiryTooFar();
+ if (expiry_ > block.timestamp + MAX_POOL_DURATION) revert ExpiryTooFar();
// ...
}
function contributeBonus(uint256 amount) external nonReentrant whenPoolNotPaused {
// ... validation and transfer ...
totalBonus += received;
+ if (!expiryLocked) {
+ expiryLocked = true;
+ }
emit BonusContributed(msg.sender, received);
}
function setExpiry(uint256 newExpiry) external onlyOwner {
if (expiryLocked) revert ExpiryLocked();
if (newExpiry < block.timestamp + _MIN_EXPIRY_LEAD) revert ExpiryTooSoon();
- if (newExpiry > type(uint32).max) revert ExpiryTooFar();
+ if (newExpiry > block.timestamp + MAX_POOL_DURATION) revert ExpiryTooFar();
expiry = uint32(newExpiry);
// ...
}

Because bonus contribution is permissionless, freezing on the first bonus allows a third party to commit the advertised deadline with a small donation. If that is undesirable, require a meaningful minimum bonus, add a sponsor-controlled funding-open phase before public contributions, or make the immutable expiry part of a signed contribution intent. Do not retain mutable expiry after accepting non-refundable custody merely to avoid that tradeoff.

Support

FAQs

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

Give us feedback!