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) {
IAttackRegistry.ContractState state = _observePoolState();
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;
if (t > expiry) t = expiry;
riskWindowEnd = uint32(t);
}
function claimCorrupted() external nonReentrant {
uint256 toSweep = stakeToken.balanceOf(address(this));
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:
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.
}