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

"contributeBonus" fails to set "expiryLocked" latch, allowing sponsor to permanently trap bonus capital

Author Revealed upon completion

Root + Impact

Description

  • Normally, the pool's expiry deadline is sponsor-mutable only until the first staker deposits. As explicitly stated in Design.md, this is mechanically enforced by the expiryLocked latch to "protect staker reliance" so the sponsor cannot move the deadline once funds are locked against it. You can see this correctly implemented inside the stake() function at ConfidencePool.sol#L229-L231.

  • However, the specific issue is that the contributeBonus() function accepts user funds but completely fails to set the expiryLocked latch. If a bonus provider deposits capital before the first staker, the latch remains false. The sponsor can then maliciously or accidentally call setExpiry() and extend the expiry to type(uint32).max (the year 2106). Because there is no withdrawBonus function, the bonus provider's capital is permanently trapped in the smart contract until the year 2106.

// [ConfidencePool.sol#L266-L275](https://github.com/Cyfrin/battlechain-safe-harbor-contracts/blob/main/src/ConfidencePool.sol#L266-L275)
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());
@> // MISSING: if (!expiryLocked) expiryLocked = true;
@> // The latch is completely missing, leaving bonus capital unprotected
// Balance-diff defense-in-depth — see `stake`.
// aderyn-fp-next-line(reentrancy-state-change)
uint256 balanceBefore = stakeToken.balanceOf(address(this));

Risk

Likelihood:

  • Occurs when a bonus provider contributes funds to the pool before any staker calls stake().

  • Occurs when the sponsor subsequently changes the pool's expiry via setExpiry() (either maliciously, or accidentally via a compromised private key).

Impact:

  • The bonus provider's capital becomes permanently trapped in the smart contract until the year 2106, resulting in a total loss of access to funds.

  • The protocol's explicit trust-minimization guarantee (which promises to mechanically prevent the sponsor from moving the deadline after deposits) is completely broken.

Proof of Concept

The following Foundry test demonstrates how a bonus provider's deposit fails to lock the expiry, allowing the sponsor to maliciously or accidentally push the deadline to the maximum uint32 value (the year 2106).

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import "forge-std/console.sol";
contract M01_BonusExpiryLockTest is BaseConfidencePoolTest {
function testContributeBonusDoesNotLockExpiry() external {
console.log("1. Alice contributes 100 tokens as bonus...");
_contributeBonus(alice, 100 * ONE);
console.log("2. Checking if the pool's expiryLocked latch was set...");
bool isLocked = pool.expiryLocked();
console.log(" - expiryLocked is:", isLocked);
assertFalse(isLocked, "Expiry should be locked, but isn't");
console.log("3. Sponsor (malicious or accidental) extends the expiry to year 2106...");
vm.prank(address(this)); // The test contract is the owner
pool.setExpiry(type(uint32).max);
console.log("4. Verifying the new expiry deadline...");
uint256 newExpiry = pool.expiry();
console.log(" - New expiry is:", newExpiry);
console.log("5. Alice has no withdrawBonus() function.");
console.log(" - IMPACT: Alice's funds are now permanently locked in the contract for 136 years.");
assertEq(newExpiry, type(uint32).max, "Expiry was changed to max");
}
}

Run this Poc:

forge test --match-contract M01_BonusExpiryLockTest -vv

Expected Output:

[PASS] testContributeBonusDoesNotLockExpiry() (gas: 171260)
Logs:
1. Alice contributes 100 tokens as bonus...
2. Checking if the pool's expiryLocked latch was set...
- expiryLocked is: false
3. Sponsor (malicious or accidental) extends the expiry to year 2106...
4. Verifying the new expiry deadline...
- New expiry is: 4294967295
5. Alice has no withdrawBonus() function.
- IMPACT: Alice's funds are now permanently locked in the contract for 136 years.

Recommended Mitigation

The issue is resolved by explicitly locking the expiryLocked latch when a bonus is contributed, exactly mirroring the logic used in the stake() function. This ensures the sponsor cannot alter the expiry once bonus capital is committed to the pool.

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());
+ if (!expiryLocked) {
+ expiryLocked = true;
+ }
// Balance-diff defense-in-depth — see `stake`.
// aderyn-fp-next-line(reentrancy-state-change)
uint256 balanceBefore = stakeToken.balanceOf(address(this));

Support

FAQs

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

Give us feedback!