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

Late UNDER_ATTACK Deposits Dilute Incumbent Bonus Rewards

Author Revealed upon completion

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:

  1. Alice has staked before the risk window has started.

  2. 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.

  3. 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();
// @> Bob is the first pool interaction after the registry entered UNDER_ATTACK,
// @> so this call sets riskWindowStart to Bob's interaction timestamp, not the
// @> registry's earlier UNDER_ATTACK transition timestamp.
_assertDepositsAllowed(_observePoolState());
...
// @> Bob's new stake is recorded at the same timestamp used as riskWindowStart.
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();
// @> UNDER_ATTACK is an active-risk state, so the first observer opens the
// @> pool's local risk window.
if (riskWindowStart == 0 && _isActiveRiskState(state)) {
_markRiskWindowStart();
}
...
}
function _markRiskWindowStart() internal {
uint256 t = block.timestamp;
if (t > expiry) t = expiry;
riskWindowStart = uint32(t);
// @> All existing stake, including Alice's pre-risk deposit, is treated as
// @> entering at Bob's first-observation timestamp.
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();
}
// @> UNDER_ATTACK deposits are allowed.
}

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 {
// Alice stakes before the registry becomes attackable.
_stake(alice, 100 * ONE);
// The registry enters UNDER_ATTACK, but nobody touches the pool, so riskWindowStart
// remains unset even though Alice is exposed to the active-risk registry state.
uint256 registryUnderAttackAt = vm.getBlockTimestamp();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
assertEq(pool.riskWindowStart(), 0, "pool has not locally observed active risk");
// Bob waits 15 days after UNDER_ATTACK started, then becomes the first pool interaction.
// His stake() call opens riskWindowStart at his own interaction timestamp.
vm.warp(registryUnderAttackAt + 15 days);
_stake(bob, 100 * ONE);
assertEq(pool.riskWindowStart(), registryUnderAttackAt + 15 days, "risk starts at Bob's first observation");
// Resolve after additional at-risk time so the k=2 formula has a positive denominator.
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;
// Bob receives the same bonus as Alice despite entering 15 days after the registry
// entered UNDER_ATTACK, because Alice is clamped to Bob's first-observation timestamp.
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;
}

Support

FAQs

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

Give us feedback!