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

contributeBonus` does not set `expiryLocked`, letting the owner re-price or indefinitely defer the deadline that committed bonus capital is bound to

Author Revealed upon completion

Root + Impact

Description

expiry is meant to become immutable the moment value is committed against it. stake() enforces this by latching the one-way expiryLocked flag on the first deposit, after which setExpiry() reverts. DESIGN §10 documents the guarantee: "once anyone has deposited against a given deadline (which feeds the k=2 weighting as T for the EXPIRED path), the sponsor cannot move it." The deadline is load-bearing: it is the EXPIRED-path T, and it gates the only path (claimExpired, hard-gated on block.timestamp >= expiry) that ever releases a bonus contributor's funds.

contributeBonus() is the other value-committing deposit entrypoint — same outcome == UNRESOLVED, block.timestamp < expiry, and _assertDepositsAllowed gating as stake() — but it never sets expiryLocked. So a pool that receives a bonus before its first stake leaves expiry fully owner-mutable even though real capital is already committed against that deadline. The owner can then call setExpiry() to push the deadline out arbitrarily (each call only needs newExpiry >= now + 30 days, with no upper bound below the 2106 uint32 ceiling), indefinitely deferring claimExpired — and the contributed bonus can be neither withdrawn (withdraw() only returns eligibleStake, never totalBonus) nor otherwise reclaimed by the contributor.

// stake() — latches the deadline on the first deposit
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
...
@> if (!expiryLocked) {
@> expiryLocked = true;
@> }
...
}
// contributeBonus() — a funded deposit, but NO expiryLocked latch
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());
...
totalBonus += received;
@> // <-- expiryLocked is never set here
emit BonusContributed(msg.sender, received);
}
// setExpiry() — stays open because contributeBonus never latched the flag
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();
...
expiry = uint32(newExpiry);
}

Risk

Likelihood:

Fires whenever contributeBonus is called on a pool that has no stakes yet — a stakerless or newly-created pool bootstrapping its bonus pot before stakers arrive, which is an ordinary lifecycle.

Requires no special state: the expiryLocked flag is simply never written on the bonus path, so any later setExpiry by the owner succeeds until the first stake eventually latches it (which may never happen for a bonus-only pool).

Impact:

The contributed bonus is bound to a deadline the contributor never consented to: the owner can extend expiry indefinitely (deferring claimExpired, the contributor's only release path) or shorten it, changing the effective lock-up term of committed capital after the fact.

The deadline-immutability guarantee DESIGN §10 states ("once anyone has deposited … the sponsor cannot move it") is broken for the bonus-first ordering, because a bonus contribution is a deposit yet does not freeze expiry.

Proof of Concept

Add to a test inheriting the project's BaseConfidencePoolTest and run with forge test. Passes green:

function test_contributeBonusLeavesExpiryUnlocked() external {
_contributeBonus(carol, 50 ether);
assertFalse(pool.expiryLocked(), "contributeBonus did NOT lock expiry");
// Owner freely moves the deadline the 50e18 bonus is committed against.
uint256 e0 = pool.expiry();
uint256 ts = e0 - 1 days;
for (uint256 i = 0; i < 4; i++) {
vm.warp(ts);
vm.prank(carol);
vm.expectRevert(IConfidencePool.PoolNotExpired.selector);
pool.claimExpired(); // contributor's only exit is always deferred
uint256 target = ts + 90 days;
pool.setExpiry(target); // succeeds: expiryLocked was never set
assertEq(uint256(pool.expiry()), target);
ts = target - 1 days;
}
// expiry pushed >300 days past the original while the bonus stays locked in the pool
assertGt(uint256(pool.expiry()), e0 + 300 days);
assertFalse(pool.expiryLocked());
}

Recommended Mitigation

Latch expiryLocked on the first funded deposit of either kind, mirroring stake(), so the deadline freezes as soon as any capital (stake or bonus) is committed against it.

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;
+ }
+
// Balance-diff defense-in-depth — see `stake`.
// aderyn-fp-next-line(reentrancy-state-change)
uint256 balanceBefore = stakeToken.balanceOf(address(this));
stakeToken.safeTransferFrom(msg.sender, address(this), amount);
// aderyn-fp-next-line(reentrancy-state-change)
uint256 balanceAfter = stakeToken.balanceOf(address(this));
uint256 received = balanceAfter > balanceBefore ? balanceAfter - balanceBefore : 0;
if (received == 0) revert NoTokensReceived();
totalBonus += received;
emit BonusContributed(msg.sender, received);
}

Support

FAQs

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

Give us feedback!