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

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.

Support

FAQs

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

Give us feedback!