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

`contributeBonus` commits capital without locking `expiry`, letting the sponsor move the deadline afterward

Author Revealed upon completion

Root + Impact

Description

ConfidencePool.stake() sets the one-way expiryLocked latch on the first deposit, after which
setExpiry() reverts ExpiryLocked. This is the mechanism designed to protect
reliance on the deadline: "once anyone has deposited against a given deadline … the sponsor cannot
move it."

ConfidencePool.contributeBonus() commits value into totalBonus but never sets expiryLocked.
A bonus contribution is an irrevocable deposit and there is no bonus-withdrawal function anywhere in
the contract, yet it leaves the latch open.

// src/ConfidencePool.sol — stake() locks the deadline on first deposit
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
...
if (!expiryLocked) {
expiryLocked = true;
}
...
}
// src/ConfidencePool.sol — contributeBonus() commits value but does NOT lock
function contributeBonus(uint256 amount) external nonReentrant whenPoolNotPaused {
...
totalBonus += received;
// No `expiryLocked = true` — the deadline stays movable after the commitment.
emit BonusContributed(msg.sender, received);
}
// src/ConfidencePool.sol — setExpiry() gates only on the latch (upper bound only uint32 max)
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();
...
}

As a result, when a bonus is contributed before the first stake, the sponsor can still call
setExpiry(type(uint32).max - 1) and push the deadline decades into the future, after the
contributor's capital is already committed. Unlike a staker, a bonus contributor has no withdraw
escape hatch, so the design stipulation "exit until risk materializes" defense that neutralizes every other
pre-attack sponsor lever does not apply to them; the committed bonus cannot be reclaimed.

This issue is limited to the pre-stake window (expiryLocked == false). Once any stake is placed
the latch closes and the deadline is fixed for the bonus contributor as well.

Risk

Likelihood

  • The condition is reached whenever a bonus is contributed while expiryLocked is still false and the sponsor then calls setExpiry.

  • No special registry state is required; the sponsor may additionally pause() to guarantee no
    staker ever stakes and closes the latch.

  • The sponsor is the agreement owner, so the lever is always available to them at zero cost.

Impact

  • The bonus contributor's committed capital is locked far past the deadline it was contributed
    against — up to the uint32 ceiling (~year 2106) — with no reclaim path: there is no bonus
    withdrawal, and claimExpired reverts PoolNotExpired until the new far expiry.

  • The harm is a liveness / time-value griefing of a participant who by design has no exit; the
    capital is effectively dead for the extension period.

  • A bonus contributed to a
    pool that attracts no stakers already sweeps to recoveryAddress under normal operation, so the
    sponsor gains nothing beyond delay.

Proof of Concept

ttestPoC_ContributeBonus_DoesNotLockExpiry_SponsorMovesDeadlineAfterCommit demonstrates the asymmetry . The core assertion:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
contract ContributeBonusExpiryHunt is BaseConfidencePoolTest {
function testPoC_ContributeBonus_DoesNotLockExpiry_SponsorMovesDeadlineAfterCommit() external {
uint256 originalExpiry = pool.expiry();
// A third party seeds the reward pool, relying on the published ~31-day deadline.
uint256 B = 1_000 * ONE;
_contributeBonus(bob, B);
// The bonus is irrevocably in the pool ...
assertEq(pool.totalBonus(), B, "bonus committed");
// ... yet the reliance latch the design promises protects "deposited value" is still open.
assertEq(pool.expiryLocked(), false, "contributeBonus did NOT lock expiry");
// Sponsor now pushes the deadline to the far future (near the uint32 / year-2106 ceiling),
// long after Bob committed against the short deadline.
uint256 farExpiry = uint256(type(uint32).max) - 1; // ~2106-02-07
pool.setExpiry(farExpiry);
assertEq(pool.expiry(), farExpiry, "sponsor moved expiry post-commit");
assertGt(pool.expiry(), originalExpiry, "deadline extended");
// Bob has no way out: there is no function to withdraw a bonus contribution, and staking is
// irrelevant to him. His capital is now locked until the sponsor-chosen far expiry.
}
}

Run with:

forge test --offline --match-path "test/unit/hunt/ContributeBonusExpiryHunt.poc.t.sol"

Recommended Mitigation

Set the latch on any value-committing deposit, mirroring stake():

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 (received == 0) revert NoTokensReceived();
totalBonus += received;
+ if (!expiryLocked) expiryLocked = true;
emit BonusContributed(msg.sender, received);
}

Support

FAQs

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

Give us feedback!