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

L-01 — `contributeBonus` does not latch `expiryLocked`, letting the sponsor move `expiry` after bonus is committed

Author Revealed upon completion

Root + Impact

Description

stake sets the one-way expiryLocked latch on its first call, permanently preventing the sponsor from calling setExpiry. Per docs/DESIGN.md §10 this protects staker reliance on expiry, which feeds the k=2 bonus formula as T on the EXPIRED path. contributeBonus accepts funds through the same _assertDepositsAllowed gate but does not set expiryLocked. As a result, while no stake has yet occurred, the sponsor can accept a bonus contribution and then freely extend or shorten expiry (down to the _MIN_EXPIRY_LEAD floor), changing how long the contributed funds are exposed and when the pool can resolve.

The latch's DESIGN rationale is framed entirely around the staker's T-weighting; contributors hold no T-position, so the protection was never extended to them. §10 does not state this asymmetry is intentional.

Risk

Likelihood: Low — requires a bonus-before-stake ordering and a sponsor motivated to reprice the timeline.

Impact: Low — no principal theft. The sponsor already holds strictly larger levers over bonus funds (pause on the deposit paths, bad-faith CORRUPTED sweep to their own recovery address). This is pure timeline manipulation of already-committed contributor funds.

Proof of Concept

An independent user contributes bonus first. The sponsor then changes the pool's expiry. This would not be possible if a staker had deposited first.

function test_L01_contributeBonusDoesNotLockExpiry() external {
_contributeBonus(alice, 1_000 * ONE);
assertFalse(pool.expiryLocked(), "bonus did not latch expiryLocked");
uint256 newExpiry = block.timestamp + 365 days;
pool.setExpiry(newExpiry); // owner == address(this) in the base test
assertEq(pool.expiry(), newExpiry, "sponsor moved expiry after bonus was committed");
}

Recommended Mitigation

Mirror stake's latch, or document the asymmetry in DESIGN §10 the way §5/§9 close off other recurring false positives.

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;
+ }

Support

FAQs

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

Give us feedback!