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

Stakers Can Be Slashed for Post-Expiry Breaches Due to Live Registry Queries in Resolution Paths 101/250

Author Revealed upon completion

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) {
// @> Read registry live, potentially long after expiry has passed
IAttackRegistry.ContractState state = _observePoolState();
// @> Reverts/locks if registry transitioned to CORRUPTED post-expiry
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:

  1. The registry transitions to CORRUPTED after the pool's expiry timestamp has passed.

  2. 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:

  1. Stakers are permanently blocked from claiming their principal via the EXPIRED resolution path.

  2. 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 :

// SPDX-License-Identifier: MIT
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 {
// Alice stakes 100 tokens.
_stake(alice, 100 * ONE);
// Move the pool through UNDER_ATTACK (to seal riskWindowStart).
_passThroughUnderAttack();
// Warp the blockchain time past the pool's expiry.
uint256 poolExpiry = pool.expiry();
vm.warp(poolExpiry + 10);
// A breach occurs AFTER the expiry (e.g. at poolExpiry + 10).
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
// Alice attempts to claimExpired() to recover her funds but is blocked.
vm.prank(alice);
vm.expectRevert(IConfidencePool.AgreementCorruptedAwaitingModerator.selector);
pool.claimExpired();
console2.log("Alice blocked from claiming expired pool due to post-expiry registry corruption.");
// Moderator flags CORRUPTED.
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
// Sweep the funds to recoveryAddress.
uint256 recoveryBalanceBefore = token.balanceOf(recovery);
pool.claimCorrupted();
uint256 recoveryBalanceAfter = token.balanceOf(recovery);
console2.log("Tokens swept to recovery address:", recoveryBalanceAfter - recoveryBalanceBefore);
// Alice is slashed; she cannot get her tokens back.
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;
- }

Support

FAQs

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

Give us feedback!