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

Post-expiry registry changes can confiscate matured fund

Author Revealed upon completion

Post-expiry live registry reads can retroactively confiscate matured principal and bonus

Description

A pool that reaches expiry without corruption during its term should resolve as EXPIRED, allowing stakers to recover matured principal and any earned bonus.

claimExpired() instead reads the registry's current state after expiry. A later transition to CORRUPTED blocks expiry settlement during the moderator grace period. Once the grace period ends, any caller can resolve the pool as bad-faith CORRUPTED, after which the entire principal and bonus balance can be transferred to recoveryAddress.

Risk timestamps observed after expiry are also clamped to expiry. This makes a post-expiry registry transition appear to have occurred at the term boundary even though the contract has no proof that corruption occurred before expiry.

function claimExpired() external nonReentrant {
if (block.timestamp < expiry) revert PoolNotExpired();
if (outcome == PoolStates.Outcome.UNRESOLVED) {
// @> Reads the live registry state after the pool term has ended.
IAttackRegistry.ContractState state = _observePoolState();
// @> A post-expiry CORRUPTED state can replace the matured EXPIRED result.
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;
return;
}
}
}
function _markRiskWindowEnd() internal {
uint256 t = block.timestamp;
// @> A corruption first observed after expiry is recorded at expiry.
if (t > expiry) t = expiry;
riskWindowEnd = uint32(t);
}
function claimCorrupted() external nonReentrant {
uint256 toSweep = stakeToken.balanceOf(address(this));
// @> All remaining principal and bonus are transferred to recoveryAddress.
stakeToken.safeTransfer(recoveryAddress, toSweep);
}

Risk

Likelihood:

  • This occurs when a pool reaches expiry unresolved, has a nonzero risk marker, and the registry changes to CORRUPTED before the pool is settled as EXPIRED.

  • After MODERATOR_CORRUPTED_GRACE, resolution and the final sweep are permissionless.

Impact:

  • All unresolved staker principal and bonus can be transferred to recoveryAddress.

  • A registry event outside the pool term can destroy claims that matured at expiry.

Proof of Concept

Place PoC.t.sol in test/ and run:

forge test test/PoC.t.sol -vvv
function test_PostExpiryForfeit() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
vm.warp(block.timestamp + 1 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
assertLt(uint256(pool.riskWindowStart()), uint256(pool.expiry()));
vm.warp(uint256(pool.expiry()) + 1);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(dave);
vm.expectRevert(IConfidencePool.AgreementCorruptedAwaitingModerator.selector);
pool.claimExpired();
vm.warp(uint256(pool.expiry()) + pool.MODERATOR_CORRUPTED_GRACE() + 1);
vm.prank(dave);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.CORRUPTED));
assertEq(uint256(pool.riskWindowEnd()), uint256(pool.expiry()));
uint256 recoveryBefore = token.balanceOf(recovery);
pool.claimCorrupted();
assertEq(token.balanceOf(recovery) - recoveryBefore, 150 * ONE);
assertEq(token.balanceOf(address(pool)), 0);
}

Recommended Mitigation

Record whether corruption was actually observed during the pool term and use that evidence when resolving an expired pool. Do not use clamped bonus-accounting timestamps as proof of an in-term corruption.

+ uint32 public corruptedObservedAt;
+
function _observePoolState()
internal
returns (IAttackRegistry.ContractState state)
{
state = _getAgreementState();
+
+ if (
+ state == IAttackRegistry.ContractState.CORRUPTED
+ && corruptedObservedAt == 0
+ && block.timestamp <= expiry
+ ) {
+ corruptedObservedAt = uint32(block.timestamp);
+ }
if (riskWindowStart == 0 && _isActiveRiskState(state)) {
_markRiskWindowStart();
}
if (riskWindowEnd == 0 && _isTerminalState(state)) {
_markRiskWindowEnd();
}
}
- if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
+ if (
+ state == IAttackRegistry.ContractState.CORRUPTED
+ && corruptedObservedAt != 0
+ ) {
// Existing corrupted-resolution logic.
}

Support

FAQs

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

Give us feedback!