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

Delayed First Observation Allows Retroactive Bonus Reweighting

Author Revealed upon completion

Description

Normal behavior: the k=2 formula (amount x (T - entryTime)^2) is designed to reward
stakers proportionally to how long they held capital at risk, crushing late entrants.
riskWindowStart is the floor every staker's entry time is measured against, and is
meant to mark the moment real risk began.

The issue: riskWindowStart is sealed lazily, on whichever transaction happens to be
the FIRST to observe the registry in an active-risk state (stake/withdraw/
pokeRiskWindow -- whichever comes first). Nothing requires this to happen promptly. A
staker can wait until the pool has seen zero interaction since active-risk began,
then call stake() at the very last moment before expiry. That same call is what
finally seals riskWindowStart -- to a timestamp of the caller's own choosing -- and
_clampUserSums retroactively floors every pre-existing staker's true, earlier entry
time up to that same late instant. Their entire accrued time-weight is nullified in
the same transaction that benefits the late depositor.

function _markRiskWindowStart() internal {
uint256 t = block.timestamp;
if (t > expiry) t = expiry;
@> riskWindowStart = uint32(t); // sealed lazily -- fires on WHOEVER's tx first
// observes active-risk, with NO guarantee that
// happens promptly
@> sumStakeTime = totalEligibleStake * t; // eagerly floors EVERY pre-existing
@> sumStakeTimeSq = totalEligibleStake * t * t; // staker's entry up to this
// self-chosen instant
emit RiskWindowStarted(t);
}
function _clampUserSums(address u) internal {
uint256 start = riskWindowStart;
uint256 stake_ = eligibleStake[u];
if (start == 0 || stake_ == 0) return;
@> if (userSumStakeTime[u] < stake_ * start) {
@> userSumStakeTime[u] = stake_ * start; // retroactively rewrites a
@> userSumStakeTimeSq[u] = stake_ * start * start; // long-term staker's true,
// earlier entry time
}
}

Risk

Likelihood:

  • Occurs in any pool that experiences low interaction during its active-risk window
    -- no stake/withdraw/pokeRiskWindow() call from any party -- which is a fully
    passive, expected state the protocol's own documentation (docs/DESIGN.md §6)
    describes as normal ("its absence only reflects that nobody interacted with the
    pool for the entire interval").

  • Requires no permissions, no collusion, and no special timing precision beyond a
    single stake() call before expiry; the on-chain state needed to detect the
    opportunity (riskWindowStart == 0 while the registry is UNDER_ATTACK) is fully
    public and free to observe.

  • Real-world corroboration: querying the live BattleChain testnet registry
    (getAgreementState on the demo agreement 0xE550...07716) confirms it has
    remained in UNDER_ATTACK continuously since at least block 10,100 through the
    current chain tip — weeks of real elapsed time with the agreement sitting in an
    active-risk state. This is not a contrived scenario: extended dormancy during
    active-risk is an observed, not merely hypothetical, condition on this registry.

Impact:

  • Long-term stakers who bear genuine risk exposure for weeks or months have their
    entire accrued time-weight premium nullified, receiving a bonus share far below
    what the protocol's own k=2 design intends. Demonstrated in the PoC: a staker with
    60 days of real risk exposure and a staker with 1 second of exposure end up
    splitting the bonus pool in an exact 50/50 ratio instead of the ~100/0 ratio the
    formula is built to produce.

  • The late depositor captures bonus value proportional to how dormant the pool has
    been, extracting up to nearly the entire "unearned" share of the bonus pool with a
    single, cheap, well-timed transaction -- undermining the core economic premise of
    the Confidence Pool, where stakers "economically signal their belief" and are
    rewarded proportionally to risk actually borne over time.

This directly falsifies two explicit statements about intended behavior:

  1. The contest brief itself: "The bonus is split with a k=2 time-weighted formula
    that crushes late entrants within the observed risk window." The attacker's
    1-second-before-expiry deposit is the latest possible entry in the pool's
    lifetime -- yet it is not crushed; it captures 50% of the bonus pool.

  2. docs/DESIGN.md §3, verbatim: "There is no late-join advantage to gate against
    and no trap." The PoC produces an exact, quantified late-join advantage of
    ~50e18 tokens, extracted directly from a long-term staker's position.

Both statements model riskWindowStart's floor as affecting only the depositor
triggering it -- neither anticipates that sealing it is a global, retroactive
operation that rewrites every OTHER pre-existing staker's true entry time.

Separately, docs/DESIGN.md §7 explicitly defends the upper bound T
(riskWindowEnd) against this exact class of timing manipulation ("any
counterparty with the opposite incentive can pokeRiskWindow() the instant the
registry transitions, collapsing the bias to ~zero") but never extends that
reasoning to the lower bound (riskWindowStart) exploited here. The asymmetry is
not academic: the upper-bound race is defended by every staker's existing incentive
to watch for the terminal-state transition (their own payout depends on it), while
the lower-bound race requires watching for a far less salient event that the design
elsewhere (§6) explicitly expects may go unobserved for an entire interval.

Formal Property Violated

Beyond the specific 50/50 numeric outcome, the PoC demonstrates a violation of a
general, checkable property -- not just an unfortunate edge case.

Desired invariant: Once the registry has transitioned into an active-risk state,
subsequent observation timing must not retroactively alter bonus weight already
attributable to pre-existing stake.

This is deliberately phrased without reference to any "true" moment the contract would
need to know -- the registry's active-risk transition is itself an objective,
independently verifiable fact (it is the block in which the registry's own
state-changing call executed and emitted its transition event), not a quantity the
pool needs to infer. The invariant only requires that whichever transaction later
observes that already-past transition must not be able to rewrite what pre-existing
stakers had already earned by that point.

Violation: _markRiskWindowStart() seals riskWindowStart = block.timestamp at
the moment of observation -- which can arrive arbitrarily long after the registry's
actual transition -- and _clampUserSums retroactively rewrites every pre-existing
staker's accrued weight against that later, observation-time value instead of
preserving what was already attributable to them from the transition onward. The PoC
holds the registry's real transition block identical across both pools (UNDER_ATTACK
begins at the same wall-clock time in Q and P) and varies only who performs the
first observation and when. Identical transition history, opposite outcomes (~100/0
vs. exact 50/50) -- the violation is caused entirely by observation timing, not by any
difference in the underlying history being weighted.

Why this is worse than an ordinary observation-lag artifact: the party who
benefits most from delaying observation (the latest possible depositor) is also
structurally the most likely party to be the one who eventually triggers it -- no one
else has an incentive to interact with a quiet pool. The mechanism does not merely
fail to prevent delay; it selects for delay, rewarding whoever waits longest with
the highest probability of also being the one who closes the window in their own
favor.

Proof of Concept

function test_lazyObservation_collapsesLongTermStakerAdvantage() external {
uint256 expiry_ = T0 + 60 days;
Env memory Q = _deploy(expiry_); // protected: prompt, honest poke
Env memory P = _deploy(expiry_); // lazy: nobody interacts until the attacker
vm.warp(T0);
_bonusInto(Q, 100e18);
_bonusInto(P, 100e18);
_stakeInto(Q, longTermStaker, 100e18);
_stakeInto(P, longTermStaker, 100e18);
// Registry genuinely enters UNDER_ATTACK for both pools.
vm.warp(T0 + 5 days);
Q.registry.setState(address(Q.agreement), IAttackRegistry.ContractState.UNDER_ATTACK);
P.registry.setState(address(P.agreement), IAttackRegistry.ContractState.UNDER_ATTACK);
// Only in Q does anyone call pokeRiskWindow() promptly.
Q.pool.pokeRiskWindow();
// P: zero interaction for 55 days -- genuinely dormant pool.
assertEq(P.pool.riskWindowStart(), 0, "P must not have sealed anything yet");
// Attacker strikes 1 second before expiry, in BOTH pools, with an equal
// deposit (isolates the pure time-weighting effect, not a capital advantage).
vm.warp(expiry_ - 1);
_stakeInto(Q, lateAttacker, 100e18);
_stakeInto(P, lateAttacker, 100e18); // this call itself seals P's riskWindowStart
assertEq(P.pool.riskWindowStart(), expiry_ - 1, "P's seal only arrives with the late stake");
vm.warp(expiry_ + 1);
Q.pool.claimExpired();
P.pool.claimExpired();
uint256 balBefore;
balBefore = Q.token.balanceOf(longTermStaker);
vm.prank(longTermStaker); Q.pool.claimExpired();
uint256 bonusA_Q = (Q.token.balanceOf(longTermStaker) - balBefore) - 100e18;
balBefore = Q.token.balanceOf(lateAttacker);
vm.prank(lateAttacker); Q.pool.claimExpired();
uint256 bonusB_Q = (Q.token.balanceOf(lateAttacker) - balBefore) - 100e18;
balBefore = P.token.balanceOf(longTermStaker);
vm.prank(longTermStaker); P.pool.claimExpired();
uint256 bonusA_P = (P.token.balanceOf(longTermStaker) - balBefore) - 100e18;
balBefore = P.token.balanceOf(lateAttacker);
vm.prank(lateAttacker); P.pool.claimExpired();
uint256 bonusB_P = (P.token.balanceOf(lateAttacker) - balBefore) - 100e18;
assertGt(bonusA_Q, 99e18, "in Q the time-weighting must reward A almost in full");
assertApproxEqAbs(bonusA_P, 50e18, 1e6, "in P A's time-weighting must collapse");
assertApproxEqAbs(bonusB_P, 50e18, 1e6, "in P the attacker gets a 60-day-staker's share");
}

Result (forge test -vvv):

=== Pool Q (window sealed honestly and promptly) ===
long-term staker (60-day risk exposure) bonus: 99999999999995571597
late attacker (1-second risk exposure) bonus: 4428402
=== Pool P (window sealed late by the attacker) ===
long-term staker (60-day risk exposure) bonus: 50000000000000000000
late attacker (1-second risk exposure) bonus: 50000000000000000000

delta bonus attacker (P - Q): 49999999999995571598
delta bonus long-term staker lost (Q - P): 49999999999995571597

Confirmed Absent From the Project's Own Test Suite

The project ships an extensive test suite (9 thematically relevant files across
unit and fuzz tests -- ConfidencePool.riskWindow.t.sol,
ConfidencePool.riskWindowEnd.t.sol, ConfidencePool.k2Bonus.t.sol,
ConfidencePool.timeWeightedBonus.t.sol, ClaimExpiredRegistryGate.t.sol,
SweepUnclaimedBonus.t.sol, ConfidencePool.branches.t.sol, ConfidencePool.t.sol, and
ConfidencePool.fuzz.t.sol). Every test involving a late staker seals
riskWindowStart via a prompt, disinterested pokeRiskWindow() call before that
staker deposits (e.g. testLastMinuteStakerEarnsMinimalBonus,
testTwoStakersOverlappingAtRiskTime). The one fuzz test touching multi-staker payout
distribution (testFuzz_proRataPayout_sumsToPot) likewise relies on a deterministic,
prompt state-transition helper rather than varying who performs the first
observation -- and only checks conservation, not distribution correctness. None
construct the scenario exploited here: a pool with zero interaction during
active-risk, where the late depositor's own transaction performs the first
observation.

Tellingly, ConfidencePool.riskWindowEnd.t.sol contains a test named
testGriefShiftRemovedForClaimExpired -- a dedicated "headline test," per its own
comment, verifying that two stakers' bonus split is identical whether the resolving
call fires immediately or 30 days late, specifically for the upper bound
(riskWindowEnd). No equivalent test exists for the lower bound
(riskWindowStart) exploited here. The project's own test suite demonstrates
explicit awareness of, and a dedicated regression test for, this exact class of
timing-manipulation risk on one bound -- and no corresponding coverage on the
structurally symmetric other bound.

One test, testContributeBonusSealsRiskWindowStart, shows the team was aware of the
general "someone needs to observe promptly" risk and added contributeBonus as an
extra sealing trigger specifically so the window closes "even if no one
stakes/withdraws." This is a real, partial mitigation -- but it only helps when a
bonus contribution happens to occur during the active-risk window itself. It does not
close the gap when (as in the PoC) all bonus was contributed before risk began, and no
party interacts at all until the late staker's own, self-interested transaction.

Recommended Mitigation

A fully robust on-chain fix is nontrivial: the external registry exposes no
canonical timestamp for when an agreement truly entered an active-risk state
(state is derived from boolean flags, per docs/DESIGN.md §7's own admission), so
the pool cannot retroactively recover the "true" transition time to seal
riskWindowStart against.

A same-transaction guard (rejecting a stake() call that is itself the first
observation of active-risk) raises the cost of the simplest form of this attack
but does not fully close it: a determined actor can trivially split the sequence
into two transactions (pokeRiskWindow() first, stake() immediately after),
reaching the same outcome for a negligible extra cost.

function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
if (amount == 0) revert InvalidAmount();
if (amount < minStake) revert BelowMinStake();
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
if (block.timestamp >= expiry) revert StakingClosed();
+ // A depositor must not be able to simultaneously perform the FIRST
+ // observation of the active-risk state AND receive stake credit at that
+ // exact, self-chosen floor -- doing so lets a late staker pick the
+ // riskWindowStart every pre-existing staker gets retroactively clamped to.
+ // Note: raises the cost of the attack but does not fully close it (see below).
+ if (riskWindowStart == 0 && _isActiveRiskState(_getAgreementState())) {
+ revert RiskWindowMustBeSealedFirst();
+ }
_assertDepositsAllowed(_observePoolState());
// IConfidencePool.sol
error RiskWindowNotReached();
+ error RiskWindowMustBeSealedFirst();

The more structurally sound direction is economic rather than purely
access-control-based: incentivize prompt, disinterested observation by letting the
sponsor optionally fund a small keeper bounty paid out of the bonus pool to
whichever address first calls pokeRiskWindow() after a genuine state transition.
This makes rapid observation profitable for any neutral third party, removing the
"nobody bothered to interact" precondition the exploit depends on, without
requiring any change to the external registry interface.

Tools Used

Foundry (unit tests, PoC construction, manual trace inspection via -vvvv), manual
line-by-line review of src/ConfidencePool.sol cross-referenced against
docs/DESIGN.md §§3, 5–7 and the top-level contest scope description, and review of
the project's own test suite (test/unit/*, test/fuzz/*) to confirm the scenario
was not already covered or intentionally excluded.

Support

FAQs

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

Give us feedback!