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

A third party's bonus contribution has no reliance latch and no refund path, and a paused pool confiscates it in full

Author Revealed upon completion

Description

  • contributeBonus:266 is a permissionless, irrevocable deposit entrypoint. README.md names its caller a distinct protocol actor - Actor 5, "Bonus Contributor": "can contribute to the bonus pool permissionlessly via contributeBonus (no claim rights)". There is no withdrawBonus(); contributed funds exit only via staker claims or sweepUnclaimedBonus to recoveryAddress.

  • Both value-committing entrypoints gate on expiry, but only one latches it:

// ConfidencePool.sol:229 - stake: the first stake freezes the deadline
@> if (!expiryLocked) {
@> expiryLocked = true;
@> }
// ConfidencePool.sol:282 - contributeBonus: same `expiry` gate, value committed, NO latch
@> totalBonus += received; // committed and unrecoverable; expiryLocked stays false
emit BonusContributed(msg.sender, received);

§10 justifies the latch as protecting reliance "once anyone has deposited against a given deadline", but the implementation reads "once anyone has staked" - narrower than the stated rationale, and excluding Actor 5 entirely. contributeBonus is never mentioned in docs/DESIGN.md, so this is an unconsidered entrypoint rather than an accepted trade-off.

  • The confiscation. The gap is not limited to re-timing. pause() blocks stake but deliberately not the sweeps, so the sponsor can guarantee no staker ever arrives - and sweepUnclaimedBonus then reserves nothing at all:

// ConfidencePool.sol:482 - sweepUnclaimedBonus
uint256 reserved;
@> if (totalEligibleStake != 0) { // no stakers -> the whole guard is skipped
reserved = totalEligibleStake;
if (riskWindowStart != 0) {
reserved += snapshotTotalBonus - claimedBonus;
}
}
uint256 freeBalance = stakeToken.balanceOf(address(this));
@> uint256 amount = freeBalance > reserved ? freeBalance - reserved : 0; // reserved == 0
stakeToken.safeTransfer(recoveryAddress, amount); // the contributor's entire deposit

Sequence: third party contributes B -> sponsor calls pause() -> no stake is possible -> at expiry, claimExpired resolves EXPIRED with totalEligibleStake == 0 -> sweepUnclaimedBonus computes reserved = 0 and sends the full balance to recoveryAddress

  • This is outside the sweep's documented purpose. protocol-readme.md describes sweepUnclaimedBonus as recovering "any excess over the remaining stakers' entitlement reserve (k=2 rounding dust, non-claimers' forfeited shares, post-resolution donations)", not an entire advertised bonus. §10's "Sponsor trust surface" likewise enumerates the sponsor's levers for stakers only and never addresses contributors

  • Not claimed: README Actor 5 states contributors "gain no claim on stake or bonus", so a contributor cannot demand a refund, and with no stakers the bonus reaches recoveryAddress on any honest resolution. The defect is that the sponsor can deterministically force that outcome - converting a third party's gift to stakers into their own revenue - not that the destination is novel

Risk

Severity: Low - Low Impact x Low Likelihood.

Likelihood: Low. Reachable only when the bonus contributor is not the sponsor; in the common case the sponsor funds their own bonus, making it self-harm. It is reachable at all because contributeBonus is permissionless and README.md names Actor 5 as a real role. The expiry-move half closes permanently the moment anyone stakes

Impact: Low. A third party's entire contribution is confiscated to the sponsor's recoveryAddress. In the no-stakers case that bonus was destined for recoveryAddress anyway on any honest resolution, so the sponsor gains funds but not value they could not otherwise reach - the defect is the absence of any contributor protection, not a novel extraction. Absent the pause, the milder consequence stands alone: the deadline a committed contributor relied on can be moved anywhere in [block.timestamp + 30 days, type(uint32).max], i.e. out to 2106, with the funds unreachable until resolution

Proof of Concept

Self-contained against the existing BaseConfidencePoolTest harness, no RPC needed. PASSES:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
contract L7ContributeBonusConfiscationTest is BaseConfidencePoolTest {
function test_pausedPoolConfiscatesThirdPartyBonus() public {
_contributeBonus(bob, 500 * ONE);
assertEq(pool.totalBonus(), 500 * ONE);
// The commitment did NOT lock the deadline; a stake would have.
assertFalse(pool.expiryLocked());
pool.setExpiry(block.timestamp + 200 days);
pool.pause();
token.mint(alice, 1000 * ONE);
vm.startPrank(alice);
token.approve(address(pool), 1000 * ONE);
vm.expectRevert(IConfidencePool.PoolPaused.selector);
pool.stake(1000 * ONE);
vm.stopPrank();
vm.warp(uint256(pool.expiry()) + 1);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED));
assertEq(pool.totalEligibleStake(), 0);
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery), 500 * ONE);
assertEq(token.balanceOf(bob), 0);
}
}
[PASS] test_pausedPoolConfiscatesThirdPartyBonus()
contributeBonus(500e18) -> expiryLocked == false // no reliance latch
setExpiry(+200 days) -> succeeds // deadline moved under committed funds
pause() ; stake() -> revert PoolPaused // no staker can ever arrive
claimExpired() -> EXPIRED, totalEligibleStake == 0
sweepUnclaimedBonus() -> reserved == 0 -> 500e18 to recovery, bob gets 0

Recommended Mitigation

Lock expiry on the first value commitment from either entrypoint, mirroring stake:229 and matching the rationale §10 already states "once anyone has deposited":

totalBonus += received;
+ // Mirror stake(): a value commitment freezes the deadline the contributor relied on.
+ // `contributeBonus` has no refund path, so the reliance is at least as strong as a staker's.
+ if (!expiryLocked) expiryLocked = true;
+
emit BonusContributed(msg.sender, received);

This alone does not stop the pause-and-sweep confiscation. If third-party contribution is intended, the pool also needs either a contributor refund path when a pool resolves with totalEligibleStake == 0, or contributeBonus restricted to onlyOwner, which would make README.md Actor 5 accurate to the code. If it is not intended, restricting the function is the smaller change and the docs should drop Actor 5's permissionless framing

Support

FAQs

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

Give us feedback!