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

`contributeBonus` omits the `expiryLocked` reliance latch that `stake` sets, leaving `expiry` sponsor-mutable against irrevocably committed third-party capital

Author Revealed upon completion

Root + Impact

Root causecontributeBonus (src/ConfidencePool.sol:266-285) takes an irrevocable deposit without setting the expiryLocked reliance latch that its sibling deposit entrypoint stake sets at :229-231. setExpiry (:622-631) gates on nothing else, so the latch never fires for a bonus-only pool.

Impact — The sponsor moves expiry to type(uint32).max (year 2106) after a third-party Bonus Contributor's capital is committed and unrecoverable, binding it to a deadline the contributor never agreed to. No refund path exists (claimExpired reverts PoolNotExpired; sweepUnclaimedBonus reverts OutcomeNotEligibleForSweep), so the contribution is stranded for ~80 years and the reward pot it was meant to fund is neutralised.

Description

  • expiryLocked is a one-way reliance latch. stake sets it before taking funds (ConfidencePool.sol:229-231), and setExpiry gates solely on it (:623). docs/DESIGN.md §10 states the rule it enforces: "expiry — sponsor-mutable only until the first stake (one-way expiryLocked latch). This protects staker reliance: 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."

  • contributeBonus is the sibling deposit entrypoint — permissionless, taking real value, gated on the same expiry (:269) and the same _assertDepositsAllowed (:271) — and it has no refund path: totalBonus is only ever incremented here and decremented by sweepUnclaimedBonus, never returned to the contributor. Yet it never writes expiryLocked. On a pool funded by bonus but not yet staked, the latch stays false and the sponsor can move expiry to type(uint32).max (year 2106), binding capital to a deadline the contributor never agreed to. The deposit §10's rationale describes — "once anyone has deposited against a given deadline" — has occurred; the latch it justifies does not fire.

  • The affected party is a documented first-class actor, not a hypothetical. README.md enumerates five actors and lists "5. Bonus Contributor / Attacker" separately from "2. Pool Sponsor (Pool Owner)", with the contributor able to "contribute to the bonus pool permissionlessly via contributeBonus". Third-party bonus funding is an intended flow, so this is not self-harm.

// src/ConfidencePool.sol:227-231 — stake sets the latch
_assertDepositsAllowed(_observePoolState());
@> if (!expiryLocked) {
@> expiryLocked = true;
@> }
// src/ConfidencePool.sol:266-285 — contributeBonus does not
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(); // relies on expiry
_assertDepositsAllowed(_observePoolState());
@> // <-- no `expiryLocked = true` here, unlike stake()
// Balance-diff defense-in-depth — see `stake`.
uint256 balanceBefore = stakeToken.balanceOf(address(this));
stakeToken.safeTransferFrom(msg.sender, address(this), amount);
uint256 balanceAfter = stakeToken.balanceOf(address(this));
uint256 received = balanceAfter > balanceBefore ? balanceAfter - balanceBefore : 0;
if (received == 0) revert NoTokensReceived();
@> totalBonus += received; // irrevocable — no path returns this to msg.sender
}
// src/ConfidencePool.sol:622-631 — setExpiry gates only on the latch
function setExpiry(uint256 newExpiry) external onlyOwner {
@> if (expiryLocked) revert ExpiryLocked(); // false => passes
if (newExpiry < block.timestamp + _MIN_EXPIRY_LEAD) revert ExpiryTooSoon(); // 30 days
@> if (newExpiry > type(uint32).max) revert ExpiryTooFar(); // 4294967295 == 2106-02-07
expiry = uint32(newExpiry);
}

Risk

Likelihood:

  • Occurs on any pool where bonus is contributed before the first stake lands — the natural funding order, since a reward pot is seeded to attract stakers rather than after them.

  • Requires no operator error, no privileged misuse, and no unusual registry state. The sponsor simply calls setExpiry, which the latch gap leaves open to them.

  • Bounded by the fact that a sponsor who is also the sole bonus contributor only harms themselves. The finding depends on the third-party contributor flow that README.md §5 documents.

Impact:

  • Capital committed against an advertised deadline is bound to one the contributor never agreed to, up to year 2106 — an ~80-year effective freeze. claimExpired reverts PoolNotExpired until block.timestamp >= expiry (:513), and sweepUnclaimedBonus reverts OutcomeNotEligibleForSweep while the outcome is UNRESOLVED (:475). No refund path exists.

  • In practice no staker joins a pool carrying an 80-year lock, so the contribution never performs the function it was made for, and the reward pot the sponsor advertised is neutralised.

  • Impact is bounded, which is why this is rated Low rather than higher: README.md §5 states "bonus contributors gain no claim on stake or bonus", so no party holds an enforceable entitlement that is taken. setExpiry also gives the sponsor no new extraction path — the bonus already routes to recoveryAddress under the documented sweep rules. The harm is stranded capital and a broken reliance guarantee, not theft.

  • No staker is harmed. Any stake latches expiry on arrival, and stakers joining afterward do so against a published expiry they can read.

Proof of Concept

Executed against the repo's own harness (test/helpers/BaseConfidencePoolTest.sol). Both tests pass on unmodified source:

forge test --match-path test/unit/AuditPoC.t.sol -vv
[PASS] test_poc_contributeBonus_control_stakeDoesLatch() (gas: 291042)
[PASS] test_poc_contributeBonus_leavesExpiryUnlocked() (gas: 175796)
Suite result: ok. 2 passed; 0 failed; 0 skipped
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract AuditPoCTest is BaseConfidencePoolTest {
address internal contributor = makeAddr("contributor");
uint256 internal constant B = 300_000e18; // bonus pot
uint256 internal constant S = 1_000_000e18; // staked principal
function test_poc_contributeBonus_leavesExpiryUnlocked() public {
uint256 originalExpiry = pool.expiry();
// A third-party Bonus Contributor funds the pot against the advertised deadline.
// README lists "5. Bonus Contributor" as an actor distinct from "2. Pool Sponsor".
_contributeBonus(contributor, B);
assertEq(pool.totalBonus(), B, "bonus committed");
// THE DEFECT: `stake` would have latched here (ConfidencePool.sol:229-231).
assertFalse(pool.expiryLocked(), "DEFECT: deposit landed but expiry is still unlocked");
// The sponsor moves the deadline to the maximum the uint32 cast allows.
uint256 farFuture = type(uint32).max; // 4294967295 == 2106-02-07
pool.setExpiry(farFuture);
assertEq(pool.expiry(), farFuture, "sponsor moved expiry ~80 years out");
assertGt(pool.expiry(), originalExpiry, "the deadline the contributor funded against is gone");
// The contribution is now unreachable: no refund path, and both exits are shut.
vm.expectRevert(); // PoolNotExpired -- block.timestamp < expiry
pool.claimExpired();
vm.expectRevert(); // OutcomeNotEligibleForSweep -- outcome is still UNRESOLVED
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(contributor), 0, "contributor holds nothing; no refund path exists");
}
/// @dev Control: a stake latches `expiry` immediately, so `setExpiry` reverts. This is the
/// protection docs/DESIGN.md section 10 describes. Present for `stake`, absent for
/// `contributeBonus` -- which isolates the defect to the missing latch rather than to
/// `setExpiry`'s own guards.
function test_poc_contributeBonus_control_stakeDoesLatch() public {
_stake(alice, S);
assertTrue(pool.expiryLocked(), "stake latches expiry");
vm.expectRevert(); // ExpiryLocked
pool.setExpiry(type(uint32).max);
}
}

Recommended Mitigation

Set the same latch in contributeBonus that stake already sets, so any deposit against the deadline freezes it — aligning the mechanism with the reliance rationale docs/DESIGN.md §10 already states.

Verified: applying this patch makes test_poc_contributeBonus_leavesExpiryUnlocked fail (the exploit is closed) and causes zero regressions — the existing suite goes from 260 passed / 0 failed to 259 passed / 1 failed, where the single failure is the PoC itself. No existing test asserts that contributeBonus leaves expiry unlocked.

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());
+ // A bonus contribution is a deposit against this deadline and has no refund path.
+ // Freeze `expiry` for the same reliance reason `stake` does (docs/DESIGN.md §10).
+ if (!expiryLocked) {
+ expiryLocked = true;
+ }
+
// Balance-diff defense-in-depth — see `stake`.
uint256 balanceBefore = stakeToken.balanceOf(address(this));

Support

FAQs

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

Give us feedback!