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

contributeBonus commits non-refundable value but does not lock expiry, unlike stake

Author Revealed upon completion

Description

Both stake and contributeBonus commit real, non-refundable value to the pool, and the sponsor's ability to move expiry is meant to end once value has been committed against a deadline (the expiryLocked one-way latch guards setExpiry).

stake sets expiryLocked = true on the first deposit, but the sibling value-committing entrypoint contributeBonus never sets it. As a result, after any amount of bonus has been irrevocably committed — but before the first stake — the sponsor-owner can still move expiry, so the two value-committing paths protect depositor reliance asymmetrically.

// ConfidencePool.sol — stake()
if (!expiryLocked) {
@> expiryLocked = true; // stake freezes the term
}
// ConfidencePool.sol — contributeBonus()
...
totalBonus += received;
@> // (no expiryLocked write) // bonus commits value but leaves the term mutable
emit BonusContributed(msg.sender, received);
// ConfidencePool.sol — setExpiry()
@> if (expiryLocked) revert ExpiryLocked(); // gated solely on the latch stake sets

Risk

Likelihood:

  • Occurs whenever a third party funds the bonus via contributeBonus before the first stake, and the sponsor-owner subsequently calls setExpiry (permitted, since no stake has locked the term) to reschedule the deadline the bonus was committed against.

Impact:

  • A bonus contributor's non-refundable capital is subject to owner-controlled expiry mutation until the first stake; the two value-committing entrypoints enforce the reliance latch inconsistently. No staker principal is at risk (the first staker locks expiry before committing), which bounds this to low severity.

Proof of Concept

test/poc/ConfidencePoolPoC.t.sol (passes against the contest repo):

function test_PoC_A_contributeBonusDoesNotLockExpiry() public {
_contributeBonus(dave, 10 * ONE); // non-refundable value committed
assertFalse(pool.expiryLocked(), "bonus did not lock expiry");
// Owner can still reschedule expiry AFTER value is committed.
uint256 newExpiry = block.timestamp + 60 days;
pool.setExpiry(newExpiry);
assertEq(pool.expiry(), uint32(newExpiry), "owner moved expiry post-bonus");
// Contrast: the first stake locks expiry permanently.
_stake(alice, ONE);
assertTrue(pool.expiryLocked(), "stake locked expiry");
vm.expectRevert(IConfidencePool.ExpiryLocked.selector);
pool.setExpiry(block.timestamp + 90 days);
}

Recommended Mitigation

Mirror stake so any committed value freezes the term, or explicitly document that bonus contributions do not lock expiry:

function contributeBonus(uint256 amount) external nonReentrant whenPoolNotPaused {
...
+ if (!expiryLocked) {
+ expiryLocked = true;
+ }
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!