Root + Impact
Description
Because of the lazy update on the riskWindowStart it is possible for a participant to join much later than the others and still have the maximum stake possible even though their claim should be heavily reduced by the k=2 bonus formula.
Assume the following case:
Alice has staked before the risk window has started.
At timestamp t the registry updates the status to UNDER_ATTACK but this is not seen in the pool because no one has interacted with it.
After 15 days at timestamp t+15days Bob deposits. The lazy update is triggered and riskWindowStart is set to t+15days. Both Alice and Bob's deposits are also set to the same timestamp even though he experienced 15 days of risk less.
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());
...
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;
}
function _observePoolState() internal returns (IAttackRegistry.ContractState state) {
state = _getAgreementState();
if (riskWindowStart == 0 && _isActiveRiskState(state)) {
_markRiskWindowStart();
}
...
}
function _markRiskWindowStart() internal {
uint256 t = block.timestamp;
if (t > expiry) t = expiry;
riskWindowStart = uint32(t);
sumStakeTime = totalEligibleStake * t;
sumStakeTimeSq = totalEligibleStake * t * t;
emit RiskWindowStarted(t);
}
function _assertDepositsAllowed(IAttackRegistry.ContractState state) private pure {
if (
state == IAttackRegistry.ContractState.PROMOTION_REQUESTED
|| state == IAttackRegistry.ContractState.PRODUCTION
|| state == IAttackRegistry.ContractState.CORRUPTED
) {
revert StakingClosed();
}
}
Risk
Likelihood:
-
Occurs when the registry enters UNDER_ATTACK and no one calls pokeRiskWindow() or any other pool function until later.
-
Occurs when a late depositor is the first pool interaction after the UNDER_ATTACK transition, such as Bob calling stake() at t + 15 days.
Impact:
-
Earlier stakers are not compensated for the additional days during which their capital was exposed while the registry was already UNDER_ATTACK.
-
A late depositor with the same stake amount receives the same bonus as an earlier staker, and a larger late depositor can capture most of the bonus pool while bearing fewer days of attack-window exposure.
Proof of Concept
function testLateStaker() external {
_stake(alice, 100 * ONE);
uint256 registryUnderAttackAt = vm.getBlockTimestamp();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
assertEq(pool.riskWindowStart(), 0, "pool has not locally observed active risk");
vm.warp(registryUnderAttackAt + 15 days);
_stake(bob, 100 * ONE);
assertEq(pool.riskWindowStart(), registryUnderAttackAt + 15 days, "risk starts at Bob's first observation");
vm.warp(vm.getBlockTimestamp() + 5 days);
_contributeBonus(carol, 100 * ONE);
_flagSurvived();
uint256 alicePayout = _claim(alice);
uint256 bobPayout = _claim(bob);
uint256 aliceBonus = alicePayout - 100 * ONE;
uint256 bobBonus = bobPayout - 100 * ONE;
assertEq(aliceBonus, 50 * ONE);
assertEq(bobBonus, 50 * ONE);
assertEq(alicePayout, bobPayout, "late UNDER_ATTACK staker earns the same as pre-risk staker");
}
Recommended Mitigation
The strongest mitigation is to anchor riskWindowStart to the registry's actual active-risk transition timestamp, if BattleChain exposes one. If that timestamp is unavailable, block new stake deposits when the first active-risk observation is made so a late observer cannot join the same effective-entry cohort as earlier stakers.
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());
+ bool hadRiskWindowStart = riskWindowStart != 0;
+ _assertDepositsAllowed(_observePoolState());
+ if (!hadRiskWindowStart && riskWindowStart != 0) revert StakingClosed();
if (!expiryLocked) {
expiryLocked = true;
}