Root + Impact
Description
-
The protocol is designed to hold stakers' capital at risk only up to the pool's expiry timestamp. Under normal conditions, if the agreement survives the term without corruption, stakers should be able to resolve the pool and claim their principal.
However, because the claimExpired() function queries the external registry state live at the transaction execution time instead of evaluating the state at the moment of expiry, any post-expiry contract breach that transitions the registry to CORRUPTED will block stakers from reclaiming their principal and allow them to be slashed.
function claimExpired() external nonReentrant {
if (block.timestamp < expiry) revert PoolNotExpired();
if (outcome != PoolStates.Outcome.UNRESOLVED && outcome != PoolStates.Outcome.EXPIRED) {
revert InvalidOutcome();
}
if (outcome == PoolStates.Outcome.UNRESOLVED) {
IAttackRegistry.ContractState state = _observePoolState();
if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}
outcome = PoolStates.Outcome.CORRUPTED;
outcomeFlaggedAt = riskWindowEnd;
corruptedReserve = snapshotTotalStaked + snapshotTotalBonus;
claimsStarted = true;
emit OutcomeFlagged(address(0), PoolStates.Outcome.CORRUPTED, false, address(0));
return;
}
Risk
Likelihood:
The registry transitions to CORRUPTED after the pool's expiry timestamp has passed.
Stakers or keepers do not execute the claimExpired() transaction immediately in the block of expiry, leaving the pool in an UNRESOLVED state when the post-expiry transition occurs.
Impact:
Stakers are permanently blocked from claiming their principal via the EXPIRED resolution path.
The entire staked pool balance is swept to the recoveryAddress, resulting in a $100%$ loss of funds for stakers for a breach that occurred outside the insured term.
Proof of Concept
-> Add this poc in test suite and run the poc to prove this vulnerability :
pragma solidity 0.8.26;
import {Test} from "forge-std/Test.sol";
import {console2} from "forge-std/console2.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract PostExpiryRaceTest is BaseConfidencePoolTest {
function test_postExpiryCorruptSlashesStakers() external {
_stake(alice, 100 * ONE);
_passThroughUnderAttack();
uint256 poolExpiry = pool.expiry();
vm.warp(poolExpiry + 10);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(alice);
vm.expectRevert(IConfidencePool.AgreementCorruptedAwaitingModerator.selector);
pool.claimExpired();
console2.log("Alice blocked from claiming expired pool due to post-expiry registry corruption.");
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
uint256 recoveryBalanceBefore = token.balanceOf(recovery);
pool.claimCorrupted();
uint256 recoveryBalanceAfter = token.balanceOf(recovery);
console2.log("Tokens swept to recovery address:", recoveryBalanceAfter - recoveryBalanceBefore);
assertEq(recoveryBalanceAfter - recoveryBalanceBefore, 100 * ONE);
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.CORRUPTED));
}
}
Recommended Mitigation
Since the external registry does not expose historical transition times, the pool cannot verify the exact block timestamp of corruption on-chain. If the registry is in UNDER_ATTACK at the moment of expiry, the pool should require the moderator to explicitly prove/flag that the breach happened before expiry.
Adjust the claimExpired() logic to prevent auto-resolution to CORRUPTED if the current time is past expiry and the outcome was not already resolved:
- if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
- if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
- revert AgreementCorruptedAwaitingModerator();
- }
- outcome = PoolStates.Outcome.CORRUPTED;
- outcomeFlaggedAt = riskWindowEnd;
- corruptedReserve = snapshotTotalStaked + snapshotTotalBonus;
- claimsStarted = true;
- emit OutcomeFlagged(address(0), PoolStates.Outcome.CORRUPTED, false, address(0));
- return;
- }