Describe the normal behavior in one or more sentences
Explain the specific issue or problem in one or more sentences
Likelihood:
Reason 1 // Describe WHEN this will occur (avoid using "if" statements)
Reason 2
Impact:
Impact 1
Impact 2
Severity
High
Likelihood
Medium
Impact
High
Description
_observePoolState() reads the registry state once and routes it through two mutually exclusive branches.
_isActiveRiskState (UNDER_ATTACK / PROMOTION_REQUESTED) and _isTerminalState (PRODUCTION / CORRUPTED) can never both be true for the same state value in a single call. This works correctly if the registry is ever observed passing through an active-risk state before it reaches a terminal one — some earlier call catches UNDER_ATTACK and sets riskWindowStart, so that by the time a later call sees CORRUPTED, riskWindowStart is already nonzero.
Nothing guarantees that sequencing actually happens on-chain, though. If the very first call to _observePoolState() for a given pool sees the registry already in a terminal state — because the attack and its resolution both occurred between two calls, or because no gated function (stake, withdraw, contributeBonus, pokeRiskWindow) was called while the registry was mid-attack — then:
_isActiveRiskState(CORRUPTED) → false → riskWindowStart.
Impact: High — this directly compounds into the fund-misdirection finding already raised on claimExpired(). A CORRUPTED pool that hits this skip path can never satisfy riskWindowStart != 0, so it is permanently routed to the EXPIRED payout path instead of sweeping funds to recoveryAddress, no matter how many more times any function is called afterward.
Mitigation
Back-fill riskWindowStart in the same call that observes a terminal state, if it hasn't already been set:
This ensures riskWindowStart is always set by the time riskWindowEnd is, regardless of whether the registry was observed mid-attack first or jumped straight to a terminal state — closing the desynchronization entirely.
Bad
function _observePoolState() internal {
IAttackRegistry.ContractState state = _getAgreementState();
if (_isActiveRiskState(state)) {
if (riskWindowStart == 0) _markRiskWindowStart();
}
if (_isTerminalState(state)) {
if (riskWindowEnd == 0) _markRiskWindowEnd();
}
}
Fixed
function _observePoolState() internal {
IAttackRegistry.ContractState state = _getAgreementState();
if (riskWindowStart == 0 && _isActiveRiskState(state)) {
_markRiskWindowStart();
}
if (riskWindowStart == 0 && _isTerminalState(state)) {
_markRiskWindowStart(); // back-fill using the same capped-at-expiry logic
}
if (riskWindowEnd == 0 && _isTerminalState(state)) {
_markRiskWindowEnd();
}
}
Test
contract ObservePoolStatePoCTest is Test {
MockAttackRegistry registry;
uint32 expiry;
function setUp() public {
registry = new MockAttackRegistry();
expiry = uint32(block.timestamp + 7 days);
}
/// @notice Core PoC: the registry jumps straight to CORRUPTED before
/// _observePoolState() is ever called on this pool (nobody staked,
/// withdrew, or poked during the active-risk window). The very first
/// observation sees a terminal state directly, permanently desyncing
/// riskWindowStart (stays 0) from riskWindowEnd (gets set).
function test_Bug_TerminalStateSkipPermanentlyZeroesRiskWindowStart() public {
ObservePoolStateHarnessBuggy pool = new ObservePoolStateHarnessBuggy(registry, expiry);
// Registry silently transitions all the way to CORRUPTED off-chain
// from this pool's perspective — no intermediate call ever observes
// UNDER_ATTACK or PROMOTION_REQUESTED.
registry.setState(IAttackRegistry.ContractState.CORRUPTED);
assertEq(pool.riskWindowStart(), 0, "never set, no prior call observed active-risk state");
assertEq(pool.riskWindowEnd(), 0, "not yet observed at all");
// First-ever observation call sees the terminal state directly.
pool.observePoolState();
// BUG CONFIRMED: end got set, start did not — permanently inconsistent pair.
assertEq(pool.riskWindowStart(), 0, "start permanently stuck at 0, no code path can ever set it now");
assertTrue(pool.riskWindowEnd() != 0, "end WAS set from the same terminal observation");
// This is the structural root cause of the claimExpired() bug: any
// logic gated on `riskWindowStart != 0` to confirm CORRUPTED will
// never fire for this pool, no matter how many more times
// observePoolState() or claimExpired() is called later.
vm.warp(block.timestamp + 1 days);
pool.observePoolState(); // calling again changes nothing
assertEq(pool.riskWindowStart(), 0, "still stuck, confirming there is no recovery path");
}
/// @notice Control: when the registry DOES pass through an observed
/// active-risk state first (the "expected sequence"), both flags get set
/// correctly and consistently, at the right respective moments.
function test_Control_ExpectedSequenceSetsBothFlagsConsistently() public {
ObservePoolStateHarnessBuggy pool = new ObservePoolStateHarnessBuggy(registry, expiry);
registry.setState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.observePoolState();
uint32 observedStart = pool.riskWindowStart();
assertTrue(observedStart != 0, "start correctly set while active-risk");
assertEq(pool.riskWindowEnd(), 0, "end not yet set, still active-risk");
vm.warp(block.timestamp + 1 hours);
registry.setState(IAttackRegistry.ContractState.CORRUPTED);
pool.observePoolState();
assertEq(pool.riskWindowStart(), observedStart, "start unchanged, already set earlier");
assertTrue(pool.riskWindowEnd() != 0, "end correctly set now that terminal state is observed");
}
/// @notice Fixed contract: the exact same "jump straight to CORRUPTED,
/// never observed mid-attack" scenario as the core PoC, but the patched
/// _observePoolState() back-fills riskWindowStart in the same call that
/// sets riskWindowEnd, so the pair never desynchronizes.
function test_Fix_TerminalObservationBackFillsStartInSameCall() public {
ObservePoolStateHarnessFixed pool = new ObservePoolStateHarnessFixed(registry, expiry);
registry.setState(IAttackRegistry.ContractState.CORRUPTED);
assertEq(pool.riskWindowStart(), 0);
assertEq(pool.riskWindowEnd(), 0);
pool.observePoolState();
// FIX CONFIRMED: both get set together, from the same terminal observation.
assertTrue(pool.riskWindowStart() != 0, "back-filled in the same call");
assertTrue(pool.riskWindowEnd() != 0, "set as before");
assertEq(pool.riskWindowStart(), pool.riskWindowEnd(), "both stamped at the same observation timestamp");
}
}
The contest is live. Earn rewards by submitting a finding.
This is your time to appeal against judgements on your submissions.
View preliminary resultsAppeals are being carefully reviewed by our judges.
The contest is complete and the rewards are being distributed.