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

expiry has a floor but no sane ceiling, so an over-long deadline can trap staker principal.

Author Revealed upon completion

The only cap on expiry is the year-2106 type limit, so a far-future deadline plus a stuck active-risk agreement leaves stakers unable to exit until that date.

Description

  • The sponsor sets expiry with a 30-day minimum lead; after expiry, anyone can call claimExpired to return principal.

  • The upper bound is only type(uint32).max, so a sponsor can set a deadline decades out, and once staking locks it, principal has no earlier release than that deadline.

if (recoveryAddress_ == address(0)) revert InvalidRecoveryAddress();
if (expiry_ < block.timestamp + _MIN_EXPIRY_LEAD) revert ExpiryTooSoon();
@> if (expiry_ > type(uint32).max) revert ExpiryTooFar();
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 (
@> riskWindowStart != 0
|| (state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
&& state != IAttackRegistry.ContractState.ATTACK_REQUESTED)
) {
revert WithdrawsDisabled();
}

Risk

Likelihood:

  • Occurs when a sponsor sets a very distant expiry, and the agreement then reaches UNDER_ATTACK and stays there without ever hitting a terminal state.

Impact:

  • Withdraw is disabled from UNDER_ATTACK onward, so stakers cannot leave.


  • The moderator has no terminal state to flag, and claimExpired stays blocked until the far-off expiry, so principal sits idle for the whole term.

Proof of Concept

function testExpiryHasNoCeilingTrappingPrincipalForDecades() external {
// Sponsor sets expiry at the outer edge of what the type permits (~year 2106) instead of
// a sane term. Only the uint32 ceiling is enforced — there is no MAX_TERM check.
uint256 farExpiry = type(uint32).max - 1;
MockSafeHarborRegistry factoryRegistry = new MockSafeHarborRegistry();
factoryRegistry.setAttackRegistry(address(attackRegistry));
address agreementOwner = makeAddr("agreementOwner");
MockAgreement mockAgreement = new MockAgreement(agreementOwner);
factoryRegistry.setAgreementValid(address(mockAgreement), true);
ConfidencePool implementation = new ConfidencePool();
ConfidencePoolFactory factoryImpl = new ConfidencePoolFactory();
ERC1967Proxy proxy = new ERC1967Proxy(
address(factoryImpl),
abi.encodeCall(
ConfidencePoolFactory.initialize, (address(factoryRegistry), address(implementation), moderator)
)
);
ConfidencePoolFactory factory = ConfidencePoolFactory(address(proxy));
factory.setStakeTokenAllowed(address(token), true);
// Creation itself succeeds — nothing in initialize() rejects a decades-out deadline.
address farPoolAddr = factory.createPool(
address(mockAgreement), address(token), farExpiry, ONE, recovery, _defaultScope()
);
ConfidencePool farPool = ConfidencePool(farPoolAddr);
// Alice stakes normally, locking in expiryLocked = true.
token.mint(alice, 100 * ONE);
vm.startPrank(alice);
token.approve(address(farPool), 100 * ONE);
farPool.stake(100 * ONE);
vm.stopPrank();
// Agreement enters active risk and gets stuck there permanently — it never reaches
// PRODUCTION or CORRUPTED (the only two states flagOutcome/claimExpired can resolve on).
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
farPool.pokeRiskWindow();
// Exit path 1: withdraw() is permanently disabled once riskWindowStart is set.
vm.prank(alice);
vm.expectRevert(IConfidencePool.WithdrawsDisabled.selector);
farPool.withdraw();
// Exit path 2: claimExpired() can't touch it either — nowhere near `farExpiry`.
vm.prank(alice);
vm.expectRevert(IConfidencePool.PoolNotExpired.selector);
farPool.claimExpired();
// Exit path 3: the moderator can't flag an outcome — the registry state is UNDER_ATTACK,
// which is neither PRODUCTION nor CORRUPTED, so both SURVIVED and CORRUPTED revert.
vm.prank(moderator);
vm.expectRevert(IConfidencePool.InvalidOutcome.selector);
farPool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
vm.prank(moderator);
vm.expectRevert(IConfidencePool.InvalidOutcome.selector);
farPool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
// All three resolution paths are closed simultaneously. Alice's 100 tokens sit in the
// pool with the only theoretical release date being `farExpiry`, decades away.
assertEq(token.balanceOf(address(farPool)), 100 * ONE, "principal stuck in pool");
assertGt(farExpiry - block.timestamp, 365 days * 50, "no exit for at least 50 years");
}

Recommended Mitigation

Add if (newExpiry > block.timestamp + MAX_TERM) revert ExpiryTooFar(); with a sensible MAX_TERM (e.g. one to two years), mirroring the existing minimum-lead floor.

Support

FAQs

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

Give us feedback!