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

L-02. `expiryLocked` not set on `contributeBonus()`, allowing sponsor to extend deadline after bonus contribution

Author Revealed upon completion

Root + Impact

The expiryLocked flag is set inside stake() at line 229 but never inside contributeBonus(). A sponsor can accept bonus contributions while expiryLocked is still false, then extend the deadline via setExpiry() before any staker deposits. The bonus pool becomes locked for longer than the contributor intended.

Description

The expiry parameter defines when staking closes (line 226) and anchors the T upper bound in the k=2 formula for the EXPIRED resolution path (line 569). Once any staker deposits, expiry is locked to prevent the sponsor from moving the goalpost (DESIGN.md §10):

// stake() — lines 229-231
if (!expiryLocked) {
expiryLocked = true; // @> only set here
}

contributeBonus() never touches expiryLocked:

// contributeBonus() — lines 266-285
function contributeBonus(uint256 amount) external nonReentrant whenPoolNotPaused {
// ...
// @> expiryLocked is never checked or set
}

Before any staker deposits, a bonus contributor can send funds. The sponsor sees expiryLocked == false and calls setExpiry() to push the deadline forward — by days, months, or years. The bonus is locked for the extended duration. If the sponsor is malicious or compromised, they can extend expiry up to type(uint32).max, trapping the bonus pool until the new deadline passes. Bonus contributors have no claim rights and cannot withdraw their contribution.

The design intent (DESIGN.md §10) — "protects staker reliance" — is incomplete. It protects stakers but not bonus contributors, even though both deposit funds into the pool.

Risk

Likelihood:

  • A bonus contributor sends funds to the pool before any staker has deposited. The expiryLocked flag is still false.

  • The pool sponsor calls setExpiry() to extend the deadline after the bonus contribution. This passes all guards — expiryLocked is false and the caller is the owner.

Impact:

  • The bonus pool is locked for longer than the contributor anticipated. The contributor cannot withdraw their contribution and has no recourse.

  • Pool resolution is delayed for all participants. If the extension pushes expiry past the moderation grace window, the auto-CORRUPTED backstop in claimExpired() (which requires block.timestamp >= expiry + 180 days) is correspondingly delayed.

Proof of Concept

The test uses the default pool from BaseConfidencePoolTest. Carol contributes 1000 tokens as bonus before any staker deposits.

Timeline and math:

  1. Carol calls contributeBonus(1000 * ONE). The pool's totalBonus increases to 1000. No staker has deposited yet.

  2. expiryLocked returns false — the lock was never triggered because stake() was never called.

  3. The sponsor calls setExpiry(originalExpiry + 90 days). The call succeeds because expiryLocked is false and the caller is the owner.

  4. pool.expiry() now returns a timestamp 90 days later than originally set.

  5. Carol's 1000-token bonus is locked for an additional 90 days. No staker has deposited, so claimExpired (the only path that pays bonus) cannot be called until the new expiry passes.

Contrast — stake correctly locks expiry: When Alice stakes first, expiryLocked becomes true. The subsequent setExpiry(originalExpiry + 90 days) reverts with ExpiryLocked. This confirms the lock works — but only for stake(), not for contributeBonus().

Test Output:

[PASS] test_L2_expiryExtendedAfterBonus() (gas: 167619)
[PASS] test_L2_expiryLockedAfterFirstStake() (gas: 296018)
[PASS] test_L2_bonusThenStakeLocksExpiry() (gas: 342368)

All three tests pass: expiry is extended after a bonus-only contribution, but correctly locked after any stake.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract TestL2_ExpiryLockedContributeBonus is BaseConfidencePoolTest {
/// @notice sponsor extends expiry after a bonus contribution, before any stake.
function test_L2_expiryExtendedAfterBonus() external {
uint256 originalExpiry = pool.expiry();
_contributeBonus(carol, 1000 * ONE);
assertEq(pool.totalBonus(), 1000 * ONE);
assertEq(pool.expiryLocked(), false);
vm.prank(address(this)); // owner in setUp
pool.setExpiry(originalExpiry + 90 days);
assertGt(pool.expiry(), originalExpiry);
// Carol's 1000 bonus locked for 90 extra days
}
/// @notice after any stake, expiryLocked prevents extension.
function test_L2_expiryLockedAfterFirstStake() external {
uint256 originalExpiry = pool.expiry();
_stake(alice, 100 * ONE);
assertEq(pool.expiryLocked(), true);
vm.prank(address(this));
vm.expectRevert(IConfidencePool.ExpiryLocked.selector);
pool.setExpiry(originalExpiry + 90 days);
}
/// @notice even with a bonus contribution, the first stake locks expiry.
function test_L2_bonusThenStakeLocksExpiry() external {
uint256 originalExpiry = pool.expiry();
_contributeBonus(carol, 1000 * ONE);
_stake(alice, 100 * ONE);
assertEq(pool.expiryLocked(), true);
vm.prank(address(this));
vm.expectRevert(IConfidencePool.ExpiryLocked.selector);
pool.setExpiry(originalExpiry + 90 days);
}
}

Recommended Mitigation

Set expiryLocked in contributeBonus() identically to how it is set in stake(). Any deposit that creates an economic commitment to the pool's deadline should lock the deadline.

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;
+ }
// ... transfer, totalBonus += received ...
}

This ensures the first deposit of any kind — stake or bonus — locks the pool's deadline. A sponsor can only set expiry before any funds have been committed.

Support

FAQs

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

Give us feedback!