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

First staker cannot bind the pool expiry they verified before depositing

Author Revealed upon completion

Root + Impact

Description

ConfidencePool intentionally lets the pool owner update expiry until the first successful stake. After the first stake, stake() sets expiryLocked = true, and later setExpiry() calls revert.

The issue is that stake() accepts only an amount and does not let the first staker provide an expected or minimum expiry. A sponsor can change expiry after the staker inspected the pool but before the staker's first deposit is ordered, and the deposit will still succeed while locking the sponsor-mutated deadline.

function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
if (amount == 0) revert InvalidAmount();
if (amount < minStake) revert BelowMinStake();
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
// @> The staker cannot bind the expiry value they inspected before signing.
if (block.timestamp >= expiry) revert StakingClosed();
_assertDepositsAllowed(_observePoolState());
if (!expiryLocked) {
// @> The first successful stake locks whichever expiry is current at execution time.
expiryLocked = true;
}
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();
uint256 oldExpiry = expiry;
// @> The owner can still change expiry before any stake has executed.
expiry = uint32(newExpiry);

Risk

Likelihood:

  • The condition occurs when the pool owner changes expiry before the first successful stake is ordered.

  • The first staker's transaction has no expiry precondition, so it can succeed against the new value as long as the mutated expiry is still in the future and satisfies the 30-day minimum lead rule.

Impact:

  • The first staker can deposit into a pool term different from the one they inspected before signing.

  • Extending the expiry can lengthen the time before mechanical EXPIRED resolution when withdrawal later becomes disabled by active-risk registry state; shortening it can reduce the expected term and alter the staker's bonus/risk assumptions.

Proof of Concept

Clone and pin the audited repository:

git clone https://github.com/CodeHawks-Contests/2026-07-bc-confidence-pools.git
cd 2026-07-bc-confidence-pools
git checkout 58e8ba4ce3f3277866e4926f3140e597f9554a1e
git submodule update --init --recursive

Save the following file as test/poc/ExpiryMutabilityCounterexample.t.sol:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test} from "forge-std/Test.sol";
import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {MockAgreement} from "test/mocks/MockAgreement.sol";
import {MockAttackRegistry} from "test/mocks/MockAttackRegistry.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
contract ExpiryMutabilityCounterexample is Test {
uint256 internal constant ONE = 1e18;
uint256 internal constant BASE_TIMESTAMP = 1_750_000_000;
address internal constant SCOPE_ACCOUNT = address(0xC0FFEE);
MockERC20 internal token;
MockAttackRegistry internal attackRegistry;
MockSafeHarborRegistry internal safeHarborRegistry;
MockAgreement internal agreementContract;
ConfidencePool internal pool;
address internal moderator = makeAddr("moderator");
address internal recovery = makeAddr("recovery");
address internal alice = makeAddr("alice");
function setUp() public {
vm.warp(BASE_TIMESTAMP);
token = new MockERC20();
attackRegistry = new MockAttackRegistry();
safeHarborRegistry = new MockSafeHarborRegistry();
agreementContract = new MockAgreement(address(this));
agreementContract.setContractInScope(SCOPE_ACCOUNT, true);
safeHarborRegistry.setAttackRegistry(address(attackRegistry));
safeHarborRegistry.setAgreementValid(address(agreementContract), true);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
ConfidencePool implementation = new ConfidencePool();
pool = ConfidencePool(Clones.clone(address(implementation)));
pool.initialize(
address(agreementContract),
address(token),
address(safeHarborRegistry),
moderator,
block.timestamp + 31 days,
ONE,
recovery,
address(this),
_scope()
);
}
function testSponsorCanChangeExpiryBeforeFirstStakeAndFirstStakeLocksIt() external {
uint256 inspectedExpiry = pool.expiry();
uint256 sponsorExpiry = block.timestamp + 90 days;
assertFalse(pool.expiryLocked(), "expiry starts mutable before first stake");
pool.setExpiry(sponsorExpiry);
assertEq(pool.expiry(), sponsorExpiry, "owner-selected expiry is applied before first stake");
assertTrue(pool.expiry() != inspectedExpiry, "staker's stale read can differ from execution state");
token.mint(alice, 10 * ONE);
vm.startPrank(alice);
token.approve(address(pool), 10 * ONE);
pool.stake(10 * ONE);
vm.stopPrank();
assertTrue(pool.expiryLocked(), "first successful stake locks expiry");
assertEq(pool.expiry(), sponsorExpiry, "stake locked the current on-chain expiry");
assertEq(pool.eligibleStake(alice), 10 * ONE, "stake succeeded under the mutated expiry");
vm.expectRevert(IConfidencePool.ExpiryLocked.selector);
pool.setExpiry(block.timestamp + 120 days);
}
function testSponsorCannotSetImmediateExpiryToTrapFirstStake() external {
uint256 inspectedExpiry = pool.expiry();
vm.expectRevert(IConfidencePool.ExpiryTooSoon.selector);
pool.setExpiry(block.timestamp + 1 days);
assertEq(pool.expiry(), inspectedExpiry, "invalid near-term expiry was not applied");
assertFalse(pool.expiryLocked(), "failed expiry update does not lock the pool");
}
function _scope() internal pure returns (address[] memory accounts) {
accounts = new address[](1);
accounts[0] = SCOPE_ACCOUNT;
}
}

Run the PoC with Foundry:

forge test --match-path test/poc/ExpiryMutabilityCounterexample.t.sol -vvv

Observed output summary with forge Version: 1.7.2-nightly, Solidity 0.8.26:

Ran 2 tests for test/poc/ExpiryMutabilityCounterexample.t.sol:ExpiryMutabilityCounterexample
[PASS] testSponsorCanChangeExpiryBeforeFirstStakeAndFirstStakeLocksIt()
[PASS] testSponsorCannotSetImmediateExpiryToTrapFirstStake()
Suite result: ok. 2 passed; 0 failed; 0 skipped
Ran 1 test suite: 2 tests passed, 0 failed, 0 skipped (2 total tests)

The first passing test shows that the sponsor changes expiry from the value Alice inspected, Alice's later stake() succeeds, and the first stake locks the mutated expiry. The second passing test shows the main bound: the sponsor cannot set a near-immediate expiry because setExpiry() reverts with ExpiryTooSoon().

Recommended Mitigation

Consider adding an expiry precondition to stake() so a depositor can bind the relevant pool term they inspected before signing. One approach is to require an exact expected expiry:

-function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
+function stake(uint256 amount, uint256 expectedExpiry) external nonReentrant whenPoolNotPaused {
if (amount == 0) revert InvalidAmount();
if (amount < minStake) revert BelowMinStake();
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
+ if (expiry != expectedExpiry) revert ExpiryChanged();
if (block.timestamp >= expiry) revert StakingClosed();
_assertDepositsAllowed(_observePoolState());

If exact matching is too strict for integrations, a minExpiry or [minExpiry, maxExpiry] guard could provide slippage-style protection while still allowing stakers to reject materially different pool terms.

Support

FAQs

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

Give us feedback!