Root + Impact
Description
A bonus contributor transfers tokens to the pool based on its advertised parameters, including the expiry that determines the term over which those tokens will incentivize stakers.
Section 10 of docs/DESIGN.md states:
“expiry — sponsor-mutable only until the first stake (one-way expiryLocked latch). This protects staker reliance: once anyone has deposited against a given deadline [...] the sponsor cannot move it.”
The same protection is not provided to bonus contributors. stake() sets expiryLocked when the first stake is accepted:
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
if (!expiryLocked) {
expiryLocked = true;
}
}
In contrast, contributeBonus() accepts and permanently accounts for the contributor's tokens without locking the expiry:
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());
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;
}
Because setExpiry() checks only expiryLocked, the sponsor remains free to change the term after the bonus has been contributed:
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);
}
For example, a contributor may fund a bonus after reviewing a one-year pool term. The sponsor can subsequently shorten the expiry to approximately 30 days, materially changing the period and risk assumptions under which the contributor intended the bonus to be distributed.
Alternatively, the sponsor can extend the expiry after the contribution. If no staker ever deposits, expiryLocked remains false, allowing the sponsor to repeatedly extend the deadline. The bonus contributor has no withdrawal path, so their tokens remain committed to a pool operating under terms they never accepted.
The protocol recognises that stakers rely on the advertised expiry, but assumes that a bonus contributor does not require equivalent term integrity despite committing value irreversibly.
Risk
Likelihood:
Any sponsor can change the expiry after a third party contributes a bonus, provided no stake has yet been accepted.
Impact:
The contributor's non-refundable bonus can be used under a materially shorter or longer term than the one they reviewed, and repeated extensions can delay settlement indefinitely.
Proof of Concept
Add the following test to test/unit/BonusExpiryTerms.t.sol and run:
forge test --offline --match-contract BonusExpiryTermsTest -vv
pragma solidity 0.8.26;
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract BonusExpiryTermsTest is BaseConfidencePoolTest {
function testPoC_sponsorCanShortenExpiryAfterBonusContributorAcceptsLongerTerm()
external
{
uint256 contributedAgainstExpiry = block.timestamp + 365 days;
pool.setExpiry(contributedAgainstExpiry);
_contributeBonus(carol, 50 * ONE);
assertFalse(pool.expiryLocked(), "bonus contribution does not lock expiry");
uint256 shortenedExpiry = block.timestamp + 31 days;
pool.setExpiry(shortenedExpiry);
assertEq(pool.expiry(), shortenedExpiry, "sponsor changes the contributor's accepted term");
assertLt(pool.expiry(), contributedAgainstExpiry, "expiry was materially shortened");
}
function testPoC_sponsorCanRepeatedlyExtendExpiryAfterBonusContribution()
external
{
uint256 contributedAgainstExpiry = pool.expiry();
_contributeBonus(carol, 50 * ONE);
uint256 firstExtension = block.timestamp + 365 days;
pool.setExpiry(firstExtension);
vm.warp(contributedAgainstExpiry + 1);
vm.prank(carol);
vm.expectRevert(IConfidencePool.PoolNotExpired.selector);
pool.claimExpired();
uint256 secondExtension = firstExtension + 365 days;
pool.setExpiry(secondExtension);
assertGt(secondExtension, firstExtension, "sponsor can extend the term again");
assertFalse(pool.expiryLocked(), "expiry remains mutable while no stake is made");
assertEq(token.balanceOf(address(pool)), 50 * ONE, "contributor's bonus remains held by the pool");
}
}
Recommended Mitigation
Finalise the pool terms before accepting any stake or bonus, or lock the expiry when the first bonus contribution is accepted as well as when the first stake is accepted.
function contributeBonus(uint256 amount) external nonReentrant whenPoolNotPaused {
_assertDepositsAllowed(_observePoolState());
if (!expiryLocked) expiryLocked = true;
}