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

Sponsor-Manipulable Pre-Stake Expiry Window Creates a Moving Target for Would-Be Stakers

Author Revealed upon completion

Description

The pool's expiry determines when staking closes, when claimExpired can mechanically resolve the pool, and critically the upper bound ( T ) in the k=2 time-weighted bonus formula for EXPIRED resolutions. The sponsor can change expiry freely via setExpiry until the first stake() call flips the one-way expiryLocked latch.

The _MIN_EXPIRY_LEAD constant (30 days) enforces a floor on how close to block.timestamp the new expiry can be, but places no constraint on the magnitude of compression. A sponsor can advertise a generous 365-day expiry, attract staker interest, then call setExpiry(block.timestamp + 30 days) moments before the first stake, permanently locking all subsequent stakers into the compressed deadline.

// @> Only gate: blocks changes AFTER first stake. Unlimited changes before that.
if (expiryLocked) revert ExpiryLocked();
// @> Floor: 30 days ahead. No constraint on compression magnitude vs. current expiry.
if (newExpiry < block.timestamp + _MIN_EXPIRY_LEAD) revert ExpiryTooSoon();
if (newExpiry > type(uint32).max) revert ExpiryTooFar();
expiry = uint32(newExpiry);

Risk

Likelihood: Low

  • Sponsor-initiated (onlyOwner), not an external attack vector

  • Sponsor has a business disincentive: compressed expiry reduces pool attractiveness and may leave the pool under-subscribed

  • The 30-day floor prevents instant expiry, but a reduction from 365 days → 30 days is a 92% compression in a single call

  • Most exposed: stakers using frontends that cache pool parameters rather than re-reading on-chain state immediately pre-stake

Impact:

  • Compressed expiry changes ( T ) in the k=2 formula, altering every staker's bonus share for EXPIRED resolutions

  • For SURVIVED resolutions, ( T = \text{riskWindowEnd} ) (capped at expiry), so a compressed expiry also caps the risk-window upper bound

  • Principal is never at risk this affects bonus distribution only

  • On-chain state is transparent: stakers who read expiry in their stake transaction are unaffected

Proof of Concept

The PoC below demonstrates two scenarios. First, a sponsor deploys a pool with a 365-day expiry, compresses it to 30 days, and the first staker locks it permanently — the sponsor cannot revert the change. Second, the sponsor yo-yos the expiry multiple times before the first stake, showing the expiry is fully fluid until the latch flips.

// 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 {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.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 PoC_ExpiryCompression is Test {
uint256 internal constant ONE = 1e18;
MockERC20 internal token;
MockAttackRegistry internal attackRegistry;
MockSafeHarborRegistry internal safeHarborRegistry;
MockAgreement internal agreementContract;
ConfidencePool internal pool;
address internal agreement;
address internal moderator = makeAddr("moderator");
address internal recovery = makeAddr("recovery");
address internal alice = makeAddr("alice");
address internal bob = makeAddr("bob");
address internal carol = makeAddr("carol");
address internal sponsor = makeAddr("sponsor");
function setUp() public {
token = new MockERC20();
attackRegistry = new MockAttackRegistry();
safeHarborRegistry = new MockSafeHarborRegistry();
agreementContract = new MockAgreement(sponsor);
agreement = address(agreementContract);
agreementContract.setContractInScope(address(0xC0FFEE), true);
safeHarborRegistry.setAttackRegistry(address(attackRegistry));
safeHarborRegistry.setAgreementValid(agreement, true);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
ConfidencePool implementation = new ConfidencePool();
pool = ConfidencePool(Clones.clone(address(implementation)));
// Sponsor deploys pool with a generous 1-year expiry
vm.prank(sponsor);
pool.initialize(
agreement,
address(token),
address(safeHarborRegistry),
moderator,
block.timestamp + 365 days, // generous: 1 year
ONE,
recovery,
sponsor,
_defaultScope()
);
}
function _defaultScope() internal pure returns (address[] memory accounts) {
accounts = new address[](1);
accounts[0] = address(0xC0FFEE);
}
function _stake(address user, uint256 amount) internal {
token.mint(user, amount);
vm.startPrank(user);
token.approve(address(pool), amount);
pool.stake(amount);
vm.stopPrank();
}
function testExpiryCompressionBeforeFirstStake() external {
// Step 1: Alice evaluates the pool off-chain and sees expiry = now + 365 days
uint256 expirySeenByAlice = pool.expiry();
assertEq(expirySeenByAlice, block.timestamp + 365 days, "generous expiry advertised");
// Step 2: Sponsor compresses expiry to the minimum 30 days
vm.prank(sponsor);
pool.setExpiry(block.timestamp + 30 days);
uint256 compressedExpiry = pool.expiry();
assertEq(compressedExpiry, block.timestamp + 30 days, "expiry compressed by sponsor");
// Step 3: Alice stakes, unaware of the change. expiryLocked flips.
_stake(alice, 100 * ONE);
assertTrue(pool.expiryLocked(), "expiry now locked");
// Step 4: Alice cannot undo the expiry. The pool now has 30 days, not 365.
vm.prank(sponsor);
vm.expectRevert(IConfidencePool.ExpiryLocked.selector);
pool.setExpiry(block.timestamp + 365 days); // sponsor can't revert it either
// Step 5: Demonstrate bonus impact. If the pool expires without a terminal
// registry state, T = expiry = now + 30 days for EXPIRED resolution.
// Alice's expected bonus economics were based on T being a year out.
// With a 30-day window, the time for the risk window to open and accrue
// is compressed, affecting her bonus outcome vs. her expectations.
vm.warp(block.timestamp + 31 days); // just past the compressed expiry
vm.prank(alice);
pool.claimExpired();
// Alice gets her principal back but the compressed expiry means
// the bonus formula used T = original_expiry + 30 days, not + 365 days.
// If the risk window had opened, bonus weighting would differ materially.
}
function testSponsorCanChangeExpiryMultipleTimesBeforeFirstStake() external {
// Sponsor can yo-yo the expiry as many times as they want pre-stake
vm.startPrank(sponsor);
pool.setExpiry(block.timestamp + 90 days);
assertEq(pool.expiry(), block.timestamp + 90 days);
pool.setExpiry(block.timestamp + 180 days);
assertEq(pool.expiry(), block.timestamp + 180 days);
pool.setExpiry(block.timestamp + 31 days);
assertEq(pool.expiry(), block.timestamp + 31 days);
vm.stopPrank();
// Until the first stake lands, everything is fluid
_stake(alice, ONE);
assertTrue(pool.expiryLocked());
// Now it's locked
vm.prank(sponsor);
vm.expectRevert(IConfidencePool.ExpiryLocked.selector);
pool.setExpiry(block.timestamp + 180 days);
}
}

Recommended Mitigation

Add a constraint on how much the expiry can be shortened in a single setExpiry call, preventing large compression jumps while allowing legitimate incremental adjustments:

+ uint256 private constant _MAX_EXPIRY_COMPRESSION = 60 days;
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();
+ if (newExpiry < expiry && expiry - newExpiry > _MAX_EXPIRY_COMPRESSION) {
+ revert ExpiryCompressionTooLarge();
+ }
uint256 oldExpiry = expiry;
expiry = uint32(newExpiry);
emit ExpiryUpdated(oldExpiry, newExpiry);
}

This bounds the worst case: a staker evaluating a pool with a 365-day expiry knows it cannot shrink below 305 days before they stake (assuming one change). Combined with the existing expiryLocked latch on first stake, this closes the compression window without adding timelock complexity or gas overhead.

Support

FAQs

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

Give us feedback!