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

Pre-stake expiry extension inflates k=2 bonus interval

Author Revealed upon completion

Root + Impact

The k=2 bonus interval is artificially widened by the owner before any staker commits, inflating future depositors' bonus shares at the expense of bonus contributors. Self-limiting: the first stake permanently locks the expiry.

Description

  • expiry is owner-mutable until the first stake, after which expiryLocked engages and the deadline is permanently frozen. This protects stakers who committed to a specific term. The k=2 bonus interval is (T - riskWindowStart)².

  • Between _markRiskWindowStart (which caps at expiry) and the first stake, the owner can extend expiry. This widens the k=2 bonus interval for future depositors, since T (the terminal observation) is also capped at expiry. A staker depositing after the extension earns a larger bonus share than the original pool parameters specified. Self-limiting: the first stake() call locks expiry.

// ConfidencePool.sol:setExpiry — line 623
@> if (expiryLocked) revert ExpiryLocked(); // only locked AFTER first stake
// ConfidencePool.sol:_markRiskWindowStart — line 807
@> if (t > expiry) t = expiry; // capped at current (pre-extension) expiry

Risk

Likelihood:

Reason 1 — `pokeRiskWindow()` seals `riskWindowStart` before any stake occurs. The owner then extends `expiry` before the first depositor stakes.
Reason 2 — No privileged timing is required. The owner simply calls `setExpiry` after the risk window opens but before anyone stakes. First stake locks it permanently.

Impact:

Impact 1 — The bonus accrual interval is inflated. A staker who deposits after the extension earns a larger bonus share than the original pool parameters would have granted.
Impact 2 — Self-limiting: the first `stake()` call permanently locks `expiry`. Future stakers can verify the current `expiry` on-chain before depositing.

Proof of Concept

Runnable Proof of Concept

function test_LD7_PreStakeExpiryExtension() public {
assertFalse(pool.expiryLocked());
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
uint256 riskStart = pool.riskWindowStart();
uint256 originalExpiry = pool.expiry();
pool.setExpiry(originalExpiry + 180 days);
_stake(alice, 100 * ONE);
vm.warp(block.timestamp + 90 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertGt(pool.outcomeFlaggedAt(), riskStart);
}

Forge output:

[PASS] test_LD7_PreStakeExpiryExtension()
Original expiry: 1752678400
Extended expiry: 1768230400
riskWindowStart: 1750000000
T (outcomeFlaggedAt): 1757776000

Recommended Mitigation

Lock expiry when the risk window opens, not just on the first stake. This prevents the owner from extending the deadline after riskWindowStart has been sealed, keeping the k=2 bonus interval consistent with the pool parameters that were visible when the risk window was first observed.

function setExpiry(uint256 newExpiry) external onlyOwner {
- if (expiryLocked) revert ExpiryLocked();
+ if (expiryLocked || riskWindowStart != 0) revert ExpiryLocked();
// ...
}

Support

FAQs

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

Give us feedback!