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

Pool owner can front-run the first stake and extend the expiry to 2106, trapping the staker’s principal

Author Revealed upon completion

Root + Impact

Description

The pool owner is permitted to modify expiry until the first stake is deposited. Once the first stake succeeds, expiryLocked is permanently set to true.

This mechanism is intended to ensure that a staker deposits against a fixed and immutable deadline. However, stake() does not let the caller specify the expiry they agreed to, and it does not protect against an expiry change between transaction signing and execution.

The mutable expiry is only locked inside stake():

function stake(uint256 amount)
external
nonReentrant
whenPoolNotPaused
{
if (amount == 0) revert InvalidAmount();
if (amount < minStake) revert BelowMinStake();
if (outcome != PoolStates.Outcome.UNRESOLVED) {
revert OutcomeAlreadySet();
}
// @> This checks whichever expiry value exists at execution time,
// @> not the value the staker observed when signing.
if (block.timestamp >= expiry) revert StakingClosed();
_assertDepositsAllowed(_observePoolState());
// @> The expiry is locked only after the owner's front-running
// @> setExpiry() transaction has already executed.
if (!expiryLocked) {
expiryLocked = true;
}
...
}

Before the first stake, the owner can set any expiry that is at least 30 days in the future and no greater than type(uint32).max:

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;
expiry = uint32(newExpiry);
emit ExpiryUpdated(oldExpiry, newExpiry);
}

There is no upper-bound duration other than the uint32 timestamp ceiling, approximately February 2106.

The following attack is therefore possible:

  1. A pool advertises a reasonable expiry, such as 31 days.

  2. The agreement is in UNDER_ATTACK, where staking is deliberately allowed.

  3. Alice submits the first stake() transaction after verifying the advertised expiry.

  4. The pool owner observes Alice's pending transaction.

  5. The owner front-runs it with:

pool.setExpiry(type(uint32).max);
  1. Alice's transaction executes successfully because the new expiry is still in the future.

  2. Alice's deposit sets expiryLocked = true, permanently locking the malicious expiry.

  3. Because the stake observed UNDER_ATTACK, riskWindowStart is set and Alice cannot withdraw.

  4. Alice also cannot use claimExpired() until approximately 2106.

The transaction does not revert when the expiry changes. Alice is silently committed to an underwriting period that may be decades longer than the one she approved.

This violates the intended staker reliance guarantee. The documentation states that the sponsor controls expiry only until the first stake and that the lock protects users who deposit against a particular deadline. An off-chain expiry check is insufficient because it is not atomic with transaction execution. :contentReference[oaicite:0]{index=0}

Risk

The malicious owner can effectively freeze the first staker's complete principal for approximately 80 years.

Once the transaction executes in UNDER_ATTACK:

  • withdraw() is permanently disabled;

  • the expiry is permanently locked at the malicious value;

  • claimExpired() cannot be used until the modified expiry;

  • the staker cannot cancel or reverse the successful deposit;

  • the moderator cannot resolve the pool while the registry remains in a nonterminal active-risk state.

The pool sponsor does not need to steal tokens directly. Extending the deadline immediately before the first stake is enough to create an effectively permanent denial of access to the victim's funds.

The impact is High because the victim's entire principal can become inaccessible for decades.

The likelihood is Medium because exploitation requires:

  • targeting the first staker;

  • observing and ordering a transaction before the victim's pending stake;

  • staking while the agreement is already in UNDER_ATTACK, or transitioning to that state before the victim can withdraw.

Proof of Concept

Add the following test to a contract inheriting BaseConfidencePoolTest:

function test_OwnerFrontRunsFirstStakeAndLocksFundsUntil2106()
external
{
uint256 amount = 100 * ONE;
uint256 expiryObservedByAlice = pool.expiry();
uint256 maliciousExpiry = type(uint32).max;
// The agreement is already in its normal active-risk state.
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.UNDER_ATTACK
);
token.mint(alice, amount);
vm.prank(alice);
token.approve(address(pool), amount);
assertFalse(pool.expiryLocked());
assertEq(pool.expiry(), expiryObservedByAlice);
// Alice has submitted stake(amount), expecting the advertised expiry.
// The owner front-runs her pending transaction.
pool.setExpiry(maliciousExpiry);
assertEq(pool.expiry(), maliciousExpiry);
assertFalse(pool.expiryLocked());
// Alice's transaction still succeeds rather than reverting because the
// expiry changed after she signed it.
vm.prank(alice);
pool.stake(amount);
assertEq(pool.eligibleStake(alice), amount);
assertEq(pool.expiry(), maliciousExpiry);
assertTrue(pool.expiryLocked());
assertGt(pool.riskWindowStart(), 0);
// The risk-window latch permanently disables withdrawal.
vm.prank(alice);
vm.expectRevert(
IConfidencePool.WithdrawsDisabled.selector
);
pool.withdraw();
// The deadline Alice originally approved no longer releases her funds.
vm.warp(expiryObservedByAlice);
vm.prank(alice);
vm.expectRevert(
IConfidencePool.PoolNotExpired.selector
);
pool.claimExpired();
// Neither possible moderator outcome is valid while the agreement remains
// in the active, nonterminal UNDER_ATTACK state.
vm.prank(moderator);
vm.expectRevert(
IConfidencePool.InvalidOutcome.selector
);
pool.flagOutcome(
PoolStates.Outcome.SURVIVED,
false,
address(0)
);
vm.prank(moderator);
vm.expectRevert(
IConfidencePool.InvalidOutcome.selector
);
pool.flagOutcome(
PoolStates.Outcome.CORRUPTED,
false,
address(0)
);
assertEq(
token.balanceOf(address(pool)),
amount,
"Alice's complete principal remains trapped"
);
assertEq(
token.balanceOf(alice),
0,
"Alice cannot recover principal"
);
assertGt(
maliciousExpiry - expiryObservedByAlice,
70 * 365 days,
"the owner extended the lock by multiple decades"
);
}

Recommended Mitigation

Allow the staker to bind their transaction to the pool parameters they reviewed.

The simplest mitigation is to require an expected expiry:

error ExpiryChanged(
uint256 expectedExpiry,
uint256 actualExpiry
);
function stake(
uint256 amount,
uint256 expectedExpiry
) external nonReentrant whenPoolNotPaused {
if (expiry != expectedExpiry) {
revert ExpiryChanged(
expectedExpiry,
expiry
);
}
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;
}
...
}

A more complete interface should also include a transaction deadline and an explicit active-risk preference:

function stake(
uint256 amount,
uint256 expectedExpiry,
uint256 transactionDeadline,
bool acceptActiveRisk
) external;

The function should revert when:

if (block.timestamp > transactionDeadline) {
revert TransactionExpired();
}
if (expiry != expectedExpiry) {
revert ExpiryChanged(
expectedExpiry,
expiry
);
}
if (
!acceptActiveRisk
&& riskWindowStart != 0
) {
revert ActiveRiskNotAccepted();
}

An alternative is to permanently lock expiry before accepting public deposits, through a separate owner finalization action:

function finalizePoolConfiguration()
external
onlyOwner
{
expiryLocked = true;
}

Staking would only become available after configuration has been finalized.

The critical requirement is that a pending stake must revert when the expiry differs from the value the staker authorized.

Support

FAQs

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

Give us feedback!