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

Bonus contributions do not lock expiry, allowing sponsors to shorten the pool term after third-party capital is committed

Author Revealed upon completion

Root + Impact

Description

The pool allows third parties to permissionlessly contribute bonus capital that will later be distributed to successful stakers.

However, bonus contributions do not lock expiry. Only the first stake() call locks the pool term. This allows the sponsor to receive bonus funding for one advertised duration, then shorten the expiry before any staker enters.

As a result, third-party contributors can fund a long-duration confidence market, while the sponsor later converts it into a materially shorter one using the same bonus pool.

This is not a generic “owner can change expiry before first stake” complaint.

The issue is narrower: the contract accepts third-party bonus capital through contributeBonus() while leaving expiry mutable because expiryLocked is only set inside stake(). That creates a mismatch between when external capital becomes committed and when core economic terms become immutable.

Root Cause

expiryLocked is only set in stake(), while contributeBonus() leaves expiry mutable.
This means real third-party capital can enter the pool while the sponsor still retains unilateral control over the underwriting horizon.

function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
...
if (!expiryLocked) {
// only stake locks expiry
expiryLocked = true;
}
...
}
function contributeBonus(uint256 amount) external nonReentrant whenPoolNotPaused {
...
// bonus capital enters while expiry is still mutable
totalBonus += received;
}
function setExpiry(uint256 newExpiry) external onlyOwner {
if (expiryLocked) revert ExpiryLocked();
...
expiry = uint32(newExpiry);
}

Risk

Likelihood:

  • Bonus contributions are permissionless and can occur before the first stake.

  • During that period, the sponsor still has full access to setExpiry() because expiryLocked remains false.

Impact:

  • During that period, the sponsor still has full access to setExpiry() because expiryLocked remains false.

  • This changes the economics of the pool after outside capital has already been committed.

Proof of Concept

The attached Foundry test demonstrates that a sponsor can set a long advertised expiry, accept bonus funding, keep expiryLocked == false, shorten the pool to the minimum allowed term, and cause the same bonus to resolve under the shortened market.

function testSubmission_bonusContributionDoesNotLockExpiry_allowsEarlierBonusSweep() external {
uint256 advertisedExpiry = block.timestamp + 180 days;
pool.setExpiry(advertisedExpiry);
assertEq(pool.expiry(), advertisedExpiry, "pool advertises the longer term before bonus funding");
_contributeBonus(carol, 50 * ONE);
assertFalse(pool.expiryLocked(), "bonus-only funding must leave expiry mutable");
uint256 shortenedExpiry = block.timestamp + 30 days;
pool.setExpiry(shortenedExpiry);
assertEq(pool.expiry(), shortenedExpiry, "owner shortened the term after bonus funding");
assertLt(shortenedExpiry, advertisedExpiry, "expiry moved materially earlier");
vm.warp(shortenedExpiry);
vm.prank(dave);
pool.claimExpired();
uint256 recoveryBefore = token.balanceOf(recovery);
vm.prank(dave);
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery) - recoveryBefore, 50 * ONE, "bonus sweeps at the shortened expiry");
assertEq(token.balanceOf(address(pool)), 0, "pool drained after the accelerated sweep");
}
  1. The sponsor configures the pool with a long expiry (e.g. 180 days).

  2. A third party contributes bonus capital to attract future stakers.

  3. No one has staked yet, so expiryLocked remains false.

  4. The sponsor calls setExpiry() and shortens the term to the minimum allowed lead (e.g. 30 days).

  5. The pool later reaches expiry under the shortened term, and the bonus is now distributed or swept under a materially different market duration than the contributor observed when funding it.

Result:
A contributor can fund a long-duration confidence market, but the sponsor can still reprice it into a much shorter-duration market after bonus capital has already entered.

Recommended Mitigation

The mitigation is to lock expiry when bonus capital is committed, not only when stake capital is committed. This ensures the sponsor cannot change the underwriting duration after outside economic value has already entered the pool.

function contributeBonus(uint256 amount) external nonReentrant whenPoolNotPaused {
if (amount == 0) revert InvalidAmount();
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
if (block.timestamp >= expiry) revert StakingClosed();
_assertDepositsAllowed(_observePoolState());
+ if (!expiryLocked) {
+ expiryLocked = true;
+ }
uint256 balanceBefore = stakeToken.balanceOf(address(this));
stakeToken.safeTransferFrom(msg.sender, address(this), amount);
uint256 balanceAfter = stakeToken.balanceOf(address(this));
uint256 received = balanceAfter > balanceBefore ? balanceAfter - balanceBefore : 0;
if (received == 0) revert NoTokensReceived();
totalBonus += received;
}

Support

FAQs

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

Give us feedback!