Root + Impact
Description
-
The current formula for calculating the bonus is disregarding the time that the staking actually happens in the risk window.
-
It is the way that a last second staker, can stake funds just before the staking period is closed. This way, they almost completely evade the risk of losing their funds to the attacker. Moreover, interestingly, they can even get more bonus comparing with users who had staked much earlier (even before the contract was set as UNDER_ATTACK).
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
if (amount == 0) revert InvalidAmount();
if (amount < minStake) revert BelowMinStake();
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();
if (received < minStake) revert BelowMinStake();
_clampUserSums(msg.sender);
uint256 newEntry = block.timestamp;
uint256 start = riskWindowStart;
if (start != 0 && newEntry < start) newEntry = start;
uint256 contribTime = received * newEntry;
uint256 contribTimeSq = received * newEntry * newEntry;
eligibleStake[msg.sender] += received;
@> userSumStakeTime[msg.sender] += contribTime;
@> userSumStakeTimeSq[msg.sender] += contribTimeSq;
totalEligibleStake += received;
sumStakeTime += contribTime;
sumStakeTimeSq += contribTimeSq;
emit Staked(msg.sender, received);
}
function _bonusShare(address u, uint256 userEligible) internal view returns (uint256) {
if (snapshotTotalBonus == 0) return 0;
if (riskWindowStart == 0) return 0;
uint256 T = outcomeFlaggedAt;
@> uint256 userPlus = T * T * userEligible + userSumStakeTimeSq[u];
@> uint256 userMinus = 2 * T * userSumStakeTime[u];
@> uint256 userScore = userPlus > userMinus ? userPlus - userMinus : 0;
uint256 plus = T * T * snapshotTotalStaked + snapshotSumStakeTimeSq;
uint256 minus = 2 * T * snapshotSumStakeTime;
uint256 globalScore = plus > minus ? plus - minus : 0;
if (globalScore == 0) {
if (snapshotTotalStaked == 0) return 0;
return Math.mulDiv(userEligible, snapshotTotalBonus, snapshotTotalStaked);
}
return Math.mulDiv(userScore, snapshotTotalBonus, globalScore);
}
Risk
Likelihood:
-
Since the contract will be verified and the source code will be available to everyone, any skillful staker can see the flawed calculations and benefit from this opportunity.
-
This is as easy as writing a smart contract and set it up to stake some funds at a specific moment in time.
Impact:
Proof of Concept
Please put the following code in a file like ConfidencePoolContest.t.sol and then run the test using forge test --mt test_claimSurvived_lastSecondStakersCanEvadeRiskAndEvenGetMoreBonus -vvv.
pragma solidity 0.8.26;
import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {stdStorage, StdStorage} from "forge-std/StdStorage.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
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";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
import {
IERC20Errors
} from "lib/openzeppelin-contracts/contracts/interfaces/draft-IERC6093.sol";
import {console} from "forge-std/console.sol";
contract ConfidencePoolContestTest is BaseConfidencePoolTest {
using stdStorage for StdStorage;
event AttackerBountyClaimed(
address indexed attacker,
uint256 amount,
uint256 totalClaimed,
uint256 totalEntitlement
);
function test_claimSurvived_lastSecondStakersCanEvadeRiskAndEvenGetMoreBonus()
external
{
token.mint(carol, ONE);
vm.startPrank(carol);
token.approve(address(pool), ONE);
pool.contributeBonus(ONE);
vm.stopPrank();
token.mint(alice, ONE);
vm.startPrank(alice);
token.approve(address(pool), ONE);
pool.stake(ONE);
vm.stopPrank();
_passThroughUnderAttack();
vm.warp(pool.expiry() - 1);
token.mint(bob, ONE);
vm.startPrank(bob);
token.approve(address(pool), ONE);
pool.stake(ONE);
vm.stopPrank();
vm.warp(1);
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.PRODUCTION
);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
uint256 aliceBalanceBefore = token.balanceOf(alice);
vm.startPrank(alice);
pool.claimSurvived();
vm.stopPrank();
uint256 aliceBalanceAfter = token.balanceOf(alice);
uint256 bobBalanceBefore = token.balanceOf(bob);
vm.startPrank(bob);
pool.claimSurvived();
vm.stopPrank();
uint256 bobBalanceAfter = token.balanceOf(bob);
uint256 aliceBonus = aliceBalanceAfter - aliceBalanceBefore;
uint256 bobBonus = bobBalanceAfter - bobBalanceBefore;
console.log("Alice bonus:", aliceBonus);
console.log("Bob bonus:", bobBonus);
assert(bobBonus > aliceBonus);
}
}
Recommended Mitigation
I recommend using the distance from the expiry time as the newEntry in the stake function for time-based calculations.
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
if (amount == 0) revert InvalidAmount();
if (amount < minStake) revert BelowMinStake();
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
if (block.timestamp >= expiry) revert StakingClosed();
_assertDepositsAllowed(_observePoolState());
if (!expiryLocked) {
expiryLocked = true;
}
// Balance-diff defense-in-depth against the factory allowlist admitting a fee-on-transfer
// or rebasing token (governance error, or a proxy-token upgrade post-allowlist).
// aderyn-fp-next-line(reentrancy-state-change)
uint256 balanceBefore = stakeToken.balanceOf(address(this));
stakeToken.safeTransferFrom(msg.sender, address(this), amount);
// aderyn-fp-next-line(reentrancy-state-change)
uint256 balanceAfter = stakeToken.balanceOf(address(this));
uint256 received = balanceAfter > balanceBefore ? balanceAfter - balanceBefore : 0;
if (received == 0) revert NoTokensReceived();
if (received < minStake) revert BelowMinStake();
_clampUserSums(msg.sender);
// Pre-risk deposits use wall clock and get promoted to riskWindowStart later via
// `_clampUserSums`; post-risk deposits go in at wall clock (already ≥ riskWindowStart).
uint256 newEntry = block.timestamp;
uint256 start = riskWindowStart;
if (start != 0 && newEntry < start) newEntry = start;
+ newEntry = expiry - newEntry;
uint256 contribTime = received * newEntry;
uint256 contribTimeSq = received * newEntry * newEntry;
eligibleStake[msg.sender] += received;
userSumStakeTime[msg.sender] += contribTime;
userSumStakeTimeSq[msg.sender] += contribTimeSq;
totalEligibleStake += received;
sumStakeTime += contribTime;
sumStakeTimeSq += contribTimeSq;
emit Staked(msg.sender, received);
}